From aa5b1bd867198670b002bbb04cf55e703f09692e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 31 May 2026 23:54:31 +0800 Subject: [PATCH 01/10] Complete USB passthrough Phase 2b/2c, resolve open questions, add sharing GUI Finish the cross-platform backends and settle every open design question so the protocol stack is feature-complete behind the default-off flag; only real-hardware verification and the external security sign-off remain. - WinUSB + IOKit backends implemented; IOKit enumerates natively and delegates transfers to libusb. Add default_passthrough_backend() factory. - Resolve OQ1-OQ8: reliable-ordered channel, frame fragmentation, LIST-over-channel, per-claim credits, WinUSB binding clarity, macOS notarisation path, Linux kernel-driver detach/reattach, and ACL HMAC-SHA256 integrity (fail-closed on tamper, pluggable key). - Add in-process UsbLoopback so one machine can share and use a device through the full protocol stack with no WebRTC channel. - Add AnyDesk-style USB Sharing panel; wire USB Browser Open for localhost via loopback. i18n across all four languages. - Lift the design doc out of DRAFT; refresh operator and security-review docs. Tests cover every new path; import je_auto_control stays Qt-free. --- .../usb_passthrough_design.rst | 212 ++++++---- .../usb_passthrough_operator_guide.rst | 66 ++- .../usb_passthrough_security_review.rst | 26 +- .../usb_passthrough_design.rst | 155 ++++--- .../usb_passthrough_operator_guide.rst | 56 ++- .../usb_passthrough_security_review.rst | 21 +- .../gui/language_wrapper/english.py | 39 ++ .../gui/language_wrapper/japanese.py | 39 ++ .../language_wrapper/simplified_chinese.py | 39 ++ .../language_wrapper/traditional_chinese.py | 39 ++ je_auto_control/gui/main_widget.py | 3 + je_auto_control/gui/usb_browser_tab.py | 130 +++++- je_auto_control/gui/usb_passthrough_panel.py | 399 ++++++++++++++++++ je_auto_control/utils/usb/__init__.py | 16 +- .../utils/usb/passthrough/__init__.py | 27 +- je_auto_control/utils/usb/passthrough/acl.py | 153 ++++++- .../utils/usb/passthrough/backend.py | 80 ++++ .../utils/usb/passthrough/iokit_backend.py | 241 ++++++++--- .../utils/usb/passthrough/loopback.py | 157 +++++++ .../utils/usb/passthrough/protocol.py | 26 +- .../utils/usb/passthrough/session.py | 76 +++- .../utils/usb/passthrough/viewer_client.py | 107 ++++- test/unit_test/headless/test_usb_acl.py | 72 ++++ test/unit_test/headless/test_usb_loopback.py | 79 ++++ .../headless/test_usb_passthrough.py | 87 ++++ .../test_usb_passthrough_list_fragment.py | 141 +++++++ .../headless/test_usb_passthrough_panel.py | 121 ++++++ .../headless/test_usb_platform_backends.py | 56 ++- 28 files changed, 2354 insertions(+), 309 deletions(-) create mode 100644 je_auto_control/gui/usb_passthrough_panel.py create mode 100644 je_auto_control/utils/usb/passthrough/loopback.py create mode 100644 test/unit_test/headless/test_usb_loopback.py create mode 100644 test/unit_test/headless/test_usb_passthrough_list_fragment.py create mode 100644 test/unit_test/headless/test_usb_passthrough_panel.py diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst index 83929901..f50309d5 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst @@ -1,37 +1,43 @@ ================================================ -USB Passthrough — Phase 2 Design (DRAFT) +USB Passthrough — Phase 2 Design ================================================ -.. warning:: - **DRAFT — Linux-libusb path complete; cross-platform backends are - structural skeletons only.** +.. note:: + **All software-side work is complete; the eight open questions are + resolved (see "Design decisions").** Two items remain that cannot + be completed in code: **verification against real USB hardware** and + the **external human security sign-off (Phase 2e)**. The feature + flag stays default-off until both are done. - **Shipped (rounds 27 / 34 / 37 / 39 / 40 / 41 / 42):** + **Done and shipped (rounds 27 / 34 / 37 / 39 / 40 / 41 / 42 / 43):** Phase 1 (read-only enumeration), Phase 1.5 (hotplug events), Phase 2a (protocol + ABCs + ``LibusbBackend`` lifecycle + ``FakeUsbBackend`` for tests + feature flag, default off), Phase 2a.1 (full ``LibusbBackend`` transfers + CREDIT-based inbound flow control + audit hooks), **viewer-side ``UsbPassthroughClient``** (blocking - open / control_transfer / bulk_transfer / interrupt_transfer / close - with outbound credit waits and shutdown propagation), + open / control_transfer / bulk_transfer / interrupt_transfer / close / + list_devices with outbound credit waits and shutdown propagation), Phase 2d (``UsbAcl`` persistent allow-list, ACL-gated OPEN with prompt-callback path, audit-log integration via the existing - tamper-evident chain). + tamper-evident chain), Phase 2d.1 (ACL file HMAC-SHA256 integrity, + fail-closed on tamper). - **Structural-only:** ``WinusbBackend`` (Phase 2b) and - ``IokitBackend`` (Phase 2c) — class scaffolding + platform / - dependency validation in place; ``list`` and ``open`` raise - ``NotImplementedError`` referencing the in-module TODO list. - These need ctypes / pyobjc wiring **plus hardware testing** to - become real. + **Phase 2b — Windows ``WinUSB``:** SetupAPI enumeration + ``WinUsb_*`` + transfer ctypes wiring complete (**hardware-unverified**). - **Process step:** Phase 2e — see - :doc:`usb_passthrough_security_review` for the reviewer - checklist that must be signed before the feature flag flips - to default-on. + **Phase 2c — macOS ``IOKit``:** native IOKit enumeration via ctypes + complete; device claim / transfers delegate to libusb (the + hardware-proven path on macOS). See the ``iokit_backend`` module + docstring (**hardware-unverified**). - Open questions stay flagged inline as ``OPEN`` for reviewers. + **Backend selection:** ``default_passthrough_backend()`` picks + WinUSB / IOKit / libusb by OS automatically. + + **Remaining process step:** Phase 2e — see + :doc:`usb_passthrough_security_review` for the reviewer checklist + that must be signed by an external reviewer before the feature flag + flips to default-on, plus the per-backend hardware test matrix. .. contents:: :local: @@ -76,8 +82,13 @@ than they tolerate loss; the existing video/audio channels already demonstrate that the underlying SCTP transport handles ordered reliable streams adequately. -OPEN: Should we use ``maxPacketLifeTime`` instead, with a generous -budget (~5 s)? Worth measuring on real WAN links before shipping. +**Resolved (OQ1):** keep ``ordered=True`` + ``maxRetransmits=None`` +(fully reliable, ordered). USB control transfers (WebAuthn signing, +descriptor reads) have zero tolerance for loss — a partially-lost +stream corrupts the device state machine — so reliable-ordered +semantics matter more than shaving a few ms of retransmit latency. +The bounded-loss ``maxPacketLifeTime`` model is left for a future +loss-tolerant high-throughput use case (YAGNI today). Framing ------- @@ -96,10 +107,13 @@ Each channel message is one length-prefixed protocol frame:: - payload: opcode-specific. Bounded to 16 KiB to keep DataChannel message sizes reasonable. -OPEN: Do we need fragmentation above 16 KiB? Most USB transfers fit; -control transfers are bounded by the device's wMaxPacketSize. A -follow-up frame with the same ``claim_id`` and a continuation flag -would be a low-cost addition. +**Resolved (OQ2):** fragmentation implemented. +``protocol.fragment_payload()`` splits a payload larger than 16 KiB +into multiple same-``claim_id`` frames, clearing ``FLAG_EOF`` on all +but the last; the receiver concatenates consecutive frame payloads +until it sees EOF. Both transfer replies and ``LIST`` replies use this +path; the common single-frame case still sends exactly one EOF-flagged +frame, so existing behaviour is unchanged. Operations ---------- @@ -119,10 +133,13 @@ Op (hex) Direction Purpose ``0xFF ERROR`` either Protocol error / unsupported op ================ ========================================= ============== -OPEN: Should ``LIST`` go through the channel at all, or should the -viewer use the existing REST ``/usb/devices`` endpoint and only use -the channel for transfers? The latter is simpler but couples the -two transports. +**Resolved (OQ3):** ``LIST`` goes over the channel. The session +handles a ``LIST`` frame and returns the devices the ACL would not +deny (a denied device is never even revealed to the viewer); the +viewer calls ``UsbPassthroughClient.list_devices()``. Enumeration and +transfers share one already-authenticated channel instead of coupling +in a second REST transport, and ACL filtering reuses the same logic as +the claim decision. Backpressure ------------ @@ -132,9 +149,13 @@ Each side starts with a credit window of 16 outstanding frames per message with a positive integer replenishes. Without flow control a slow remote USB device would balloon DataChannel send buffers. -OPEN: Should credits be per-endpoint (IN/OUT separately) instead of -per-claim? Bulk endpoints are independent, so per-endpoint is more -faithful to the hardware. Costs more state. +**Resolved (OQ4):** keep per-claim credits. They already meet the core +goal — stopping a slow remote device from ballooning the host send +buffer — with the least state and the simplest reasoning. +Per-endpoint credits would track IN/OUT and each bulk endpoint +separately for a meaningful complexity jump that only matters when +multiple endpoints on one claim saturate at once; that is YAGNI until +real measurement shows head-of-line blocking. Per-OS driver wrappers @@ -166,25 +187,33 @@ Windows — WinUSB - ``ctypes`` wrappers around ``winusb.dll`` are public API; no kernel driver authoring required. -OPEN: WinUSB requires the device to be *not already claimed* by another -driver. This rules out devices that the host OS thinks it owns -(printers, hubs, keyboards). We will need an in-app prompt explaining -why a particular device cannot be claimed. +**Resolved (OQ5):** WinUSB requires the device to be *not already +claimed* by another driver, and only devices already bound to +``winusb.sys`` appear in ``WinusbBackend.list()``. Devices the host OS +owns (printers, hubs, keyboards) therefore never list — the viewer +only ever sees the claimable subset and cannot mistake a system device +for one it could claim. An OPEN for a vid/pid not in the list returns a +clear ``no device matches`` error; the operator guide explains binding +a device to WinUSB via Zadig / libwdi. macOS — IOKit ------------- -- ``IOUSBHostInterface`` (modern, since 10.12) or ``IOUSBInterfaceInterface`` - (older but ubiquitous) via ``pyobjc``. -- Requires entitlement signing if shipped through the App Store; for - dev / direct distribution this is fine but the binary must be - notarised. -- IOKit's ``CompletionMethod`` callbacks integrate with ``CFRunLoop``, - not asyncio. We will need a thread that owns the runloop and - marshals completions back to the WebRTC bridge thread. - -OPEN: System Integrity Protection blocks claiming Apple devices and -some USB-C peripherals. Document the limit clearly. +- Enumeration uses native IOKit through ``ctypes`` (no ``pyobjc`` + dependency): ``IOServiceGetMatchingServices`` over ``IOUSBDevice`` + plus ``IORegistryEntryCreateCFProperty`` reads of idVendor / idProduct + / serial / locationID. +- Claim and transfers delegate to libusb (see OQ6) rather than + hand-rolling the COM-style ``IOUSBHostInterface`` plugin vtable. + +**Resolved (OQ6):** enumeration is native IOKit; claim / transfers +delegate to libusb — the hardware-proven USB path on macOS — which +avoids hand-coding an ``IOUSBHostInterface`` plugin vtable that could +not be verified without hardware. A directly distributed (non +App Store) build must be notarised; libusb device access needs no +special entitlement, but System Integrity Protection still hides Apple +internal devices and some USB-C peripherals. The operator guide +documents the SIP exclusion boundary. Linux — libusb -------------- @@ -194,10 +223,13 @@ Linux — libusb - Hot-detach handling: libusb fires ``LIBUSB_TRANSFER_NO_DEVICE`` on in-flight transfers; we map that to ``CLOSED`` on the channel. -OPEN: Some distros default to attaching ``usbhid`` to anything that -looks like a HID. We must call ``libusb_detach_kernel_driver`` and, -on close, ``libusb_attach_kernel_driver`` to restore — otherwise the -host OS loses input devices. +**Resolved (OQ7):** implemented. ``_LibusbHandle`` calls +``detach_kernel_driver`` for each interface of the active configuration +that the kernel actually holds on open, remembers which it touched, and +``attach_kernel_driver`` restores them on close — otherwise the host OS +permanently loses its keyboard / mouse after the session. libusb on +Windows / macOS raises ``NotImplementedError`` for detach, which is +tolerated and skipped (those platforms arbitrate drivers in the OS). Security & ACL @@ -226,9 +258,18 @@ Stored in ``~/.je_auto_control/usb_acl.json``:: - Allow rules can be persisted with a "remember" checkbox in the prompt. -OPEN: Should we sign or HMAC the ACL file so a compromised host -process cannot silently grant itself access? Probably yes, with a -master key derived from a user passphrase or platform keychain. +**Resolved (OQ8):** HMAC-SHA256 implemented. The ACL carries a sidecar +``.sig`` signature, verified on load; a mismatch fails closed +(default-deny, ``integrity_ok`` False) so a process that silently +rewrites the JSON cannot grant itself access without also forging the +signature. The signing key is pluggable — a deployment can pass a +keychain-derived key via the constructor's ``hmac_key=``; absent that, +a random key file is generated next to the ACL (``0o600`` on POSIX). +Note: a same-user process can still read the key file and forge a +signature, so keychain-derived keys are recommended for high-assurance +deployments (see operator guide). Files written before signing existed +are treated as legacy (still load, signed on next save); pass +``require_signature=True`` to reject unsigned files. Audit ----- @@ -251,28 +292,41 @@ Phasing 1. **Done — Phase 1**: read-only enumeration (``list_usb_devices``). 2. **Done — Phase 1.5**: hotplug events (``UsbHotplugWatcher``, ``/usb/events``). -3. **Phase 2a (this design)**: protocol skeleton + ``UsbBackend`` ABC - + Linux ``libusb`` backend behind a feature flag. -4. **Phase 2b**: Windows ``WinUSB`` backend. -5. **Phase 2c**: macOS ``IOKit`` backend. -6. **Phase 2d**: ACL persistence + host-side prompt UI + audit - integration. -7. **Phase 2e**: external security review *before* default-on. - -Each subphase is its own multi-round project. Estimated effort -(experienced contributor): ~1 week per backend, ~1 week for ACL/UI, -plus the security review which depends on a reviewer's calendar. - - -Open questions, summarised -========================== - -1. ``maxRetransmits=None`` vs ``maxPacketLifeTime`` for the channel. -2. Frame fragmentation above 16 KiB. -3. ``LIST`` over the channel vs. exclusively over REST. -4. Backpressure granularity (per-claim vs per-endpoint). -5. What WinUSB cannot claim, and how to communicate that to the - viewer. -6. macOS entitlement story for non-App-Store distribution. -7. Linux kernel-driver detach/reattach lifecycle. -8. ACL file integrity (HMAC vs platform keychain). +3. **Done — Phase 2a**: protocol + ``UsbBackend`` ABC + Linux + ``libusb`` backend behind a feature flag. +4. **Done — Phase 2b**: Windows ``WinUSB`` backend (ctypes, + hardware-unverified). +5. **Done — Phase 2c**: macOS ``IOKit`` backend (native enumeration + + libusb transfers, hardware-unverified). +6. **Done — Phase 2d / 2d.1**: ACL persistence + host-side prompt + callback + audit integration + ACL file HMAC integrity. +7. **In progress — Phase 2e**: external security review *before* + default-on, **plus** the per-backend real-hardware test matrix. + Both inherently require hardware and an external reviewer and cannot + be completed in code alone. + +The feature flag stays default-off until Phase 2e is signed off. + + +Design decisions (formerly open questions) +========================================== + +All eight original open questions are resolved; see the matching +sections above for the implementation. + +1. **OQ1 — channel reliability**: ``maxRetransmits=None`` (fully + reliable, ordered). +2. **OQ2 — frame fragmentation**: implemented via ``fragment_payload`` + + EOF reassembly. +3. **OQ3 — ``LIST`` over the channel**: yes, ACL-filtered, over the + channel. +4. **OQ4 — backpressure granularity**: per-claim (per-endpoint is + YAGNI). +5. **OQ5 — what WinUSB cannot claim**: only WinUSB-bound devices list; + a failed claim returns a clear error. +6. **OQ6 — macOS distribution**: native IOKit enumeration + libusb + transfers; notarisation, no special entitlement, SIP boundary + documented. +7. **OQ7 — Linux kernel driver**: detach on open, reattach on close. +8. **OQ8 — ACL integrity**: HMAC-SHA256 sidecar, pluggable + (keychain-capable) key. diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index 72238d70..c25a6982 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -3,9 +3,11 @@ USB Passthrough — Operator Guide ============================================================ Step-by-step recipe for getting a USB device on a host machine to -respond to traffic from a remote viewer. Assumes Phase 2a.1 (current -shipping state) — host-side end-to-end works on Linux libusb; Windows -WinUSB is hardware-unverified; macOS IOKit is not yet implemented. +respond to traffic from a remote viewer. Host-side end-to-end works on +Linux libusb; Windows WinUSB and macOS IOKit are implemented but +**hardware-unverified** — both must pass the Phase 2e hardware test +matrix before production use. ``default_passthrough_backend()`` picks +the right backend for the current OS. If you're a security reviewer instead of an operator, you want :doc:`usb_passthrough_security_review`. If you're a developer wanting @@ -76,11 +78,21 @@ hardware. Treat as alpha. Steps: 3. Hardware testing is required before relying on transfers. See the security review checklist for the expected test matrix. -macOS (IOKit) — *not yet implemented* +macOS (IOKit) — *hardware-unverified* ------------------------------------- -The skeleton exists; ``IokitBackend()`` constructs but ``list`` / -``open`` raise ``NotImplementedError``. Track Phase 2c. +``IokitBackend`` enumerates USB devices natively through IOKit +(``ctypes``; no pyobjc needed), so ``IokitBackend().list()`` works. +Claiming a device for transfers delegates to libusb, so install it: +``pip install pyusb`` and ``brew install libusb``. Notes: + +1. A directly distributed (non App Store) build must be notarised. + libusb device access needs no special entitlement. +2. System Integrity Protection hides Apple internal devices and some + USB-C peripherals — they will not appear in ``list()`` and cannot be + claimed. This is expected. +3. Transfers are hardware-unverified; see the security review checklist + for the expected test matrix before relying on them. Enabling the feature @@ -129,7 +141,10 @@ operator hasn't approved. Add per-device rules: 3. By editing ``~/.je_auto_control/usb_acl.json`` directly. The file is permission-checked (mode ``0600`` on POSIX). Bad JSON or an - unknown ``version`` falls back to default-deny. + unknown ``version`` falls back to default-deny. **If you hand-edit + the file, the HMAC signature will no longer match and the ACL fails + closed** (see below) — re-save through ``UsbAcl`` instead, which + refreshes the signature. Decision precedence: @@ -138,6 +153,23 @@ Decision precedence: - If no rule matches, the file's ``default`` (``"deny"`` out of the box) applies. +ACL file integrity (HMAC) +------------------------- + +The ACL is protected by an HMAC-SHA256 signature stored in a sidecar +``usb_acl.json.sig``. On load the signature is verified against the +file bytes; a mismatch makes the ACL **fail closed** (default-deny, +``UsbAcl.integrity_ok`` reports ``False``). This stops a process that +silently rewrites the JSON from granting itself access. + +- By default the signing key is a random 32-byte file + ``usb_acl.json.key`` (mode ``0600`` on POSIX), created on first save. +- For higher assurance, derive the key from a platform keychain and + pass it explicitly: ``UsbAcl(hmac_key=)``. A same-user process + that can read the key file could otherwise forge a signature. +- Pass ``UsbAcl(require_signature=True)`` to reject even legacy + unsigned files outright. + Starting the host ================= @@ -241,11 +273,19 @@ Audit chain shows ``broken_at_id`` Someone edited ``audit.db`` directly What is *not* shipped yet ========================= -- WebRTC viewer GUI does not auto-wire the ``usb`` DataChannel — the - *USB Browser* tab's *Open* button shows a "not yet wired" message. - You can drive the protocol from Python today. -- Windows WinUSB transfer methods are written but not validated - against real hardware. Do not use in production. -- macOS IOKit backend is unimplemented (Phase 2c). +- The *USB Sharing* tab is the simple, AnyDesk-style surface: enable + sharing on the left and Allow / Block local devices in the ACL; on the + right, list the shared devices over the in-process channel and *Open* + one (a descriptor read proves the full stack). The *USB Browser* tab's + *Open* button now also works against a **localhost** target via the + same loopback path. +- Cross-machine open still needs the WebRTC ``usb`` DataChannel, which + the viewer GUI does not yet auto-wire — against a remote host the + *Open* button shows a "not yet wired" message. You can drive the + protocol from Python today (including + ``UsbPassthroughClient.list_devices()`` over the channel). +- Windows WinUSB and macOS IOKit transfer paths are written but not yet + validated against real hardware. Do not use in production until the + Phase 2e hardware test matrix passes. - Phase 2e external security review has not been signed; the feature flag must remain explicit opt-in. diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_security_review.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_security_review.rst index 6479fa9e..11d78ab6 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_security_review.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_security_review.rst @@ -50,9 +50,15 @@ ACL ``test_save_persists_to_disk_with_safe_mode``). - [ ] Recommend storing the ACL on a filesystem that supports POSIX permissions; document the Windows ACL story in the deploy guide. -- [ ] **OPEN question 8 — ACL integrity (HMAC / keychain)**. Currently - a process running as the user can rewrite the ACL silently. If - that's not acceptable, file the follow-up project before sign-off. +- [ ] **OQ8 — ACL integrity (HMAC) implemented**. The ACL carries an + HMAC-SHA256 sidecar signature and fails closed on tamper (tests + ``test_tampered_acl_file_fails_closed``, + ``test_explicit_key_roundtrip_and_wrong_key_fails``). **Residual + risk:** the default key lives in a same-user-readable + ``usb_acl.json.key``; a same-identity process can still forge a + signature. High-assurance deployments should pass a + keychain-derived key via ``UsbAcl(hmac_key=...)`` — confirm + whether the deployment does. Audit @@ -131,11 +137,15 @@ Per-OS requirements - [ ] **Linux libusb**: ``libusb_detach_kernel_driver`` invoked before a HID device is claimed; reattached on close. Confirm host OS keyboard / mouse remains functional after a session. -- [ ] **Windows WinUSB** (Phase 2b — *not yet shipped*): the device - must already be associated with WinUSB (Zadig / libwdi). - Document the operator-facing instructions. -- [ ] **macOS IOKit** (Phase 2c — *not yet shipped*): notarisation - story for non-App-Store distribution. Document SIP exclusions. +- [ ] **Windows WinUSB** (Phase 2b — *implemented, hardware-unverified*): + the device must already be associated with WinUSB (Zadig / + libwdi); only bound devices appear in ``list()``. Sign off only + after running the bulk / HID / composite test matrix on real + hardware. +- [ ] **macOS IOKit** (Phase 2c — *implemented, hardware-unverified*): + native IOKit enumeration + libusb transfers. Notarisation for + non-App-Store distribution; document SIP exclusions. Sign off only + after running the test matrix on real hardware. - [ ] All three backends: opening a device that another driver owns surfaces as a clear "busy" RuntimeError, not a hang or crash. diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst index 3701773c..2b41e1a1 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst @@ -1,31 +1,38 @@ ================================================ -USB Passthrough — 第二階段設計(DRAFT) +USB Passthrough — 第二階段設計 ================================================ -.. warning:: - **DRAFT — Linux-libusb 路徑完成;跨平台 backend 為結構骨架。** +.. note:: + **軟體面已全部完成;八個未決問題全部拍板(見「設計決策」)。** + 剩餘兩項本質上無法靠寫程式完成:**真實 USB 硬體驗證** 與 + **外部人員的安全 sign-off(Phase 2e)**。在這兩項完成前, + feature flag 維持預設 off。 - **已發布(rounds 27 / 34 / 37 / 39 / 40 / 41 / 42):** + **已完成並發布(rounds 27 / 34 / 37 / 39 / 40 / 41 / 42 / 43):** Phase 1(唯讀列舉)、Phase 1.5(hotplug events)、Phase 2a (協定 + ABC + ``LibusbBackend`` lifecycle + 給測試用的 ``FakeUsbBackend`` + feature flag,預設 off)、Phase 2a.1 (完整 ``LibusbBackend`` 傳輸 + CREDIT-based 入站流量控制 + 稽核 hook)、**viewer 端 ``UsbPassthroughClient``**\ (阻塞式 - open / control_transfer / bulk_transfer / interrupt_transfer / close - 含 outbound credit 等待與 shutdown 傳播)、Phase 2d + open / control_transfer / bulk_transfer / interrupt_transfer / close / + list_devices,含 outbound credit 等待與 shutdown 傳播)、Phase 2d (``UsbAcl`` 持久化白名單、ACL-gated OPEN 含 prompt-callback、 - 稽核紀錄整合到既有的 tamper-evident 鏈)。 + 稽核紀錄整合到既有的 tamper-evident 鏈)、Phase 2d.1 + (ACL 檔案 HMAC-SHA256 完整性,竄改則 fail-closed)。 - **結構骨架:** ``WinusbBackend``\ (Phase 2b)與 - ``IokitBackend``\ (Phase 2c)— class 骨架 + 平台/相依驗證已就位; - ``list`` 與 ``open`` 拋 ``NotImplementedError`` 並指向模組內 - TODO 清單。這兩者需要 ctypes / pyobjc 接線 **加上硬體測試** 才能 - 真正運作。 + **Phase 2b — Windows ``WinUSB``:** SetupAPI 列舉 + ``WinUsb_*`` + 傳輸的 ctypes 接線已完成(**硬體未驗證**)。 - **流程步驟:** Phase 2e — 見 :doc:`usb_passthrough_security_review` - 的審查者清單;feature flag 翻成預設 on 之前必須簽核。 + **Phase 2c — macOS ``IOKit``:** 透過 ctypes 的原生 IOKit 列舉已 + 完成;裝置 claim/傳輸委派給 libusb(macOS 上經硬體驗證的路徑)。 + 詳見 ``iokit_backend`` 模組說明(**硬體未驗證**)。 - 未決問題在內文中以 ``OPEN`` 標示,方便 reviewer 集中。 + **backend 選擇:** ``default_passthrough_backend()`` 依 OS 自動挑 + WinUSB / IOKit / libusb。 + + **剩餘流程:** Phase 2e — 見 :doc:`usb_passthrough_security_review` + 的審查者清單;feature flag 翻成預設 on 之前必須由外部人員簽核, + 且三個 backend 都需在真實硬體上跑過測試矩陣。 .. contents:: :local: @@ -67,8 +74,11 @@ USB 的 bulk 與 interrupt 傳輸對延遲的容忍度遠高於對遺失的容 既有的 video/audio channel 也已示範底層 SCTP 傳輸足以承擔有序可靠 串流。 -OPEN:是否應改用 ``maxPacketLifeTime``,給寬鬆預算(~5 秒)? -出貨前在真實 WAN 連線上測量看看再決定。 +**已決(OQ1):** 維持 ``ordered=True`` + ``maxRetransmits=None`` +(完全可靠有序)。USB control 傳輸(如 WebAuthn 簽章、descriptor +讀取)對遺失零容忍,部分遺失的串流會讓裝置狀態機壞掉;可靠有序的 +語意比節省幾毫秒重傳延遲重要。``maxPacketLifeTime`` 的有界遺失模型 +留給未來若出現純高吞吐、可容忍遺失的使用情境再評估(目前 YAGNI)。 Framing ------- @@ -87,9 +97,11 @@ Framing - payload:依 opcode 不同。上限 16 KiB 以維持 DataChannel 訊息 尺寸合理。 -OPEN:需要超過 16 KiB 的 fragmentation 嗎?多數 USB 傳輸都裝得下; -control 傳輸受裝置的 wMaxPacketSize 限制。後續 frame 用相同 -``claim_id`` 加 continuation flag 是低成本的擴充。 +**已決(OQ2):** 已實作分片。``protocol.fragment_payload()`` 把超過 +16 KiB 的 payload 切成多個同 ``claim_id`` 的 frame,除最後一個外都清掉 +``FLAG_EOF``;接收端串接連續 frame 的 payload 直到看到 EOF。傳輸回覆 +與 ``LIST`` 回覆都走這條路;single-frame 的常見情形仍只送一個帶 EOF +的 frame,行為與先前一致。 操作 ---- @@ -109,9 +121,11 @@ Op (hex) 方向 用途 ``0xFF ERROR`` 雙向 協定錯誤/不支援 op ================ ===================================== ====================== -OPEN:``LIST`` 該走 channel,還是讓 viewer 用既有 REST -``/usb/devices`` 端點而 channel 只負責傳輸?後者比較簡單但耦合 -兩層 transport。 +**已決(OQ3):** ``LIST`` 走 channel。``session`` 處理 ``LIST`` frame +並回傳 ACL 不會 deny 的裝置(被 deny 的裝置連存在都不讓 viewer 知道); +viewer 端用 ``UsbPassthroughClient.list_devices()`` 取得。讓列舉與傳輸 +共用同一條已通過 auth gate 的 channel,避免再耦合一層 REST transport, +也讓 ACL 過濾與 claim 決策走同一份邏輯。 Backpressure ------------- @@ -121,8 +135,11 @@ Backpressure 沒有流量控制的話,慢的遠端 USB 裝置會把 DataChannel 送出 buffer 撐爆。 -OPEN:credit 該按 endpoint(IN/OUT 各別)還是按 claim?bulk -endpoint 是獨立的,按 endpoint 比較貼近硬體,但需要更多狀態。 +**已決(OQ4):** 維持 per-claim credit。它已足以達成核心目的—— +防止慢速遠端裝置撐爆 host 送出 buffer——而且狀態最少、推理最簡單。 +per-endpoint credit 會把 IN/OUT、多 bulk endpoint 各自記帳,複雜度 +明顯上升卻只在單一 claim 內多個 endpoint 同時飽和時才有差別;屬於 +YAGNI,待真實量測出現 head-of-line 問題再說。 各 OS driver 包裝 @@ -153,9 +170,12 @@ Windows — WinUSB - ``ctypes`` 包 ``winusb.dll`` 的 wrapper 是 public API;不需要 寫 kernel driver。 -OPEN:WinUSB 要求裝置 *尚未被別的 driver claim*。這排除了 host OS -認為自己擁有的裝置(印表機、hub、鍵盤)。需要在 app 內顯示為何某 -些裝置 claim 不到的提示。 +**已決(OQ5):** WinUSB 要求裝置 *尚未被別的 driver claim*,且只有 +已綁定 ``winusb.sys`` 的裝置會出現在 ``WinusbBackend.list()`` 中。因此 +host OS 自己擁有的裝置(印表機、hub、鍵盤)根本不會列出來——viewer +看到的就是「可 claim」的子集,不會誤以為能 claim 系統裝置。若 OPEN +的 vid/pid 不在清單中,host 回明確的 ``no device matches`` 錯誤; +operator guide 說明如何用 Zadig / libwdi 綁定裝置到 WinUSB。 macOS — IOKit ------------- @@ -168,8 +188,12 @@ macOS — IOKit asyncio。需要一個專屬 thread 持有 runloop,把 completion marshal 回 WebRTC bridge thread。 -OPEN:System Integrity Protection 會擋 Apple 自家裝置與某些 USB-C -週邊。要清楚記載這個界線。 +**已決(OQ6):** 列舉走原生 IOKit(ctypes,不需 pyobjc);claim/ +傳輸委派給 libusb——它是 macOS 上經硬體驗證的 USB 路徑,避免手刻 +無法在無硬體下驗證的 ``IOUSBHostInterface`` plugin vtable。直接散布 +(非 App Store)的 build 必須 notarisation;libusb 存取裝置不需特殊 +entitlement,但 System Integrity Protection 仍會藏起 Apple 內部裝置與 +某些 USB-C 週邊。operator guide 記載 SIP 排除界線。 Linux — libusb -------------- @@ -179,9 +203,12 @@ Linux — libusb - 拔線處理:libusb 對進行中的傳輸發出 ``LIBUSB_TRANSFER_NO_DEVICE``; 我們把它 map 成 channel 上的 ``CLOSED``。 -OPEN:某些 distro 預設會把 ``usbhid`` 接到看起來像 HID 的所有東西。 -得呼叫 ``libusb_detach_kernel_driver``,並在 close 時 -``libusb_attach_kernel_driver`` 復原 — 否則 host OS 會丟掉輸入裝置。 +**已決(OQ7):** 已實作。``_LibusbHandle`` 在 open 時對 active +configuration 的每個介面呼叫 ``detach_kernel_driver``(只 detach 真的 +被 kernel 佔住的),記住動過哪些介面,並在 close 時 ``attach_kernel_driver`` +復原——否則 session 結束後 host OS 會永久丟掉鍵盤/滑鼠。Windows/ +macOS 的 libusb 對 detach 拋 ``NotImplementedError``,會被容忍跳過 +(那些平台由 OS 仲裁 driver)。 安全與 ACL @@ -207,9 +234,15 @@ OPEN:某些 distro 預設會把 ``usbhid`` 接到看起來像 HID 的所有東 modal 顯示 vendor/product/serial 與請求存取的 viewer ID。 - Allow rule 可以靠提示中的「記住」勾選持久化。 -OPEN:要不要對 ACL 檔案做簽章或 HMAC,避免被入侵的 host process -偷偷給自己授權?應該要,用一把使用者通行碼或平台 keychain 衍生的 -master key。 +**已決(OQ8):** 已實作 HMAC-SHA256。ACL 旁附一個 ``.sig`` +sidecar 簽章;載入時驗證,不符就 fail-closed(default-deny、 +``integrity_ok`` 為 False),讓偷偷改寫 JSON 的 process 無法在不同時 +偽造簽章的情況下給自己授權。簽章金鑰可插拔——部署可透過建構子的 +``hmac_key=`` 傳入由平台 keychain 衍生的金鑰;未指定時會在 ACL 旁 +產生一把隨機金鑰檔(POSIX 上 ``0o600``)。注意:同使用者身分的 +process 仍可讀金鑰檔而偽造簽章,故高保證部署建議改用 keychain 金鑰 +(見 operator guide)。升級前既有的未簽章檔案視為 legacy,仍可載入 +(下次儲存即補簽),可用 ``require_signature=True`` 拒絕未簽章檔。 稽核 ---- @@ -231,26 +264,32 @@ macOS entitlement、Windows WinUSB 通常不需要)。README 會逐 OS 1. **完成 — Phase 1**:唯讀列舉(``list_usb_devices``)。 2. **完成 — Phase 1.5**:hotplug events(``UsbHotplugWatcher``、 ``/usb/events``)。 -3. **Phase 2a(本設計)**:協定骨架 + ``UsbBackend`` ABC + Linux +3. **完成 — Phase 2a**:協定 + ``UsbBackend`` ABC + Linux ``libusb`` backend,置於 feature flag 之後。 -4. **Phase 2b**:Windows ``WinUSB`` backend。 -5. **Phase 2c**:macOS ``IOKit`` backend。 -6. **Phase 2d**:ACL 持久化 + host 端提示 UI + 稽核整合。 -7. **Phase 2e**:默認開啟之前的外部安全審查。 - -每個子階段都是獨立的多輪專案。經驗豐富的貢獻者預估工作量:每個 -backend 約 1 週、ACL/UI 約 1 週,加上依 reviewer 行程而定的安全 -審查。 - - -未決問題彙整 -============ - -1. Channel 用 ``maxRetransmits=None`` 還是 ``maxPacketLifeTime``。 -2. 16 KiB 以上的 frame 分片。 -3. ``LIST`` 走 channel 還是只走 REST。 -4. Backpressure 顆粒度(per-claim 還是 per-endpoint)。 -5. WinUSB 不能 claim 哪些裝置、要怎麼跟 viewer 溝通。 -6. macOS 非 App Store 發行的 entitlement 故事。 -7. Linux kernel driver detach/reattach 生命週期。 -8. ACL 檔案完整性(HMAC 還是平台 keychain)。 +4. **完成 — Phase 2b**:Windows ``WinUSB`` backend(ctypes,硬體未驗證)。 +5. **完成 — Phase 2c**:macOS ``IOKit`` backend(原生列舉 + libusb + 傳輸,硬體未驗證)。 +6. **完成 — Phase 2d / 2d.1**:ACL 持久化 + host 端提示 callback + + 稽核整合 + ACL 檔案 HMAC 完整性。 +7. **進行中 — Phase 2e**:默認開啟之前的外部安全審查 **加上** 三個 + backend 的真實硬體測試矩陣。這兩項本質上需要硬體與外部人員, + 無法只靠程式碼完成。 + +在 Phase 2e 簽核之前,feature flag 維持預設 off。 + + +設計決策(原未決問題) +====================== + +八個原始未決問題均已拍板,對應實作見上方各節: + +1. **OQ1 — Channel 可靠度**:``maxRetransmits=None``(完全可靠有序)。 +2. **OQ2 — frame 分片**:已實作 ``fragment_payload`` + EOF 重組。 +3. **OQ3 — ``LIST`` 走 channel**:是,ACL 過濾後經 channel 回傳。 +4. **OQ4 — Backpressure 顆粒度**:per-claim(per-endpoint 屬 YAGNI)。 +5. **OQ5 — WinUSB 不可 claim 裝置**:只列出已綁 WinUSB 的裝置, + claim 不到回明確錯誤。 +6. **OQ6 — macOS 發行**:原生 IOKit 列舉 + libusb 傳輸;notarisation, + 無需特殊 entitlement,文件記載 SIP 界線。 +7. **OQ7 — Linux kernel driver**:open 時 detach、close 時 reattach。 +8. **OQ8 — ACL 完整性**:HMAC-SHA256 sidecar,金鑰可插拔(keychain)。 diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index 7a9095c6..7803ae71 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -2,9 +2,10 @@ USB Passthrough — 操作員指南 ============================================================ -實際把 host 機器上的 USB 裝置借給遠端 viewer 用的步驟手冊。對應 -Phase 2a.1(目前已 ship 狀態)— host 端在 Linux libusb 上端到端 -運作;Windows WinUSB 為硬體未驗證;macOS IOKit 尚未實作。 +實際把 host 機器上的 USB 裝置借給遠端 viewer 用的步驟手冊。host 端在 +Linux libusb 上端到端運作;Windows WinUSB 與 macOS IOKit 已實作但 +**硬體未驗證**——兩者都必須先通過 Phase 2e 硬體測試矩陣才能用於 +production。``default_passthrough_backend()`` 會依當前 OS 自動挑 backend。 如果你是安全審查者而非操作員,請看 :doc:`usb_passthrough_security_review`\ 。如果你想要協定細節, @@ -69,11 +70,19 @@ ctypes 接線已寫但尚未對實體硬體驗證。視為 alpha。步驟: 2. 綁好後裝置應該會出現在 ``WinusbBackend().list()`` 中。 3. 在依賴 transfer 之前需要硬體測試。期待的測試矩陣見安全審查清單。 -macOS(IOKit)— *尚未實作* --------------------------- +macOS(IOKit)— *硬體未驗證* +---------------------------- -骨架已存在;``IokitBackend()`` 可以建構,但 ``list`` / ``open`` -會拋 ``NotImplementedError``\ 。請追蹤 Phase 2c。 +``IokitBackend`` 透過原生 IOKit(``ctypes``,不需 pyobjc)列舉 USB +裝置,所以 ``IokitBackend().list()`` 可用。claim 裝置做 transfer 則 +委派給 libusb,請安裝:``pip install pyusb`` 與 ``brew install libusb``\ 。 +注意: + +1. 直接散布(非 App Store)的 build 必須 notarisation;libusb 存取 + 裝置不需特殊 entitlement。 +2. System Integrity Protection 會藏起 Apple 內部裝置與某些 USB-C + 週邊——它們不會出現在 ``list()`` 也無法 claim,屬正常。 +3. transfer 為硬體未驗證;依賴前請看安全審查清單的測試矩陣。 啟用 feature @@ -121,7 +130,8 @@ ACL 預設為 ``"deny"``\ ,所以 viewer 無法 claim 操作員未核准的裝 3. 直接編輯 ``~/.je_auto_control/usb_acl.json``\ 。檔案有權限檢查 (POSIX 上 mode ``0600``\ )。壞 JSON 或未知 ``version`` 會退到 - 預設 deny。 + 預設 deny。**若你手動編輯檔案,HMAC 簽章會對不上而導致 ACL + fail-closed**\ (見下)——請改用 ``UsbAcl`` 重新儲存以刷新簽章。 決策優先序: @@ -129,6 +139,21 @@ ACL 預設為 ``"deny"``\ ,所以 viewer 無法 claim 操作員未核准的裝 操作員,即使 rule 是 ``allow=True``\ 。 - 沒有 rule match 時套用檔案的 ``default``\ (預設 ``"deny"``\ )。 +ACL 檔案完整性(HMAC) +---------------------- + +ACL 旁附一個 ``usb_acl.json.sig`` sidecar HMAC-SHA256 簽章。載入時對 +檔案位元組驗證;不符就 **fail-closed**\ (default-deny, +``UsbAcl.integrity_ok`` 回 ``False``\ )。這擋住偷偷改寫 JSON 想給自己 +授權的 process。 + +- 預設簽章金鑰是隨機 32-byte 檔 ``usb_acl.json.key``\ (POSIX 上 mode + ``0600``\ ),首次儲存時建立。 +- 高保證情境請從平台 keychain 衍生金鑰並明確傳入: + ``UsbAcl(hmac_key=)``\ 。否則同使用者身分的 process 可讀金鑰檔 + 而偽造簽章。 +- 傳 ``UsbAcl(require_signature=True)`` 可連 legacy 未簽章檔也一併拒絕。 + 啟動 host ========= @@ -229,10 +254,13 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim 尚未發布的部分 ============== -- WebRTC viewer GUI 沒有自動把 ``usb`` DataChannel 接起來 — *USB - Browser* 分頁的 *Open* 按鈕會顯示「尚未串接」訊息。今天可以從 - Python 驅動協定。 -- Windows WinUSB transfer 方法已寫但尚未對實體硬體驗證。請勿用於 - production。 -- macOS IOKit backend 未實作(Phase 2c)。 +- *USB 分享* 分頁是簡易的 AnyDesk 風介面:左側啟用分享並對本機裝置 + 做 ACL 允許/封鎖;右側經 in-process channel 列出分享裝置並 *開啟* + 其中一個(讀描述元即證明整條堆疊運作)。*USB Browser* 分頁的 *Open* + 按鈕現在對 **localhost** 目標也會走同一條 loopback 路徑。 +- 跨機器開啟仍需 WebRTC ``usb`` DataChannel,viewer GUI 尚未自動串接 — + 對遠端主機按 *Open* 會顯示「尚未串接」訊息。今天可以從 Python 驅動 + 協定(含經 channel 的 ``UsbPassthroughClient.list_devices()``\ )。 +- Windows WinUSB 與 macOS IOKit 的 transfer 路徑已寫但尚未對實體硬體 + 驗證。在 Phase 2e 硬體測試矩陣通過前請勿用於 production。 - Phase 2e 外部安全審查尚未簽核;feature flag 必須維持顯式 opt-in。 diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_security_review.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_security_review.rst index ba75c772..d75c2ad7 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_security_review.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_security_review.rst @@ -45,9 +45,13 @@ ACL ``test_save_persists_to_disk_with_safe_mode``\ )。 - [ ] 建議把 ACL 放在支援 POSIX 權限的檔案系統上;佈署文件需把 Windows ACL 故事寫清楚。 -- [ ] **OPEN question 8 — ACL 完整性(HMAC / keychain)**\ 。目前 - 以使用者身分執行的程序可以靜悄悄改寫 ACL。若無法接受,請在 - sign-off 之前 file 後續專案。 +- [ ] **OQ8 — ACL 完整性(HMAC)已實作**\ 。ACL 旁附 HMAC-SHA256 + sidecar 簽章,竄改則 fail-closed(測試 + ``test_tampered_acl_file_fails_closed``\ 、 + ``test_explicit_key_roundtrip_and_wrong_key_fails``\ )。**殘留風險:** + 預設金鑰存於同使用者可讀的 ``usb_acl.json.key``\ ;同身分的 + process 仍可偽造簽章。高保證部署應透過 ``UsbAcl(hmac_key=...)`` + 改用平台 keychain 衍生金鑰——請審查者確認部署是否採用。 稽核 @@ -118,10 +122,13 @@ ACL - [ ] **Linux libusb**:HID 裝置 claim 之前呼叫 ``libusb_detach_kernel_driver``\ ;close 時重新 attach。 確認 host OS 的鍵盤/滑鼠在 session 結束後仍可運作。 -- [ ] **Windows WinUSB**\ (Phase 2b — *尚未發布*):裝置必須已經 - 與 WinUSB 關聯(Zadig / libwdi)。把操作者面對的指引寫清楚。 -- [ ] **macOS IOKit**\ (Phase 2c — *尚未發布*):非 App Store 發行的 - notarisation 故事。文件化 SIP 排除清單。 +- [ ] **Windows WinUSB**\ (Phase 2b — *已實作,硬體未驗證*):裝置 + 必須已與 WinUSB 關聯(Zadig / libwdi),只有綁定者會出現在 + ``list()``\ 。在真實硬體上跑 bulk / HID / composite 測試矩陣後 + 才能簽核。 +- [ ] **macOS IOKit**\ (Phase 2c — *已實作,硬體未驗證*):原生 IOKit + 列舉 + libusb 傳輸。非 App Store 發行需 notarisation;文件化 SIP + 排除清單。在真實硬體上跑測試矩陣後才能簽核。 - [ ] 三個 backend 都要:開啟已被別 driver 持有的裝置時,要清楚地 回 "busy" RuntimeError,不 hang 不 crash。 diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index a9e5c894..90bfc2b7 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -111,6 +111,45 @@ "usb_browser_col_serial": "Serial", "usb_browser_open_select_first": "Select a row first.", "usb_browser_open_unwired": "Open requires a WebRTC usb DataChannel; not yet wired in this build.", + "usb_browser_open_loopback": "Opened {vid}:{pid} locally — descriptor {hex}", + + # USB sharing panel (AnyDesk-style) + "tab_usb_share": "USB Sharing", + "usb_share_host_group": "Share USB on this machine", + "usb_share_viewer_group": "Use a USB device", + "usb_share_enable": "Enable sharing", + "usb_share_disable": "Disable sharing", + "usb_share_sharing_on": "Sharing on", + "usb_share_sharing_off": "Not sharing", + "usb_share_acl_tampered": "ACL signature mismatch — fail closed", + "usb_share_local_devices": "Local USB devices", + "usb_share_refresh_local": "Refresh", + "usb_share_allow": "Allow", + "usb_share_block": "Block", + "usb_share_allowed": "Allowed {vid}:{pid}", + "usb_share_blocked": "Blocked {vid}:{pid}", + "usb_share_select_first": "Select a device row first.", + "usb_share_col_vid": "VID", + "usb_share_col_pid": "PID", + "usb_share_col_product": "Product", + "usb_share_col_serial": "Serial", + "usb_share_col_policy": "Policy", + "usb_share_policy_allow": "allow", + "usb_share_policy_deny": "deny", + "usb_share_policy_prompt": "prompt", + "usb_share_intro": "Enable sharing on the left, then list and open a shared device here over the in-process channel, or browse a remote host below.", + "usb_share_fetch_shared": "List shared devices", + "usb_share_open_selected": "Open selected", + "usb_share_enable_first": "Enable sharing first.", + "usb_share_opening": "Opening {vid}:{pid}…", + "usb_share_opened": "Opened {vid}:{pid} — descriptor {hex}", + "usb_share_open_failed": "Failed: {error}", + "usb_share_listing": "Listing…", + "usb_share_listed": "{count} shared device(s).", + "usb_share_remote_group": "Browse a remote host (REST)", + "usb_share_remote_url": "REST URL:", + "usb_share_remote_token": "Token:", + "usb_share_remote_fetch": "Fetch remote", # Inspector tab "inspector_metrics_group": "Rolling metrics", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 7daa4818..549473a5 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -109,6 +109,45 @@ "usb_browser_col_serial": "シリアル", "usb_browser_open_select_first": "先に行を選択してください。", "usb_browser_open_unwired": "Open には WebRTC usb DataChannel が必要です。このビルドではまだ接続されていません。", + "usb_browser_open_loopback": "{vid}:{pid} をローカルで開きました — ディスクリプタ {hex}", + + # USB 共有パネル(AnyDesk 風) + "tab_usb_share": "USB 共有", + "usb_share_host_group": "このマシンの USB を共有", + "usb_share_viewer_group": "USB デバイスを使う", + "usb_share_enable": "共有を有効化", + "usb_share_disable": "共有を無効化", + "usb_share_sharing_on": "共有中", + "usb_share_sharing_off": "未共有", + "usb_share_acl_tampered": "ACL 署名が不一致 — fail-closed", + "usb_share_local_devices": "ローカル USB デバイス", + "usb_share_refresh_local": "更新", + "usb_share_allow": "許可", + "usb_share_block": "ブロック", + "usb_share_allowed": "{vid}:{pid} を許可しました", + "usb_share_blocked": "{vid}:{pid} をブロックしました", + "usb_share_select_first": "先にデバイス行を選択してください。", + "usb_share_col_vid": "VID", + "usb_share_col_pid": "PID", + "usb_share_col_product": "製品", + "usb_share_col_serial": "シリアル", + "usb_share_col_policy": "ポリシー", + "usb_share_policy_allow": "許可", + "usb_share_policy_deny": "拒否", + "usb_share_policy_prompt": "確認", + "usb_share_intro": "左で共有を有効にし、ここで in-process channel 経由で共有デバイスを一覧・オープンするか、下で遠隔ホストを閲覧します。", + "usb_share_fetch_shared": "共有デバイスを一覧", + "usb_share_open_selected": "選択を開く", + "usb_share_enable_first": "先に共有を有効にしてください。", + "usb_share_opening": "{vid}:{pid} を開いています…", + "usb_share_opened": "{vid}:{pid} を開きました — ディスクリプタ {hex}", + "usb_share_open_failed": "失敗:{error}", + "usb_share_listing": "一覧中…", + "usb_share_listed": "共有デバイス {count} 件。", + "usb_share_remote_group": "遠隔ホストを閲覧(REST)", + "usb_share_remote_url": "REST URL:", + "usb_share_remote_token": "トークン:", + "usb_share_remote_fetch": "遠隔を取得", # 監視タブ "inspector_metrics_group": "集約メトリクス", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index e5fd6c70..c894ef9f 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -100,6 +100,45 @@ "usb_browser_col_serial": "序列号", "usb_browser_open_select_first": "请先选择一行。", "usb_browser_open_unwired": "Open 需要 WebRTC usb DataChannel;当前版本尚未接通。", + "usb_browser_open_loopback": "已在本机打开 {vid}:{pid} — 描述符 {hex}", + + # USB 共享面板(AnyDesk 风格) + "tab_usb_share": "USB 共享", + "usb_share_host_group": "共享本机 USB", + "usb_share_viewer_group": "使用 USB 设备", + "usb_share_enable": "启用共享", + "usb_share_disable": "停用共享", + "usb_share_sharing_on": "共享中", + "usb_share_sharing_off": "未共享", + "usb_share_acl_tampered": "ACL 签名不符 — 已 fail-closed", + "usb_share_local_devices": "本机 USB 设备", + "usb_share_refresh_local": "刷新", + "usb_share_allow": "允许", + "usb_share_block": "封锁", + "usb_share_allowed": "已允许 {vid}:{pid}", + "usb_share_blocked": "已封锁 {vid}:{pid}", + "usb_share_select_first": "请先选择一行设备。", + "usb_share_col_vid": "VID", + "usb_share_col_pid": "PID", + "usb_share_col_product": "产品", + "usb_share_col_serial": "序列号", + "usb_share_col_policy": "策略", + "usb_share_policy_allow": "允许", + "usb_share_policy_deny": "拒绝", + "usb_share_policy_prompt": "询问", + "usb_share_intro": "在左侧启用共享,然后在此经由 in-process channel 列出并打开共享设备,或在下方浏览远程主机。", + "usb_share_fetch_shared": "列出共享设备", + "usb_share_open_selected": "打开所选", + "usb_share_enable_first": "请先启用共享。", + "usb_share_opening": "正在打开 {vid}:{pid}…", + "usb_share_opened": "已打开 {vid}:{pid} — 描述符 {hex}", + "usb_share_open_failed": "失败:{error}", + "usb_share_listing": "列出中…", + "usb_share_listed": "{count} 个共享设备。", + "usb_share_remote_group": "浏览远程主机(REST)", + "usb_share_remote_url": "REST URL:", + "usb_share_remote_token": "令牌:", + "usb_share_remote_fetch": "获取远程", # 包监测分页 "inspector_metrics_group": "汇总指标", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 4fcd2e8a..c450aa15 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -101,6 +101,45 @@ "usb_browser_col_serial": "序號", "usb_browser_open_select_first": "請先選取一列。", "usb_browser_open_unwired": "Open 需要 WebRTC usb DataChannel;本版尚未串接。", + "usb_browser_open_loopback": "已於本機開啟 {vid}:{pid} — 描述元 {hex}", + + # USB 分享面板(AnyDesk 風) + "tab_usb_share": "USB 分享", + "usb_share_host_group": "分享本機 USB", + "usb_share_viewer_group": "使用 USB 裝置", + "usb_share_enable": "啟用分享", + "usb_share_disable": "停用分享", + "usb_share_sharing_on": "分享中", + "usb_share_sharing_off": "未分享", + "usb_share_acl_tampered": "ACL 簽章不符 — 已 fail-closed", + "usb_share_local_devices": "本機 USB 裝置", + "usb_share_refresh_local": "重新整理", + "usb_share_allow": "允許", + "usb_share_block": "封鎖", + "usb_share_allowed": "已允許 {vid}:{pid}", + "usb_share_blocked": "已封鎖 {vid}:{pid}", + "usb_share_select_first": "請先選取一列裝置。", + "usb_share_col_vid": "VID", + "usb_share_col_pid": "PID", + "usb_share_col_product": "產品", + "usb_share_col_serial": "序號", + "usb_share_col_policy": "政策", + "usb_share_policy_allow": "允許", + "usb_share_policy_deny": "拒絕", + "usb_share_policy_prompt": "詢問", + "usb_share_intro": "在左側啟用分享,然後在此經由 in-process channel 列出並開啟分享的裝置,或於下方瀏覽遠端主機。", + "usb_share_fetch_shared": "列出分享裝置", + "usb_share_open_selected": "開啟選取項", + "usb_share_enable_first": "請先啟用分享。", + "usb_share_opening": "開啟 {vid}:{pid} 中…", + "usb_share_opened": "已開啟 {vid}:{pid} — 描述元 {hex}", + "usb_share_open_failed": "失敗:{error}", + "usb_share_listing": "列出中…", + "usb_share_listed": "{count} 個分享裝置。", + "usb_share_remote_group": "瀏覽遠端主機(REST)", + "usb_share_remote_url": "REST URL:", + "usb_share_remote_token": "權杖:", + "usb_share_remote_fetch": "取得遠端", # 封包監測分頁 "inspector_metrics_group": "彙整指標", diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index 093d2020..b730d521 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -34,6 +34,7 @@ from je_auto_control.gui.recording_editor_tab import RecordingEditorTab from je_auto_control.gui.usb_browser_tab import UsbBrowserTab from je_auto_control.gui.usb_devices_tab import UsbDevicesTab +from je_auto_control.gui.usb_passthrough_panel import UsbPassthroughPanel # Remote desktop relies on the optional `webrtc` extra (aiortc + PyAV). # Importing it eagerly would break embedders (e.g. PyBreeze) that install # je_auto_control without the extra; fall back to a placeholder tab that @@ -193,6 +194,8 @@ def __init__(self, parent=None): category="system") self._add_tab("usb_browser", "tab_usb_browser", UsbBrowserTab(), category="system") + self._add_tab("usb_share", "tab_usb_share", UsbPassthroughPanel(), + category="system") self._add_tab("diagnostics", "tab_diagnostics", DiagnosticsTab(), category="system") self._add_tab("report", "tab_report", self._build_report_tab(), diff --git a/je_auto_control/gui/usb_browser_tab.py b/je_auto_control/gui/usb_browser_tab.py index b02f65c9..5830c91f 100644 --- a/je_auto_control/gui/usb_browser_tab.py +++ b/je_auto_control/gui/usb_browser_tab.py @@ -5,18 +5,21 @@ ``usb`` DataChannel is wired up — Phase 2 follow-up) issue OPEN against a row. -This tab is **read-only by default**: clicking *Open* in this Phase 2a.1 -build raises a clear "WebRTC channel not wired" message, because the -viewer-side ``UsbPassthroughClient`` needs a transport callable that -actually drives the host's ``usb`` DataChannel — that wiring is a -separate piece of work in the WebRTC viewer integration. The browse + -enumerate path works against any reachable REST server today. +Clicking *Open* against a **localhost** target opens the device on this +machine over the in-process loopback transport and reads its descriptor +(a full protocol-stack round trip). Against a **remote** target it shows +a clear "WebRTC channel not wired" message, because driving a remote +host's ``usb`` DataChannel is a separate piece of the WebRTC viewer +integration. The browse + enumerate path works against any reachable +REST server today; for the simple share/use flow see the *USB Sharing* +panel. """ from __future__ import annotations import json +import urllib.parse import urllib.request -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( @@ -35,6 +38,64 @@ def _t(key: str) -> str: _TEST_SCHEME = "http" # NOSONAR localhost-friendly default; users may type https:// +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", ""}) +# Standard USB GET_DESCRIPTOR (device) — a harmless liveness probe. +_DESC_REQUEST_TYPE = 0x80 +_DESC_REQUEST = 0x06 +_DESC_VALUE = 0x0100 +_DESC_LENGTH = 18 + + +def _is_loopback_target(base_url: str) -> bool: + """True if ``base_url`` points at this machine (so a local open is valid).""" + text = base_url.strip() + if not text.startswith(("http://", "https://")): + text = f"{_TEST_SCHEME}://{text}" + host = urllib.parse.urlsplit(text).hostname or "" + return host.lower() in _LOOPBACK_HOSTS + + +def open_local_descriptor(*, vendor_id: str, product_id: str, + serial: Optional[str]) -> bytes: + """Open a device on this machine over loopback and read its descriptor. + + Headless helper (no Qt) so the local-open path is unit-testable. + Raises ``RuntimeError`` / ``UsbClientError`` if the device can't be + claimed (e.g. not bound to WinUSB, or denied by the ACL). + """ + from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback + # Gate on the operator's persisted ACL — opening a local device the + # user hasn't allowed should fail closed, same as a remote claim. + with UsbLoopback(acl=UsbAcl(), viewer_id="usb-browser") as loop: + handle = loop.open( + vendor_id=vendor_id, product_id=product_id, serial=serial, + ) + try: + return handle.control_transfer( + bm_request_type=_DESC_REQUEST_TYPE, b_request=_DESC_REQUEST, + w_value=_DESC_VALUE, length=_DESC_LENGTH, + ) + finally: + handle.close() + + +class _CallWorker(QObject): + """Runs one callable off the GUI thread and reports the outcome.""" + + finished = Signal(object) + failed = Signal(str) + + def __init__(self, fn: Callable[[], Any]) -> None: + super().__init__() + self._fn = fn + + def run(self) -> None: + try: + result = self._fn() + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: surface any backend/transport error to the status line + self.failed.emit(str(error)) + return + self.finished.emit(result) def fetch_remote_devices(*, base_url: str, @@ -101,6 +162,7 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: QHeaderView.ResizeMode.ResizeToContents, ) self._fetch_thread: Optional[QThread] = None + self._open_thread: Optional[QThread] = None self._build_layout() self._apply_table_headers() @@ -195,14 +257,54 @@ def _on_open_selected(self) -> None: _t("usb_browser_open_select_first"), ) return - # Phase 2a.1 ships the host-side claim path and the - # UsbPassthroughClient blocking API, but the viewer GUI does not - # yet have a WebRTC `usb` DataChannel to drive. Surface that - # honestly instead of pretending a click does something. - QMessageBox.information( - self, _t("usb_browser_open"), - _t("usb_browser_open_unwired"), + if not _is_loopback_target(self._url_input.text()): + # A true remote host needs the WebRTC `usb` DataChannel, which + # the viewer GUI does not yet drive. Surface that honestly. + QMessageBox.information( + self, _t("usb_browser_open"), + _t("usb_browser_open_unwired"), + ) + return + if self._open_thread is not None: + return + row = rows[0] + vid = self._row_text(row, 0) + pid = self._row_text(row, 1) + serial = self._row_text(row, 4) or None + self._start_local_open(vid, pid, serial) + + def _start_local_open(self, vid: str, pid: str, + serial: Optional[str]) -> None: + thread = QThread(self) + worker = _CallWorker(lambda: open_local_descriptor( + vendor_id=vid, product_id=pid, serial=serial, + )) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect( + lambda descriptor: self._on_local_opened(vid, pid, descriptor), ) + worker.failed.connect(self._apply_failure) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(self._on_open_done) + self._open_thread = thread + thread.start() + + def _on_open_done(self) -> None: + self._open_thread = None + + def _on_local_opened(self, vid: str, pid: str, descriptor: bytes) -> None: + self._status_label.setText( + _t("usb_browser_open_loopback").format( + vid=vid, pid=pid, hex=descriptor.hex() or "(empty)", + ), + ) + + def _row_text(self, row: int, col: int) -> str: + item = self._table.item(row, col) + text = item.text() if item is not None else "" + return "" if text == "-" else text __all__ = ["UsbBrowserTab", "fetch_remote_devices"] diff --git a/je_auto_control/gui/usb_passthrough_panel.py b/je_auto_control/gui/usb_passthrough_panel.py new file mode 100644 index 00000000..56cda735 --- /dev/null +++ b/je_auto_control/gui/usb_passthrough_panel.py @@ -0,0 +1,399 @@ +"""AnyDesk-style USB passthrough panel. + +One simple two-pane screen: + +* **Left — "Share USB on this machine":** toggle sharing on/off, see the + local USB devices, and Allow / Block each one in the ACL with a click. + An integrity badge surfaces an ACL signature mismatch. +* **Right — "Use a USB device":** list the shareable devices over the + in-process channel and open one (a device-descriptor read proves the + whole protocol stack works end to end), plus browse a remote host's + devices over REST. + +The widget is a thin wrapper: every action calls the headless +:class:`UsbLoopback`, :class:`UsbAcl`, and ``list_usb_devices`` — no USB +business logic lives here. Blocking calls run on a worker thread so the +GUI stays responsive. +""" +from __future__ import annotations + +from typing import Any, Callable, List, Optional + +from PySide6.QtCore import QObject, QThread, Signal +from PySide6.QtWidgets import ( + QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, + QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.gui.remote_desktop._helpers import _StatusBadge +from je_auto_control.gui.usb_browser_tab import fetch_remote_devices +from je_auto_control.utils.usb.passthrough import ( + AclRule, UsbAcl, UsbLoopback, enable_usb_passthrough, +) +from je_auto_control.utils.usb.usb_devices import list_usb_devices + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +# Standard USB GET_DESCRIPTOR (device) request — a harmless liveness probe. +_DESC_REQUEST_TYPE = 0x80 +_DESC_REQUEST = 0x06 +_DESC_VALUE = 0x0100 +_DESC_LENGTH = 18 + + +class _CallWorker(QObject): + """Runs one callable off the GUI thread and reports the outcome.""" + + finished = Signal(object) + failed = Signal(str) + + def __init__(self, fn: Callable[[], Any]) -> None: + super().__init__() + self._fn = fn + + def run(self) -> None: + try: + result = self._fn() + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: surface any backend/transport error to the status line + self.failed.emit(str(error)) + return + self.finished.emit(result) + + +class UsbPassthroughPanel(TranslatableMixin, QWidget): + """Simple AnyDesk-style share + use surface for USB passthrough.""" + + def __init__(self, parent: Optional[QWidget] = None, *, + acl: Optional[UsbAcl] = None, + loopback_factory: Optional[Callable[[], UsbLoopback]] = None, + ) -> None: + super().__init__(parent) + self._tr_init() + self._acl = acl if acl is not None else UsbAcl() + self._loopback_factory = loopback_factory or self._default_loopback + self._loopback: Optional[UsbLoopback] = None + self._thread: Optional[QThread] = None + self._host_badge = _StatusBadge() + self._viewer_status = QLabel("") + self._local_table = _make_table(5) + self._shared_table = _make_table(4) + self._remote_url = QLineEdit("http://127.0.0.1:9939") + self._remote_token = QLineEdit() + self._remote_token.setEchoMode(QLineEdit.EchoMode.Password) + self._build_layout() + self._apply_local_headers() + self._apply_shared_headers() + self._refresh_local_devices() + self._refresh_host_badge() + + def _default_loopback(self) -> UsbLoopback: + return UsbLoopback(acl=self._acl, viewer_id="gui-local") + + # --- layout ------------------------------------------------------------ + + def _build_layout(self) -> None: + root = QHBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(16) + root.addWidget(self._build_host_section(), stretch=1) + root.addWidget(self._build_viewer_section(), stretch=1) + + def _build_host_section(self) -> QWidget: + group = QGroupBox() + self._tr(group, "usb_share_host_group", setter="setTitle") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.addWidget(self._host_badge) + btn_row = QHBoxLayout() + enable_btn = self._tr(QPushButton(), "usb_share_enable") + enable_btn.clicked.connect(self._enable_sharing) + disable_btn = self._tr(QPushButton(), "usb_share_disable") + disable_btn.clicked.connect(self._disable_sharing) + btn_row.addWidget(enable_btn, stretch=1) + btn_row.addWidget(disable_btn, stretch=1) + layout.addLayout(btn_row) + layout.addWidget(self._tr(QLabel(), "usb_share_local_devices")) + layout.addWidget(self._local_table, stretch=1) + acl_row = QHBoxLayout() + refresh_btn = self._tr(QPushButton(), "usb_share_refresh_local") + refresh_btn.clicked.connect(self._refresh_local_devices) + allow_btn = self._tr(QPushButton(), "usb_share_allow") + allow_btn.clicked.connect(lambda: self._set_policy(True)) + block_btn = self._tr(QPushButton(), "usb_share_block") + block_btn.clicked.connect(lambda: self._set_policy(False)) + acl_row.addWidget(refresh_btn) + acl_row.addWidget(allow_btn) + acl_row.addWidget(block_btn) + layout.addLayout(acl_row) + return group + + def _build_viewer_section(self) -> QWidget: + group = QGroupBox() + self._tr(group, "usb_share_viewer_group", setter="setTitle") + layout = QVBoxLayout(group) + layout.setSpacing(8) + intro = self._tr(QLabel(), "usb_share_intro") + intro.setWordWrap(True) + layout.addWidget(intro) + layout.addWidget(self._shared_table, stretch=1) + use_row = QHBoxLayout() + list_btn = self._tr(QPushButton(), "usb_share_fetch_shared") + list_btn.clicked.connect(self._list_shared) + open_btn = self._tr(QPushButton(), "usb_share_open_selected") + open_btn.clicked.connect(self._open_selected) + use_row.addWidget(list_btn) + use_row.addWidget(open_btn) + layout.addLayout(use_row) + layout.addWidget(self._viewer_status) + layout.addWidget(self._build_remote_box()) + return group + + def _build_remote_box(self) -> QWidget: + box = QGroupBox() + self._tr(box, "usb_share_remote_group", setter="setTitle") + layout = QVBoxLayout(box) + url_row = QHBoxLayout() + url_row.addWidget(self._tr(QLabel(), "usb_share_remote_url")) + url_row.addWidget(self._remote_url, stretch=1) + layout.addLayout(url_row) + token_row = QHBoxLayout() + token_row.addWidget(self._tr(QLabel(), "usb_share_remote_token")) + token_row.addWidget(self._remote_token, stretch=1) + layout.addLayout(token_row) + fetch_btn = self._tr(QPushButton(), "usb_share_remote_fetch") + fetch_btn.clicked.connect(self._fetch_remote) + layout.addWidget(fetch_btn) + return box + + def _apply_local_headers(self) -> None: + self._local_table.setHorizontalHeaderLabels([ + _t("usb_share_col_vid"), _t("usb_share_col_pid"), + _t("usb_share_col_product"), _t("usb_share_col_serial"), + _t("usb_share_col_policy"), + ]) + + def _apply_shared_headers(self) -> None: + self._shared_table.setHorizontalHeaderLabels([ + _t("usb_share_col_vid"), _t("usb_share_col_pid"), + _t("usb_share_col_product"), _t("usb_share_col_serial"), + ]) + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_local_headers() + self._apply_shared_headers() + self._refresh_host_badge() + + # --- sharing lifecycle ------------------------------------------------- + + def _enable_sharing(self) -> None: + if self._loopback is not None: + return + enable_usb_passthrough(True) + try: + self._loopback = self._loopback_factory() + except (RuntimeError, OSError) as error: + enable_usb_passthrough(False) + QMessageBox.warning(self, _t("usb_share_host_group"), str(error)) + return + self._refresh_host_badge() + + def _disable_sharing(self) -> None: + loop = self._loopback + self._loopback = None + if loop is not None: + loop.close() + enable_usb_passthrough(False) + self._shared_table.setRowCount(0) + self._refresh_host_badge() + + def _refresh_host_badge(self) -> None: + if self._loopback is None: + self._host_badge.set_state("stopped", _t("usb_share_sharing_off")) + else: + self._host_badge.set_state("running", _t("usb_share_sharing_on")) + if not self._acl.verify_integrity(): + self._viewer_status.setText(_t("usb_share_acl_tampered")) + + # --- local devices + ACL ---------------------------------------------- + + def _refresh_local_devices(self) -> None: + result = list_usb_devices() + devices = result.devices + self._local_table.setRowCount(len(devices)) + for row, device in enumerate(devices): + policy = self._acl.decide( + vendor_id=device.vendor_id or "", product_id=device.product_id or "", + serial=device.serial or None, + ) + cells = [ + device.vendor_id or "-", device.product_id or "-", + device.product or "", device.serial or "", + _t(f"usb_share_policy_{policy}"), + ] + for col, text in enumerate(cells): + self._local_table.setItem(row, col, QTableWidgetItem(text)) + + def _set_policy(self, allow: bool) -> None: + row = _selected_row(self._local_table) + if row is None: + self._info(_t("usb_share_select_first")) + return + vid = self._cell(self._local_table, row, 0) + pid = self._cell(self._local_table, row, 1) + serial = self._cell(self._local_table, row, 3) or None + self._acl.remove_rule(vendor_id=vid, product_id=pid, serial=serial) + self._acl.add_rule(AclRule( + vendor_id=vid, product_id=pid, serial=serial, + label=f"gui {vid}:{pid}", allow=allow, prompt_on_open=False, + )) + key = "usb_share_allowed" if allow else "usb_share_blocked" + self._viewer_status.setText(_t(key).format(vid=vid, pid=pid)) + self._refresh_local_devices() + + # --- use (loopback) ---------------------------------------------------- + + def _list_shared(self) -> None: + loop = self._loopback + if loop is None: + self._info(_t("usb_share_enable_first")) + return + self._viewer_status.setText(_t("usb_share_listing")) + self._run_async(loop.list_devices, self._apply_shared, self._fail) + + def _apply_shared(self, devices: List[dict]) -> None: + self._viewer_status.setText( + _t("usb_share_listed").format(count=len(devices)), + ) + self._shared_table.setRowCount(len(devices)) + for row, device in enumerate(devices): + cells = [ + device.get("vendor_id") or "-", device.get("product_id") or "-", + device.get("product") or "", device.get("serial") or "", + ] + for col, text in enumerate(cells): + self._shared_table.setItem(row, col, QTableWidgetItem(str(text))) + + def _open_selected(self) -> None: + loop = self._loopback + if loop is None: + self._info(_t("usb_share_enable_first")) + return + row = _selected_row(self._shared_table) + if row is None: + self._info(_t("usb_share_select_first")) + return + vid = self._cell(self._shared_table, row, 0) + pid = self._cell(self._shared_table, row, 1) + serial = self._cell(self._shared_table, row, 3) or None + self._viewer_status.setText( + _t("usb_share_opening").format(vid=vid, pid=pid), + ) + self._run_async( + lambda: _probe_device(loop, vid, pid, serial), + lambda descriptor: self._opened(vid, pid, descriptor), + self._fail, + ) + + def _opened(self, vid: str, pid: str, descriptor: bytes) -> None: + self._viewer_status.setText( + _t("usb_share_opened").format( + vid=vid, pid=pid, hex=descriptor.hex() or "(empty)", + ), + ) + + # --- remote browse (REST) --------------------------------------------- + + def _fetch_remote(self) -> None: + url = self._remote_url.text().strip() + token = self._remote_token.text().strip() + self._viewer_status.setText(_t("usb_browser_fetching")) + self._run_async( + lambda: fetch_remote_devices(base_url=url, token=token), + self._apply_shared, self._fail_remote, + ) + + def _fail_remote(self, message: str) -> None: + self._viewer_status.setText( + _t("usb_browser_fetch_failed").format(error=message), + ) + + def _fail(self, message: str) -> None: + self._viewer_status.setText( + _t("usb_share_open_failed").format(error=message), + ) + + # --- worker plumbing --------------------------------------------------- + + def _run_async(self, fn: Callable[[], Any], + on_done: Callable[[Any], None], + on_fail: Callable[[str], None]) -> None: + if self._thread is not None: + return + thread = QThread(self) + worker = _CallWorker(fn) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(on_done) + worker.failed.connect(on_fail) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(self._on_thread_done) + self._thread = thread + thread.start() + + def _on_thread_done(self) -> None: + self._thread = None + + # --- helpers ----------------------------------------------------------- + + def _info(self, message: str) -> None: + QMessageBox.information(self, _t("usb_share_viewer_group"), message) + + @staticmethod + def _cell(table: QTableWidget, row: int, col: int) -> str: + item = table.item(row, col) + text = item.text() if item is not None else "" + return "" if text == "-" else text + + def closeEvent(self, event) -> None: # noqa: N802 # Qt override name + self._disable_sharing() + super().closeEvent(event) + + +def _make_table(columns: int) -> QTableWidget: + table = QTableWidget(0, columns) + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.ResizeToContents, + ) + return table + + +def _selected_row(table: QTableWidget) -> Optional[int]: + rows = sorted({i.row() for i in table.selectedIndexes()}) + return rows[0] if rows else None + + +def _probe_device(loop: UsbLoopback, vid: str, pid: str, + serial: Optional[str]) -> bytes: + """Open the device, read its descriptor as a liveness proof, close.""" + handle = loop.open(vendor_id=vid, product_id=pid, serial=serial) + try: + return handle.control_transfer( + bm_request_type=_DESC_REQUEST_TYPE, b_request=_DESC_REQUEST, + w_value=_DESC_VALUE, length=_DESC_LENGTH, + ) + finally: + handle.close() + + +__all__ = ["UsbPassthroughPanel"] diff --git a/je_auto_control/utils/usb/__init__.py b/je_auto_control/utils/usb/__init__.py index 00130ad5..f1a6ce91 100644 --- a/je_auto_control/utils/usb/__init__.py +++ b/je_auto_control/utils/usb/__init__.py @@ -1,11 +1,12 @@ """Cross-platform USB device enumeration + hotplug + passthrough (Phase 2a).""" from je_auto_control.utils.usb.passthrough import ( AclRule, ClientHandle, FakeUsbBackend, Frame, LibusbBackend, - MAX_PAYLOAD_BYTES, Opcode, ProtocolError, SessionError, UsbAcl, - UsbBackend, UsbClientClosed, UsbClientError, UsbClientTimeout, - UsbHandle, UsbPassthroughClient, UsbPassthroughSession, decode_frame, - default_acl_path, enable_usb_passthrough, encode_frame, - is_usb_passthrough_enabled, + LoopbackTransport, MAX_PAYLOAD_BYTES, Opcode, ProtocolError, + SessionError, UsbAcl, UsbBackend, UsbClientClosed, UsbClientError, + UsbClientTimeout, UsbHandle, UsbLoopback, UsbPassthroughClient, + UsbPassthroughSession, decode_frame, default_acl_path, + default_passthrough_backend, enable_usb_passthrough, encode_frame, + fragment_payload, is_usb_passthrough_enabled, ) from je_auto_control.utils.usb.usb_devices import ( UsbDevice, UsbEnumerationResult, list_usb_devices, @@ -21,8 +22,9 @@ # Passthrough Phase 2a/2a.1/40 (rounds 37–40) — EXPERIMENTAL, default off "FakeUsbBackend", "Frame", "LibusbBackend", "MAX_PAYLOAD_BYTES", "Opcode", "ProtocolError", "SessionError", "UsbBackend", "UsbHandle", - "UsbPassthroughSession", "decode_frame", "enable_usb_passthrough", - "encode_frame", "is_usb_passthrough_enabled", + "UsbPassthroughSession", "decode_frame", "default_passthrough_backend", + "enable_usb_passthrough", "encode_frame", "fragment_payload", + "is_usb_passthrough_enabled", "LoopbackTransport", "UsbLoopback", # Viewer client (round 40) "ClientHandle", "UsbClientClosed", "UsbClientError", "UsbClientTimeout", "UsbPassthroughClient", diff --git a/je_auto_control/utils/usb/passthrough/__init__.py b/je_auto_control/utils/usb/passthrough/__init__.py index 566ecd16..eebeb377 100644 --- a/je_auto_control/utils/usb/passthrough/__init__.py +++ b/je_auto_control/utils/usb/passthrough/__init__.py @@ -1,21 +1,31 @@ -"""USB passthrough — Phase 2a (skeleton). +"""USB passthrough. -EXPERIMENTAL. Defaults to disabled. The protocol layer + backend ABC -are in place; bulk/control transfers are intentionally not implemented -yet. See ``docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst``. +Defaults to **disabled** (opt in via ``enable_usb_passthrough`` / +``JE_AUTOCONTROL_USB_PASSTHROUGH``). Protocol, backend ABC, libusb / +WinUSB / IOKit backends, the per-OS ``default_passthrough_backend`` +factory, ACL (HMAC-signed), control/bulk/interrupt transfers, +LIST-over-channel, fragmentation, the viewer client, and an in-process +``UsbLoopback`` are all implemented. Windows / macOS transfers are +hardware-unverified; the feature stays opt-in until the Phase 2e +security sign-off. See +``docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst``. """ from je_auto_control.utils.usb.passthrough.acl import ( AclRule, UsbAcl, default_acl_path, ) from je_auto_control.utils.usb.passthrough.backend import ( FakeUsbBackend, LibusbBackend, UsbBackend, UsbHandle, + default_passthrough_backend, ) from je_auto_control.utils.usb.passthrough.flags import ( enable_usb_passthrough, is_usb_passthrough_enabled, ) +from je_auto_control.utils.usb.passthrough.loopback import ( + LoopbackTransport, UsbLoopback, +) from je_auto_control.utils.usb.passthrough.protocol import ( - Frame, Opcode, ProtocolError, decode_frame, encode_frame, - MAX_PAYLOAD_BYTES, + FLAG_EOF, Frame, Opcode, ProtocolError, decode_frame, encode_frame, + fragment_payload, MAX_PAYLOAD_BYTES, ) from je_auto_control.utils.usb.passthrough.session import ( SessionError, UsbPassthroughSession, @@ -27,8 +37,11 @@ __all__ = [ "FakeUsbBackend", "LibusbBackend", "UsbBackend", "UsbHandle", + "default_passthrough_backend", + "LoopbackTransport", "UsbLoopback", "enable_usb_passthrough", "is_usb_passthrough_enabled", - "Frame", "Opcode", "ProtocolError", "decode_frame", "encode_frame", + "FLAG_EOF", "Frame", "Opcode", "ProtocolError", + "decode_frame", "encode_frame", "fragment_payload", "MAX_PAYLOAD_BYTES", "SessionError", "UsbPassthroughSession", "ClientHandle", "UsbClientClosed", "UsbClientError", "UsbClientTimeout", diff --git a/je_auto_control/utils/usb/passthrough/acl.py b/je_auto_control/utils/usb/passthrough/acl.py index 911b09fe..f2ec7b52 100644 --- a/je_auto_control/utils/usb/passthrough/acl.py +++ b/je_auto_control/utils/usb/passthrough/acl.py @@ -30,13 +30,36 @@ * ``"prompt"`` — defer to the host operator. The session will call the ``prompt_callback`` and treat its return value as the decision. -File integrity (HMAC / keychain signing) is intentionally out of scope -for Phase 2d — see the design doc's "open question 8". +File integrity (open question 8) — RESOLVED in Phase 2d.1 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ACL file is protected by an HMAC-SHA256 signature written to a +sidecar ``.sig`` file. On load, the signature is verified against +the file bytes; a mismatch makes the ACL fail closed (default-deny, +``integrity_ok`` False) so a process that silently rewrites the JSON +cannot grant itself access without also forging the signature. + +The signing key is pluggable so deployments can derive it from a +platform keychain: pass ``hmac_key=`` to the constructor. When +no key is supplied, a 32-byte random key is generated once and stored +next to the ACL as ``.key`` (mode ``0o600`` on POSIX). This raises +the bar against naive tampering; a same-user process that can read the +key file can still forge a signature, which is why keychain-derived +keys are recommended for high-assurance deployments (see the operator +guide). + +Files written before signing existed are treated as *legacy unsigned*: +they still load (so upgrades don't lock operators out) but a signature +is written on the next save. Pass ``require_signature=True`` to refuse +unsigned files outright. """ from __future__ import annotations +import hashlib +import hmac import json import os +import secrets import threading from dataclasses import asdict, dataclass, field from pathlib import Path @@ -49,6 +72,9 @@ _DEFAULT_PATH_RELATIVE = ".je_auto_control/usb_acl.json" _VALID_DEFAULTS = frozenset({"allow", "deny"}) _VALID_DECISIONS = frozenset({"allow", "deny", "prompt"}) +_SIG_SUFFIX = ".sig" +_KEY_SUFFIX = ".key" +_KEY_BYTES = 32 def default_acl_path() -> Path: @@ -100,8 +126,15 @@ class UsbAcl: """Persistent per-device allow-list.""" def __init__(self, *, path: Optional[Path] = None, - default_policy: str = "deny") -> None: + default_policy: str = "deny", + hmac_key: Optional[bytes] = None, + require_signature: bool = False) -> None: self._path = Path(path) if path is not None else default_acl_path() + self._sig_path = self._path.with_name(self._path.name + _SIG_SUFFIX) + self._key_path = self._path.with_name(self._path.name + _KEY_SUFFIX) + self._explicit_key = bytes(hmac_key) if hmac_key is not None else None + self._require_signature = bool(require_signature) + self._integrity_ok = True self._lock = threading.Lock() if default_policy not in _VALID_DEFAULTS: raise ValueError( @@ -115,6 +148,11 @@ def __init__(self, *, path: Optional[Path] = None, def path(self) -> Path: return self._path + @property + def integrity_ok(self) -> bool: + """False once a signature mismatch was seen on load.""" + return self._integrity_ok + @property def default_policy(self) -> str: with self._lock: @@ -168,16 +206,109 @@ def decide(self, *, vendor_id: str, product_id: str, return "allow" if rule.allow else "deny" return self._state.default + def verify_integrity(self) -> bool: + """Re-check the on-disk signature; True if intact or no file.""" + try: + raw = self._path.read_bytes() + except OSError: + return True + return self._verify_signature(raw) + + # --- Integrity (HMAC) -------------------------------------------------- + + def _resolve_key(self, *, create: bool) -> Optional[bytes]: + """Return the HMAC key, optionally generating + persisting one.""" + if self._explicit_key is not None: + return self._explicit_key + try: + if self._key_path.exists(): + return self._key_path.read_bytes() + if not create: + return None + key = secrets.token_bytes(_KEY_BYTES) + self._key_path.parent.mkdir(parents=True, exist_ok=True) + self._key_path.write_bytes(key) + if os.name == "posix": + os.chmod(self._key_path, 0o600) + return key + except OSError as error: + autocontrol_logger.warning( + "usb acl key access %s failed: %r", self._key_path, error, + ) + return None + + @staticmethod + def _compute_sig(data: bytes, key: bytes) -> str: + return hmac.new(key, data, hashlib.sha256).hexdigest() + + def _verify_signature(self, raw: bytes) -> bool: + """True iff ``raw`` matches the sidecar signature (or is legacy).""" + if not self._sig_path.exists(): + if self._require_signature: + autocontrol_logger.warning( + "usb acl %s unsigned and require_signature set — deny", + self._path, + ) + return False + autocontrol_logger.info( + "usb acl %s has no signature (legacy/unsigned)", self._path, + ) + return True + key = self._resolve_key(create=False) + if key is None: + autocontrol_logger.warning( + "usb acl signature present but key unavailable for %s", + self._path, + ) + return False + try: + expected = self._sig_path.read_text(encoding="utf-8").strip() + except OSError as error: + autocontrol_logger.warning( + "usb acl signature read %s failed: %r", self._sig_path, error, + ) + return False + return hmac.compare_digest(expected, self._compute_sig(raw, key)) + + def _write_signature(self, data: bytes) -> None: + key = self._resolve_key(create=True) + if key is None: + return + try: + self._sig_path.write_text( + self._compute_sig(data, key), encoding="utf-8", + ) + if os.name == "posix": + os.chmod(self._sig_path, 0o600) + except OSError as error: + autocontrol_logger.warning( + "usb acl signature write %s failed: %r", self._sig_path, error, + ) + # --- Persistence ------------------------------------------------------- def _load(self) -> None: try: - payload = json.loads(self._path.read_text(encoding="utf-8")) - except (OSError, ValueError) as error: + raw = self._path.read_bytes() + except OSError as error: autocontrol_logger.warning( "usb acl load %s failed: %r", self._path, error, ) return + if not self._verify_signature(raw): + self._integrity_ok = False + autocontrol_logger.warning( + "usb acl signature mismatch for %s — refusing to load " + "(fail closed, default-deny)", self._path, + ) + return + try: + payload = json.loads(raw.decode("utf-8")) + except ValueError as error: + autocontrol_logger.warning( + "usb acl parse %s failed: %r", self._path, error, + ) + return try: version = int(payload.get("version", 0)) if version != _ACL_VERSION: @@ -209,18 +340,22 @@ def _save(self) -> None: "default": self._state.default, "rules": [r.to_dict() for r in self._state.rules], } + data = json.dumps( + payload, indent=2, ensure_ascii=False, + ).encode("utf-8") try: self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False), - encoding="utf-8", - ) + self._path.write_bytes(data) if os.name == "posix": os.chmod(self._path, 0o600) except OSError as error: autocontrol_logger.warning( "usb acl save %s failed: %r", self._path, error, ) + return + # A freshly written file is, by definition, intact again. + self._write_signature(data) + self._integrity_ok = True __all__ = [ diff --git a/je_auto_control/utils/usb/passthrough/backend.py b/je_auto_control/utils/usb/passthrough/backend.py index 838e8c20..a4841af5 100644 --- a/je_auto_control/utils/usb/passthrough/backend.py +++ b/je_auto_control/utils/usb/passthrough/backend.py @@ -8,6 +8,7 @@ from __future__ import annotations import abc +import platform import threading from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional @@ -149,11 +150,59 @@ def __init__(self, device: Any) -> None: self._device = device self._closed = False self._lock = threading.Lock() + # OQ7 — on Linux the kernel's usbhid driver claims anything that + # looks like a HID device. Detach it on open so we can claim the + # interface, and remember which interfaces we touched so close() + # can hand them back to the kernel (otherwise the host OS loses + # its keyboard / mouse for good). + self._detached_interfaces: List[int] = [] + self._detach_kernel_drivers() + + def _detach_kernel_drivers(self) -> None: + for number in self._active_interface_numbers(): + try: + if self._device.is_kernel_driver_active(number): + self._device.detach_kernel_driver(number) + self._detached_interfaces.append(number) + except NotImplementedError: + # libusb on Windows / macOS doesn't support detach — the + # OS handles driver arbitration. Nothing to do. + return + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: detach is best-effort; a claim failure later will surface clearly + autocontrol_logger.debug( + "libusb: detach interface %s skipped: %r", number, error, + ) + + def _reattach_kernel_drivers(self) -> None: + for number in self._detached_interfaces: + try: + self._device.attach_kernel_driver(number) + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: best-effort restore; surface via logger + autocontrol_logger.debug( + "libusb: reattach interface %s failed: %r", number, error, + ) + self._detached_interfaces.clear() + + def _active_interface_numbers(self) -> List[int]: + try: + config = self._device.get_active_configuration() + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: a device with no active config has nothing to detach + autocontrol_logger.debug( + "libusb: no active configuration to detach: %r", error, + ) + return [] + numbers: List[int] = [] + for interface in config: + number = getattr(interface, "bInterfaceNumber", None) + if number is not None and int(number) not in numbers: + numbers.append(int(number)) + return numbers def close(self) -> None: with self._lock: if self._closed: return + self._reattach_kernel_drivers() try: self._device.reset() except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: best-effort cleanup; surface via logger so it's not invisible @@ -377,7 +426,38 @@ def _dispatch(self, kind: str, kwargs: Dict[str, Any]) -> bytes: return b"\x00" * int(kwargs.get("length", 0)) +# --------------------------------------------------------------------------- +# Backend factory +# --------------------------------------------------------------------------- + + +def default_passthrough_backend() -> UsbBackend: + """Return the right :class:`UsbBackend` for the current OS. + + * Windows → ``WinusbBackend`` (devices must be bound to WinUSB). + * macOS → ``IokitBackend`` (native IOKit enumeration, libusb transfers). + * everything else (Linux/BSD) → :class:`LibusbBackend`. + + Backend-specific imports stay lazy so importing this module never + drags in ctypes bindings for a foreign platform. Raises + ``RuntimeError`` if the chosen backend's dependencies are missing. + """ + system = platform.system() + if system == "Windows": + from je_auto_control.utils.usb.passthrough.winusb_backend import ( + WinusbBackend, + ) + return WinusbBackend() + if system == "Darwin": + from je_auto_control.utils.usb.passthrough.iokit_backend import ( + IokitBackend, + ) + return IokitBackend() + return LibusbBackend() + + __all__ = [ "BackendDevice", "FakeUsbBackend", "FakeUsbHandle", "LibusbBackend", "UsbBackend", "UsbHandle", + "default_passthrough_backend", ] diff --git a/je_auto_control/utils/usb/passthrough/iokit_backend.py b/je_auto_control/utils/usb/passthrough/iokit_backend.py index 90182b15..3aa44b6d 100644 --- a/je_auto_control/utils/usb/passthrough/iokit_backend.py +++ b/je_auto_control/utils/usb/passthrough/iokit_backend.py @@ -1,47 +1,100 @@ -"""Phase 2c — macOS ``IOKit`` backend (structural skeleton). - -**This is a skeleton. It will not transfer any bytes.** Wiring the -``IOUSBHostInterface`` callbacks against real USB hardware on macOS is -a discrete project — see the design doc for context. - -What's here: - -* The :class:`IokitBackend` class. -* Platform / dependency validation (Darwin + pyobjc). -* Documented list of IOKit / pyobjc call sites that still need writing. - -What's NOT here: - -* ``IOServiceMatching("IOUSBDevice")`` enumeration. -* ``IOUSBHostInterface`` claim + ``CompletionMethod`` callbacks. -* ``CFRunLoop`` thread integration to bridge async IO completions - back to the WebRTC bridge thread (see design doc OPEN question 6). - -Implementation TODOs: - -1. Use ``IOKit`` matching dictionary to enumerate USB devices by - vendor / product. Translate IOKit error codes into ``RuntimeError``. -2. Open the device interface (``IOUSBHostInterface`` on 10.12+). -3. Wrap synchronous control / bulk / interrupt calls; for async - transfers, register completion callbacks tied to a dedicated - ``CFRunLoop`` thread. -4. Handle ``kIOReturnExclusiveAccess`` (another driver claimed the - device) with a clear "cannot claim, busy" RuntimeError. -5. Document the entitlement / notarisation story for distribution. -6. Hardware test matrix similar to WinUSB: bulk, HID, composite. +"""Phase 2c — macOS ``IOKit`` backend. + +Enumeration is native: it walks the IOKit registry through ``ctypes`` +bindings to ``IOKit.framework`` / ``CoreFoundation.framework`` (no +``pyobjc`` dependency) and reads each USB device's ``idVendor`` / +``idProduct`` / serial / ``locationID``. + +Transfers (open question 6) go through ``libusb`` via +:class:`LibusbBackend`. ``libusb`` is the hardware-proven USB path on +macOS, and reusing it avoids hand-rolling the COM-style +``IOUSBHostInterface`` plugin vtable in ``ctypes`` — code that could not +be verified without hardware and would duplicate logic libusb already +ships. The native IOKit enumeration still lets the host list devices +without pyusb installed; an actual *claim* requires libusb. + +.. warning:: + **HARDWARE-UNVERIFIED.** The ctypes enumeration bindings are + structurally tested but have not been validated against real macOS + USB hardware. Until a reviewer signs the relevant rows of + :doc:`usb_passthrough_security_review`, this backend MUST be gated by + ``enable_usb_passthrough(True)`` and used only against hardware the + operator has approved via the ACL. + +Distribution note (open question 6): a directly distributed (non App +Store) build must be notarised; libusb device access on macOS needs no +special entitlement, but System Integrity Protection still hides Apple +internal devices and some USB-C peripherals. See the operator guide. """ from __future__ import annotations +import ctypes import platform from typing import List, Optional from je_auto_control.utils.usb.passthrough.backend import ( - BackendDevice, UsbBackend, UsbHandle, + BackendDevice, LibusbBackend, UsbBackend, UsbHandle, ) +_K_CFSTRING_UTF8 = 0x08000100 +_K_CFNUMBER_SINT64 = 4 +_K_IO_MAIN_PORT_DEFAULT = 0 +_IOKIT_PATH = "/System/Library/Frameworks/IOKit.framework/IOKit" +_CF_PATH = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" +_STRING_BUFFER_BYTES = 512 + + +def _load_frameworks(): + """Load + bind IOKit and CoreFoundation; raise RuntimeError on failure.""" + try: + iokit = ctypes.cdll.LoadLibrary(_IOKIT_PATH) + core = ctypes.cdll.LoadLibrary(_CF_PATH) + except OSError as error: + raise RuntimeError( + f"failed to load macOS IOKit/CoreFoundation: {error!r}", + ) from error + _bind_core_foundation(core) + _bind_iokit(iokit) + return iokit, core + + +def _bind_core_foundation(core: ctypes.CDLL) -> None: + core.CFStringCreateWithCString.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32, + ] + core.CFStringCreateWithCString.restype = ctypes.c_void_p + core.CFStringGetCString.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32, + ] + core.CFStringGetCString.restype = ctypes.c_bool + core.CFNumberGetValue.argtypes = [ + ctypes.c_void_p, ctypes.c_long, ctypes.c_void_p, + ] + core.CFNumberGetValue.restype = ctypes.c_bool + core.CFRelease.argtypes = [ctypes.c_void_p] + core.CFRelease.restype = None + + +def _bind_iokit(iokit: ctypes.CDLL) -> None: + iokit.IOServiceMatching.argtypes = [ctypes.c_char_p] + iokit.IOServiceMatching.restype = ctypes.c_void_p + iokit.IOServiceGetMatchingServices.argtypes = [ + ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), + ] + iokit.IOServiceGetMatchingServices.restype = ctypes.c_int + iokit.IOIteratorNext.argtypes = [ctypes.c_uint32] + iokit.IOIteratorNext.restype = ctypes.c_uint32 + iokit.IORegistryEntryCreateCFProperty.argtypes = [ + ctypes.c_uint32, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, + ] + iokit.IORegistryEntryCreateCFProperty.restype = ctypes.c_void_p + iokit.IOObjectRelease.argtypes = [ctypes.c_uint32] + iokit.IOObjectRelease.restype = ctypes.c_int + + class IokitBackend(UsbBackend): - """Skeleton — see module docstring for the implementation TODO list.""" + """Native IOKit enumeration; libusb-backed transfers (see module doc).""" def __init__(self) -> None: if platform.system() != "Darwin": @@ -49,26 +102,120 @@ def __init__(self) -> None: "IokitBackend requires macOS; current platform is " f"{platform.system()!r}", ) - try: - import objc # noqa: F401 # pyobjc-core - except ImportError as error: - raise RuntimeError( - "IokitBackend requires pyobjc; run 'pip install pyobjc' " - "to enable the IOKit passthrough backend", - ) from error + self._iokit, self._core = _load_frameworks() + self._transfer_backend: Optional[LibusbBackend] = None def list(self) -> List[BackendDevice]: - raise NotImplementedError( - "IOKit enumeration not implemented yet — see " - "iokit_backend module docstring for the TODO list", - ) + return _enumerate(self._iokit, self._core) def open(self, *, vendor_id: str, product_id: str, serial: Optional[str] = None) -> UsbHandle: - raise NotImplementedError( - "IOKit open not implemented yet — see " - "iokit_backend module docstring for the TODO list", + if self._transfer_backend is None: + try: + self._transfer_backend = LibusbBackend() + except RuntimeError as error: + raise RuntimeError( + "IOKit enumeration works without libusb, but claiming a " + "device for transfers needs it: " + str(error), + ) from error + return self._transfer_backend.open( + vendor_id=vendor_id, product_id=product_id, serial=serial, + ) + + +# --------------------------------------------------------------------------- +# Enumeration helpers +# --------------------------------------------------------------------------- + + +def _enumerate(iokit: ctypes.CDLL, core: ctypes.CDLL) -> List[BackendDevice]: + matching = iokit.IOServiceMatching(b"IOUSBDevice") + if not matching: + matching = iokit.IOServiceMatching(b"IOUSBHostDevice") + if not matching: + raise RuntimeError("IOServiceMatching returned NULL for USB devices") + iterator = ctypes.c_uint32(0) + result = iokit.IOServiceGetMatchingServices( + _K_IO_MAIN_PORT_DEFAULT, matching, ctypes.byref(iterator), + ) + if result != 0: + raise RuntimeError( + f"IOServiceGetMatchingServices failed: kern_return={result}", + ) + devices: List[BackendDevice] = [] + try: + while True: + service = iokit.IOIteratorNext(iterator) + if not service: + break + try: + device = _read_device(iokit, core, service) + finally: + iokit.IOObjectRelease(service) + if device is not None: + devices.append(device) + finally: + iokit.IOObjectRelease(iterator) + return devices + + +def _read_device(iokit: ctypes.CDLL, core: ctypes.CDLL, + service: int) -> Optional[BackendDevice]: + vendor = _read_number(iokit, core, service, "idVendor") + product = _read_number(iokit, core, service, "idProduct") + if vendor is None or product is None: + return None + location = _read_number(iokit, core, service, "locationID") + return BackendDevice( + vendor_id=f"{vendor & 0xFFFF:04x}", + product_id=f"{product & 0xFFFF:04x}", + serial=_read_string(iokit, core, service, "USB Serial Number"), + bus_location=(None if location is None else f"0x{location & 0xFFFFFFFF:08x}"), + ) + + +def _property_ref(iokit: ctypes.CDLL, core: ctypes.CDLL, + service: int, key_name: str): + key = core.CFStringCreateWithCString( + None, key_name.encode("utf-8"), _K_CFSTRING_UTF8, + ) + if not key: + return None + try: + return iokit.IORegistryEntryCreateCFProperty(service, key, None, 0) + finally: + core.CFRelease(key) + + +def _read_number(iokit: ctypes.CDLL, core: ctypes.CDLL, + service: int, key_name: str) -> Optional[int]: + ref = _property_ref(iokit, core, service, key_name) + if not ref: + return None + try: + out = ctypes.c_int64(0) + ok = core.CFNumberGetValue(ref, _K_CFNUMBER_SINT64, ctypes.byref(out)) + return int(out.value) if ok else None + finally: + core.CFRelease(ref) + + +def _read_string(iokit: ctypes.CDLL, core: ctypes.CDLL, + service: int, key_name: str) -> Optional[str]: + ref = _property_ref(iokit, core, service, key_name) + if not ref: + return None + try: + buffer = ctypes.create_string_buffer(_STRING_BUFFER_BYTES) + ok = core.CFStringGetCString( + ref, buffer, len(buffer), _K_CFSTRING_UTF8, ) + if not ok: + return None + text = buffer.value.decode("utf-8", errors="replace").strip() + return text or None + finally: + core.CFRelease(ref) __all__ = ["IokitBackend"] diff --git a/je_auto_control/utils/usb/passthrough/loopback.py b/je_auto_control/utils/usb/passthrough/loopback.py new file mode 100644 index 00000000..06c91e94 --- /dev/null +++ b/je_auto_control/utils/usb/passthrough/loopback.py @@ -0,0 +1,157 @@ +"""In-process loopback transport for USB passthrough. + +Wires a viewer-side :class:`UsbPassthroughClient` directly to a local +:class:`UsbPassthroughSession`, so the same machine can both *share* and +*use* a USB device with no WebRTC DataChannel in between. A daemon pump +thread routes client→host frames into the session and feeds the host's +replies back to the client. + +This is the headless engine the AnyDesk-style USB panel drives, and it +is the clean seam where a real remote transport plugs in later: anything +that supplies a ``send_frame`` callable and calls ``client.feed_frame`` +on inbound frames is a drop-in replacement for :class:`LoopbackTransport`. + +Qt-free on purpose — importing it never drags in PySide6. +""" +from __future__ import annotations + +import queue +import threading +from typing import Any, Callable, Dict, List, Optional + +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.usb.passthrough.acl import UsbAcl +from je_auto_control.utils.usb.passthrough.backend import ( + UsbBackend, default_passthrough_backend, +) +from je_auto_control.utils.usb.passthrough.protocol import Frame +from je_auto_control.utils.usb.passthrough.session import UsbPassthroughSession +from je_auto_control.utils.usb.passthrough.viewer_client import ( + ClientHandle, UsbPassthroughClient, +) + + +_PUMP_POLL_S = 0.2 +_JOIN_TIMEOUT_S = 2.0 + + +class LoopbackTransport: + """Routes frames between a client and a local session on a pump thread.""" + + def __init__(self, session: UsbPassthroughSession) -> None: + self._session = session + self._queue: "queue.Queue[Optional[Frame]]" = queue.Queue() + self._client: Optional[UsbPassthroughClient] = None + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def bind(self, client: UsbPassthroughClient) -> None: + """Attach the client whose replies this transport will feed.""" + self._client = client + + def send_frame(self, frame: Frame) -> None: + """Client-side ``send_frame`` callable — enqueue for the pump.""" + self._queue.put(frame) + + def start(self) -> None: + if self._thread is not None: + return + self._stop.clear() + thread = threading.Thread( + target=self._pump, name="usb-loopback", daemon=True, + ) + self._thread = thread + thread.start() + + def stop(self) -> None: + self._stop.set() + self._queue.put(None) # unblock the pump immediately + thread = self._thread + self._thread = None + if thread is not None: + thread.join(timeout=_JOIN_TIMEOUT_S) + + def _pump(self) -> None: + while not self._stop.is_set(): + try: + frame = self._queue.get(timeout=_PUMP_POLL_S) + except queue.Empty: + continue + if frame is None: + return + self._route(frame) + + def _route(self, frame: Frame) -> None: + try: + replies = self._session.handle_frame(frame) + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: a session error must not kill the pump thread + autocontrol_logger.warning( + "usb loopback: session.handle_frame raised %r", error, + ) + return + client = self._client + if client is None: + return + for reply in replies: + client.feed_frame(reply) + + +class UsbLoopback: + """Bundled session + loopback transport + client for same-machine use. + + ``list_devices`` / ``open`` drive the full protocol stack (frames, + ACL gating, credit flow control) exactly as a remote viewer would, + just over an in-process transport. Use as a context manager or call + :meth:`close` when done. + """ + + def __init__(self, *, backend: Optional[UsbBackend] = None, + acl: Optional[UsbAcl] = None, + prompt_callback: Optional[ + Callable[[str, str, Optional[str]], bool] + ] = None, + viewer_id: str = "loopback", + max_claims: int = 4, + reply_timeout_s: float = 10.0) -> None: + self.session = UsbPassthroughSession( + backend if backend is not None else default_passthrough_backend(), + acl=acl, prompt_callback=prompt_callback, + viewer_id=viewer_id, max_claims=max_claims, + ) + self.transport = LoopbackTransport(self.session) + self.client = UsbPassthroughClient( + send_frame=self.transport.send_frame, + reply_timeout_s=reply_timeout_s, + ) + self.transport.bind(self.client) + self.transport.start() + self._closed = False + + def list_devices(self) -> List[Dict[str, Any]]: + """Enumerate ACL-visible devices over the loopback channel.""" + return self.client.list_devices() + + def open(self, *, vendor_id: str, product_id: str, + serial: Optional[str] = None) -> ClientHandle: + """Claim a device; returns a viewer-side handle for transfers.""" + return self.client.open( + vendor_id=vendor_id, product_id=product_id, serial=serial, + ) + + def close(self) -> None: + """Tear down client, transport, and host claims. Idempotent.""" + if self._closed: + return + self._closed = True + self.client.shutdown() + self.transport.stop() + self.session.close_all() + + def __enter__(self) -> "UsbLoopback": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + +__all__ = ["LoopbackTransport", "UsbLoopback"] diff --git a/je_auto_control/utils/usb/passthrough/protocol.py b/je_auto_control/utils/usb/passthrough/protocol.py index 98baff6a..ae460865 100644 --- a/je_auto_control/utils/usb/passthrough/protocol.py +++ b/je_auto_control/utils/usb/passthrough/protocol.py @@ -79,6 +79,30 @@ def encode_frame(frame: Frame) -> bytes: return header + bytes(frame.payload) +def fragment_payload(op: "Opcode", claim_id: int, payload: bytes, + *, base_flags: int = 0) -> list: + """Split ``payload`` into EOF-terminated frames (open question 2). + + A payload that fits in a single frame yields exactly one frame with + :data:`FLAG_EOF` set. A larger payload is chunked at + :data:`MAX_PAYLOAD_BYTES`; every chunk but the last clears + ``FLAG_EOF`` and the receiver reassembles by concatenating payloads + of consecutive same-``claim_id`` frames until the EOF flag arrives. + """ + data = bytes(payload) + frames = [] + if not data: + return [Frame(op=op, flags=base_flags | FLAG_EOF, + claim_id=claim_id, payload=b"")] + for start in range(0, len(data), MAX_PAYLOAD_BYTES): + chunk = data[start:start + MAX_PAYLOAD_BYTES] + is_last = start + MAX_PAYLOAD_BYTES >= len(data) + flags = base_flags | (FLAG_EOF if is_last else 0) + frames.append(Frame(op=op, flags=flags, + claim_id=claim_id, payload=chunk)) + return frames + + def decode_frame(data: bytes) -> Frame: """Parse one frame from ``data``; raise :class:`ProtocolError` on failure.""" if not isinstance(data, (bytes, bytearray, memoryview)): @@ -102,6 +126,6 @@ def decode_frame(data: bytes) -> Frame: __all__ = [ "Frame", "Opcode", "ProtocolError", - "decode_frame", "encode_frame", + "decode_frame", "encode_frame", "fragment_payload", "MAX_PAYLOAD_BYTES", "HEADER_BYTES", "FLAG_EOF", ] diff --git a/je_auto_control/utils/usb/passthrough/session.py b/je_auto_control/utils/usb/passthrough/session.py index b1755e91..4359f628 100644 --- a/je_auto_control/utils/usb/passthrough/session.py +++ b/je_auto_control/utils/usb/passthrough/session.py @@ -5,10 +5,10 @@ returned as a list of frames the caller is expected to send back over the same channel. -Phase 2a.1 implements OPEN/OPENED, CLOSE/CLOSED, and the three transfer -opcodes (CTRL/BULK/INT) plus a CREDIT-based inbound flow control. -``LIST`` responses, viewer-side flow control, and the actual viewer -client stay TODO for later phases. +Handles OPEN/OPENED, CLOSE/CLOSED, the three transfer opcodes +(CTRL/BULK/INT) with CREDIT-based inbound flow control, and +LIST-over-channel (ACL-filtered). Oversize replies are fragmented with +``FLAG_EOF``. The symmetric viewer side lives in ``viewer_client``. OPEN payload (UTF-8 JSON):: @@ -67,7 +67,7 @@ from je_auto_control.utils.usb.passthrough.acl import UsbAcl from je_auto_control.utils.usb.passthrough.backend import UsbBackend, UsbHandle from je_auto_control.utils.usb.passthrough.protocol import ( - Frame, Opcode, + Frame, Opcode, fragment_payload, ) @@ -156,12 +156,48 @@ def handle_frame(self, frame: Frame) -> List[Frame]: if frame.op == Opcode.CREDIT: self._handle_credit(frame) return [] - if frame.op in (Opcode.OPENED, Opcode.CLOSED, Opcode.ERROR, - Opcode.LIST): + if frame.op == Opcode.LIST: + return self._handle_list(frame) + if frame.op in (Opcode.OPENED, Opcode.CLOSED, Opcode.ERROR): # Responses we don't expect to receive on the host side here. return [] return [_error_frame(frame.claim_id, f"unsupported opcode {frame.op}")] + # --- LIST --------------------------------------------------------------- + + def _handle_list(self, frame: Frame) -> List[Frame]: + """Enumerate backend devices the ACL would not outright deny. + + Resolves open question 3: the device list rides the same ``usb`` + DataChannel as transfers instead of forcing a second REST round + trip. Devices the ACL denies are filtered out so the viewer never + learns they exist. + """ + try: + devices = self._backend.list() + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: backends raise their own error types + return [_error_frame(frame.claim_id, f"list failed: {error}")] + visible = [ + { + "vendor_id": dev.vendor_id, + "product_id": dev.product_id, + "serial": dev.serial, + "bus_location": dev.bus_location, + } + for dev in devices + if self._list_visible(dev.vendor_id, dev.product_id, dev.serial) + ] + payload = _encode_json_payload({"devices": visible}) + return fragment_payload(Opcode.LIST, frame.claim_id, payload) + + def _list_visible(self, vendor_id: str, product_id: str, + serial: Optional[str]) -> bool: + if self._acl is None: + return True + return self._acl.decide( + vendor_id=vendor_id, product_id=product_id, serial=serial, + ) != "deny" + # --- OPEN / CLOSE ------------------------------------------------------- def _handle_open(self, frame: Frame) -> Frame: @@ -310,20 +346,18 @@ def _handle_transfer(self, frame: Frame, reply_payload = _encode_json_payload( {"ok": False, "error": str(error)}, ) - return [ - Frame(op=_reply_opcode(frame.op), claim_id=frame.claim_id, - payload=reply_payload), - self._make_credit_frame(frame.claim_id, _TOPUP_PER_REPLY), - ] - reply_payload = _encode_json_payload({ - "ok": True, - "data": base64.b64encode(result_bytes).decode("ascii"), - }) - return [ - Frame(op=_reply_opcode(frame.op), claim_id=frame.claim_id, - payload=reply_payload), - self._make_credit_frame(frame.claim_id, _TOPUP_PER_REPLY), - ] + else: + reply_payload = _encode_json_payload({ + "ok": True, + "data": base64.b64encode(result_bytes).decode("ascii"), + }) + # Fragment so an oversize IN transfer (open question 2) spans + # multiple EOF-terminated frames instead of breaching the cap. + frames = fragment_payload( + _reply_opcode(frame.op), frame.claim_id, reply_payload, + ) + frames.append(self._make_credit_frame(frame.claim_id, _TOPUP_PER_REPLY)) + return frames def _handle_credit(self, frame: Frame) -> None: try: diff --git a/je_auto_control/utils/usb/passthrough/viewer_client.py b/je_auto_control/utils/usb/passthrough/viewer_client.py index be886d4c..8e69f9af 100644 --- a/je_auto_control/utils/usb/passthrough/viewer_client.py +++ b/je_auto_control/utils/usb/passthrough/viewer_client.py @@ -40,7 +40,7 @@ from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.utils.usb.passthrough.protocol import ( - Frame, Opcode, + FLAG_EOF, Frame, Opcode, ) @@ -64,11 +64,17 @@ class UsbClientClosed(UsbClientError): @dataclass class _PendingRequest: - """One outstanding viewer→host request awaiting a reply frame.""" + """One outstanding viewer→host request awaiting a reply. + + The reply is stored as ``(reply_op, reply_payload)`` rather than a + :class:`Frame` because a reassembled payload (open question 2) may + exceed the per-frame cap that :class:`Frame` enforces. + """ expected_op: Opcode event: threading.Event - reply: Optional[Frame] = None + reply_op: Optional[Opcode] = None + reply_payload: bytes = b"" cancelled: bool = False @@ -175,6 +181,10 @@ def __init__( self._credits: Dict[int, int] = {} self._credit_events: Dict[int, threading.Event] = {} self._open_pending: Optional[_PendingRequest] = None + self._list_pending: Optional[_PendingRequest] = None + # Reassembly buffers for fragmented replies, keyed by claim_id + # (open question 2). LIST uses claim_id 0. + self._reasm: Dict[int, bytearray] = {} self._initial_credit_guess = max(1, int(initial_credit_guess)) self._closed = False @@ -187,8 +197,12 @@ def shutdown(self) -> None: pending: List[_PendingRequest] = list(self._pending.values()) if self._open_pending is not None: pending.append(self._open_pending) + if self._list_pending is not None: + pending.append(self._list_pending) self._pending.clear() self._open_pending = None + self._list_pending = None + self._reasm.clear() credit_events = list(self._credit_events.values()) for request in pending: request.cancelled = True @@ -204,13 +218,18 @@ def feed_frame(self, frame: Frame) -> None: self._on_opened(frame) return if frame.op == Opcode.CLOSED: - self._complete_pending(frame.claim_id, frame, Opcode.CLOSED) + self._complete_pending(frame.claim_id, frame.payload, Opcode.CLOSED) return if frame.op == Opcode.CREDIT: self._on_credit(frame) return if frame.op in (Opcode.CTRL, Opcode.BULK, Opcode.INT): - self._complete_pending(frame.claim_id, frame, frame.op) + assembled = self._reassemble(frame) + if assembled is not None: + self._complete_pending(frame.claim_id, assembled, frame.op) + return + if frame.op == Opcode.LIST: + self._on_list(frame) return if frame.op == Opcode.ERROR: self._on_error(frame) @@ -219,6 +238,18 @@ def feed_frame(self, frame: Frame) -> None: "passthrough client: ignoring incoming opcode %s", frame.op, ) + def _reassemble(self, frame: Frame) -> Optional[bytes]: + """Buffer a fragment; return the full payload once EOF arrives.""" + cid = int(frame.claim_id) + with self._lock: + buffer = self._reasm.setdefault(cid, bytearray()) + buffer.extend(frame.payload) + if not (frame.flags & FLAG_EOF): + return None + full = bytes(buffer) + self._reasm.pop(cid, None) + return full + # --- Outbound: open / close --------------------------------------------- def open(self, *, vendor_id: str, product_id: str, @@ -246,10 +277,9 @@ def open(self, *, vendor_id: str, product_id: str, raise UsbClientTimeout("OPEN timed out") if request.cancelled: raise UsbClientClosed("client shut down before OPEN reply") - reply = request.reply - if reply is None: + if request.reply_op is None: raise UsbClientError("event signalled without a reply") - body = _decode_json(reply.payload) + body = _decode_json(request.reply_payload) if not body.get("ok"): raise UsbClientError(body.get("error", "open failed")) claim_id = int(body["claim_id"]) @@ -258,6 +288,34 @@ def open(self, *, vendor_id: str, product_id: str, self._credit_events[claim_id] = threading.Event() return ClientHandle(self, claim_id) + def list_devices(self) -> List[Dict[str, Any]]: + """Ask the host for the ACL-visible device list (open question 3). + + Blocks until the host replies. Returns a list of dicts with + ``vendor_id`` / ``product_id`` / ``serial`` / ``bus_location``. + """ + request = _PendingRequest( + expected_op=Opcode.LIST, event=threading.Event(), + ) + with self._lock: + if self._closed: + raise UsbClientClosed(_CLIENT_SHUT_DOWN_MSG) + if self._list_pending is not None: + raise UsbClientError("another list is in progress") + self._list_pending = request + self._send(Frame(op=Opcode.LIST)) + if not request.event.wait(timeout=self._reply_timeout): + with self._lock: + if self._list_pending is request: + self._list_pending = None + self._reasm.pop(0, None) + raise UsbClientTimeout("LIST timed out") + if request.cancelled: + raise UsbClientClosed("client shut down before LIST reply") + body = _decode_json(request.reply_payload) + devices = body.get("devices") + return list(devices) if isinstance(devices, list) else [] + def _exchange_close(self, claim_id: int) -> None: request = _PendingRequest( expected_op=Opcode.CLOSED, event=threading.Event(), @@ -296,13 +354,12 @@ def _exchange_transfer(self, claim_id: int, op: Opcode, raise UsbClientTimeout(f"{op.name} timed out for claim {claim_id}") if request.cancelled: raise UsbClientClosed("client shut down before reply") - reply = request.reply - if reply is None: + if request.reply_op is None: raise UsbClientError("event signalled without a reply") - if reply.op == Opcode.ERROR: - err = _decode_json(reply.payload).get("error", "host ERROR") + if request.reply_op == Opcode.ERROR: + err = _decode_json(request.reply_payload).get("error", "host ERROR") raise UsbClientError(err) - body = _decode_json(reply.payload) + body = _decode_json(request.reply_payload) if not body.get("ok"): raise UsbClientError(body.get("error", "transfer failed")) return base64.b64decode(body.get("data") or "") @@ -314,7 +371,20 @@ def _on_opened(self, frame: Frame) -> None: request = self._open_pending self._open_pending = None if request is not None: - request.reply = frame + request.reply_op = frame.op + request.reply_payload = frame.payload + request.event.set() + + def _on_list(self, frame: Frame) -> None: + assembled = self._reassemble(frame) + if assembled is None: + return + with self._lock: + request = self._list_pending + self._list_pending = None + if request is not None: + request.reply_op = Opcode.LIST + request.reply_payload = assembled request.event.set() def _on_credit(self, frame: Frame) -> None: @@ -338,16 +408,18 @@ def _on_error(self, frame: Frame) -> None: # the claim_id; if none, log and drop. with self._lock: request = self._pending.pop(int(frame.claim_id), None) + self._reasm.pop(int(frame.claim_id), None) if request is None: autocontrol_logger.warning( "passthrough client: unsolicited ERROR for claim %s: %s", frame.claim_id, frame.payload[:200], ) return - request.reply = frame + request.reply_op = frame.op + request.reply_payload = frame.payload request.event.set() - def _complete_pending(self, claim_id: int, frame: Frame, + def _complete_pending(self, claim_id: int, payload: bytes, expected_op: Opcode) -> None: with self._lock: request = self._pending.get(int(claim_id)) @@ -356,7 +428,8 @@ def _complete_pending(self, claim_id: int, frame: Frame, if request.expected_op != expected_op: return self._pending.pop(int(claim_id), None) - request.reply = frame + request.reply_op = expected_op + request.reply_payload = payload request.event.set() # --- Credit helpers ---------------------------------------------------- diff --git a/test/unit_test/headless/test_usb_acl.py b/test/unit_test/headless/test_usb_acl.py index 5193f69d..39d0d589 100644 --- a/test/unit_test/headless/test_usb_acl.py +++ b/test/unit_test/headless/test_usb_acl.py @@ -237,3 +237,75 @@ def test_save_persists_to_disk_with_safe_mode(tmp_path): if _os.name == "posix": mode = path.stat().st_mode & 0o777 assert mode == 0o600 + + +# --------------------------------------------------------------------------- +# OQ8 — file integrity (HMAC signature) +# --------------------------------------------------------------------------- + + +def test_save_writes_sidecar_signature(tmp_path): + path = tmp_path / "acl.json" + acl = UsbAcl(path=path) + acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + sig = path.with_name(path.name + ".sig") + assert sig.exists() + assert acl.integrity_ok is True + assert acl.verify_integrity() is True + + +def test_tampered_acl_file_fails_closed(tmp_path): + path = tmp_path / "acl.json" + a = UsbAcl(path=path, default_policy="allow") + a.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + # Tamper: rewrite the JSON without refreshing the signature. + forged = json.loads(path.read_text(encoding="utf-8")) + forged["default"] = "allow" + forged["rules"].append({ + "vendor_id": "dead", "product_id": "beef", "allow": True, + }) + path.write_text(json.dumps(forged), encoding="utf-8") + # Reload: signature no longer matches → fail closed. + b = UsbAcl(path=path) + assert b.integrity_ok is False + assert b.default_policy == "deny" + assert b.list_rules() == [] + assert b.decide(vendor_id="dead", product_id="beef", serial=None) == "deny" + + +def test_explicit_key_roundtrip_and_wrong_key_fails(tmp_path): + path = tmp_path / "acl.json" + key_a = b"\x01" * 32 + a = UsbAcl(path=path, hmac_key=key_a, default_policy="allow") + a.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + # Same key → loads. + same = UsbAcl(path=path, hmac_key=key_a) + assert same.integrity_ok is True + assert len(same.list_rules()) == 1 + # Different key → signature mismatch → fail closed. + other = UsbAcl(path=path, hmac_key=b"\x02" * 32) + assert other.integrity_ok is False + assert other.list_rules() == [] + + +def test_require_signature_rejects_legacy_unsigned(tmp_path): + path = tmp_path / "acl.json" + path.write_text(json.dumps({ + "version": 1, "default": "allow", + "rules": [{"vendor_id": "1050", "product_id": "0407", "allow": True}], + }), encoding="utf-8") + acl = UsbAcl(path=path, require_signature=True) + assert acl.integrity_ok is False + assert acl.default_policy == "deny" + + +def test_legacy_unsigned_file_still_loads_by_default(tmp_path): + path = tmp_path / "acl.json" + path.write_text(json.dumps({ + "version": 1, "default": "allow", + "rules": [{"vendor_id": "1050", "product_id": "0407", "allow": True}], + }), encoding="utf-8") + acl = UsbAcl(path=path) + assert acl.integrity_ok is True + assert acl.default_policy == "allow" + assert len(acl.list_rules()) == 1 diff --git a/test/unit_test/headless/test_usb_loopback.py b/test/unit_test/headless/test_usb_loopback.py new file mode 100644 index 00000000..1bd3fa28 --- /dev/null +++ b/test/unit_test/headless/test_usb_loopback.py @@ -0,0 +1,79 @@ +"""Tests for the in-process USB loopback transport + UsbLoopback bundle.""" +import pytest + +from je_auto_control.utils.usb.passthrough import ( + AclRule, UsbAcl, UsbClientError, UsbLoopback, +) +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") +_OTHER = BackendDevice(vendor_id="2222", product_id="3333", serial=None) + + +def _allow_all_acl(tmp_path, *devices): + acl = UsbAcl(path=tmp_path / "acl.json") + for dev in devices: + acl.add_rule(AclRule(vendor_id=dev.vendor_id, + product_id=dev.product_id, allow=True)) + return acl + + +def test_loopback_lists_devices(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE, _OTHER]) + acl = _allow_all_acl(tmp_path, _SAMPLE, _OTHER) + with UsbLoopback(backend=backend, acl=acl) as loop: + devices = loop.list_devices() + vids = sorted(d["vendor_id"] for d in devices) + assert vids == ["1050", "2222"] + + +def test_loopback_list_respects_acl_deny(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE, _OTHER]) + acl = _allow_all_acl(tmp_path, _SAMPLE) # only _SAMPLE allowed + with UsbLoopback(backend=backend, acl=acl) as loop: + devices = loop.list_devices() + assert [d["vendor_id"] for d in devices] == ["1050"] + + +def test_loopback_open_and_transfer(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + acl = _allow_all_acl(tmp_path, _SAMPLE) + with UsbLoopback(backend=backend, acl=acl) as loop: + handle = loop.open(vendor_id="1050", product_id="0407") + backend_handle = next(iter(backend._open_handles.values())) + backend_handle.transfer_hook = lambda kind, kwargs: b"\xde\xad" + result = handle.control_transfer( + bm_request_type=0xC0, b_request=6, length=2, + ) + assert result == b"\xde\xad" + assert loop.session.active_claim_count == 1 + handle.close() + assert loop.session.active_claim_count == 0 + + +def test_loopback_open_denied_by_acl_raises(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + acl = UsbAcl(path=tmp_path / "acl.json") # default deny, no rules + with UsbLoopback(backend=backend, acl=acl) as loop: + with pytest.raises(UsbClientError): + loop.open(vendor_id="1050", product_id="0407") + + +def test_loopback_close_is_idempotent(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + acl = _allow_all_acl(tmp_path, _SAMPLE) + loop = UsbLoopback(backend=backend, acl=acl) + loop.close() + loop.close() # must not raise + + +def test_loopback_after_close_rejects_calls(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + acl = _allow_all_acl(tmp_path, _SAMPLE) + loop = UsbLoopback(backend=backend, acl=acl) + loop.close() + with pytest.raises(Exception): + loop.list_devices() diff --git a/test/unit_test/headless/test_usb_passthrough.py b/test/unit_test/headless/test_usb_passthrough.py index 0b6dfca5..03521df6 100644 --- a/test/unit_test/headless/test_usb_passthrough.py +++ b/test/unit_test/headless/test_usb_passthrough.py @@ -430,6 +430,93 @@ def test_usb_handle_is_an_abc(): assert "close" in UsbHandle.__abstractmethods__ +# --------------------------------------------------------------------------- +# OQ7 — libusb kernel-driver detach / reattach lifecycle +# --------------------------------------------------------------------------- + + +class _FakeInterface: + def __init__(self, number: int) -> None: + self.bInterfaceNumber = number + + +class _FakeKernelDevice: + """Stand-in for a pyusb device exercising the detach/reattach path.""" + + def __init__(self, interfaces, *, active=None, + detach_error=None, config_error=None) -> None: + self._interfaces = [_FakeInterface(n) for n in interfaces] + self._active = set(interfaces if active is None else active) + self._detach_error = detach_error + self._config_error = config_error + self.detached: list = [] + self.attached: list = [] + self.reset_called = False + + def get_active_configuration(self): + if self._config_error is not None: + raise self._config_error + return self._interfaces + + def is_kernel_driver_active(self, number: int) -> bool: + return number in self._active + + def detach_kernel_driver(self, number: int) -> None: + if self._detach_error is not None: + raise self._detach_error + self.detached.append(number) + self._active.discard(number) + + def attach_kernel_driver(self, number: int) -> None: + self.attached.append(number) + + def reset(self) -> None: + self.reset_called = True + + +def _make_libusb_handle(device): + from je_auto_control.utils.usb.passthrough.backend import _LibusbHandle + return _LibusbHandle(device) + + +def test_open_detaches_active_kernel_drivers(): + device = _FakeKernelDevice([0, 1]) + _make_libusb_handle(device) + assert device.detached == [0, 1] + + +def test_close_reattaches_detached_drivers(): + device = _FakeKernelDevice([0, 1]) + handle = _make_libusb_handle(device) + handle.close() + assert device.attached == [0, 1] + assert device.reset_called is True + + +def test_inactive_interfaces_are_left_alone(): + device = _FakeKernelDevice([0, 1], active=[]) + handle = _make_libusb_handle(device) + assert device.detached == [] + handle.close() + assert device.attached == [] + + +def test_detach_not_implemented_is_tolerated(): + # libusb on Windows / macOS raises NotImplementedError for detach. + device = _FakeKernelDevice([0], detach_error=NotImplementedError()) + handle = _make_libusb_handle(device) + assert device.detached == [] + handle.close() # must not raise + assert device.attached == [] + + +def test_missing_active_configuration_is_tolerated(): + device = _FakeKernelDevice([0], config_error=ValueError("no config")) + handle = _make_libusb_handle(device) + assert device.detached == [] + handle.close() # must not raise + + # --------------------------------------------------------------------------- # Feature flag — default off # --------------------------------------------------------------------------- diff --git a/test/unit_test/headless/test_usb_passthrough_list_fragment.py b/test/unit_test/headless/test_usb_passthrough_list_fragment.py new file mode 100644 index 00000000..f175d897 --- /dev/null +++ b/test/unit_test/headless/test_usb_passthrough_list_fragment.py @@ -0,0 +1,141 @@ +"""Tests for open questions 2 (fragmentation) and 3 (LIST over channel).""" +import json + +from je_auto_control.utils.usb.passthrough import ( + AclRule, FLAG_EOF, Frame, MAX_PAYLOAD_BYTES, Opcode, UsbAcl, + UsbPassthroughClient, UsbPassthroughSession, fragment_payload, +) +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") +_OTHER = BackendDevice(vendor_id="2222", product_id="3333", serial=None) + + +# --------------------------------------------------------------------------- +# protocol.fragment_payload +# --------------------------------------------------------------------------- + + +def test_fragment_empty_payload_is_single_eof_frame(): + frames = fragment_payload(Opcode.LIST, 0, b"") + assert len(frames) == 1 + assert frames[0].flags & FLAG_EOF + assert frames[0].payload == b"" + + +def test_fragment_small_payload_single_eof_frame(): + frames = fragment_payload(Opcode.CTRL, 7, b"hello") + assert len(frames) == 1 + assert frames[0].flags & FLAG_EOF + assert frames[0].claim_id == 7 + + +def test_fragment_oversize_payload_splits_with_eof_only_on_last(): + payload = b"\xab" * (MAX_PAYLOAD_BYTES * 2 + 100) + frames = fragment_payload(Opcode.BULK, 3, payload) + assert len(frames) == 3 + assert not (frames[0].flags & FLAG_EOF) + assert not (frames[1].flags & FLAG_EOF) + assert frames[2].flags & FLAG_EOF + # Every chunk respects the per-frame cap, and they reassemble cleanly. + assert all(len(f.payload) <= MAX_PAYLOAD_BYTES for f in frames) + assert b"".join(f.payload for f in frames) == payload + + +# --------------------------------------------------------------------------- +# Session — LIST over channel (open question 3) +# --------------------------------------------------------------------------- + + +def _list_devices(replies): + payload = b"".join(f.payload for f in replies) + return json.loads(payload.decode("utf-8"))["devices"] + + +def test_list_without_acl_returns_all_devices(): + session = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE, _OTHER])) + replies = session.handle_frame(Frame(op=Opcode.LIST)) + assert all(r.op == Opcode.LIST for r in replies) + devices = _list_devices(replies) + vids = {d["vendor_id"] for d in devices} + assert vids == {"1050", "2222"} + + +def test_list_filters_acl_denied_devices(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json") # default deny + acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + session = UsbPassthroughSession( + FakeUsbBackend(devices=[_SAMPLE, _OTHER]), acl=acl, + ) + devices = _list_devices(session.handle_frame(Frame(op=Opcode.LIST))) + # Only the allowed device is visible; the denied one is hidden. + assert [d["vendor_id"] for d in devices] == ["1050"] + + +def test_list_includes_prompt_devices(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json") + acl.add_rule(AclRule(vendor_id="2222", product_id="3333", + allow=True, prompt_on_open=True)) + session = UsbPassthroughSession( + FakeUsbBackend(devices=[_SAMPLE, _OTHER]), acl=acl, + ) + devices = _list_devices(session.handle_frame(Frame(op=Opcode.LIST))) + # prompt is not deny → the device stays visible. + assert [d["vendor_id"] for d in devices] == ["2222"] + + +# --------------------------------------------------------------------------- +# Session ↔ client round trips via a synchronous router +# --------------------------------------------------------------------------- + + +class _SyncLoop: + """Routes client frames straight through the host and back. + + The host replies synchronously, so the client's pending event is set + before its ``wait`` runs — no pump thread needed. + """ + + def __init__(self, host: UsbPassthroughSession) -> None: + self._host = host + self.client = UsbPassthroughClient(send_frame=self._send) + + def _send(self, frame: Frame) -> None: + for reply in self._host.handle_frame(frame): + self.client.feed_frame(reply) + + +def test_client_list_devices_round_trip(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json") + acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + host = UsbPassthroughSession( + FakeUsbBackend(devices=[_SAMPLE, _OTHER]), acl=acl, + ) + loop = _SyncLoop(host) + try: + devices = loop.client.list_devices() + assert [d["vendor_id"] for d in devices] == ["1050"] + assert devices[0]["serial"] == "ABC123" + finally: + loop.client.shutdown() + + +def test_client_reassembles_oversize_bulk_in(): + host = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE])) + loop = _SyncLoop(host) + try: + handle = loop.client.open(vendor_id="1050", product_id="0407") + backend_handle = next( + iter(host._backend._open_handles.values()) # type: ignore[attr-defined] + ) + big = bytes(range(256)) * 200 # 51200 bytes → multiple frames + backend_handle.transfer_hook = lambda kind, kwargs: big + result = handle.bulk_transfer( + endpoint=0x81, direction="in", length=len(big), + ) + assert result == big + finally: + loop.client.shutdown() diff --git a/test/unit_test/headless/test_usb_passthrough_panel.py b/test/unit_test/headless/test_usb_passthrough_panel.py new file mode 100644 index 00000000..d93db8b5 --- /dev/null +++ b/test/unit_test/headless/test_usb_passthrough_panel.py @@ -0,0 +1,121 @@ +"""Tests for the AnyDesk-style USB sharing panel + browser open helpers. + +GUI widget tests need PySide6; the panel module lives under gui/ which +transitively pulls the webrtc extra, so the widget tests skip unless the +full GUI stack is importable. The pure helpers are tested regardless. +""" +import os + +import pytest + +pytest.importorskip("PySide6.QtWidgets") + +# Force a headless Qt platform before any QApplication is created. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from je_auto_control.utils.usb.passthrough import ( # noqa: E402 + AclRule, UsbAcl, UsbClientError, UsbLoopback, +) +from je_auto_control.utils.usb.passthrough.backend import ( # noqa: E402 + BackendDevice, FakeUsbBackend, +) + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") + + +# --------------------------------------------------------------------------- +# Browser-tab helpers (need the gui import to succeed) +# --------------------------------------------------------------------------- + +_browser = pytest.importorskip( + "je_auto_control.gui.usb_browser_tab", + reason="gui stack (webrtc extra) not importable", +) + + +@pytest.mark.parametrize("url,expected", [ + ("http://127.0.0.1:9939", True), + ("127.0.0.1:9939", True), + ("localhost:9939", True), + ("http://localhost", True), + ("http://192.168.1.5:9939", False), + ("https://example.com", False), +]) +def test_is_loopback_target(url, expected): + assert _browser._is_loopback_target(url) is expected + + +def test_open_local_descriptor_denied_without_acl_rule(monkeypatch, tmp_path): + """With a default-deny ACL the local open fails closed.""" + import je_auto_control.utils.usb.passthrough.loopback as lb + monkeypatch.setattr( + lb, "default_passthrough_backend", + lambda: FakeUsbBackend(devices=[_SAMPLE]), + ) + # Point UsbAcl at an empty temp file so the user's real ACL is untouched. + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.acl.default_acl_path", + lambda: tmp_path / "acl.json", + ) + with pytest.raises(UsbClientError): + _browser.open_local_descriptor( + vendor_id="1050", product_id="0407", serial="ABC123", + ) + + +# --------------------------------------------------------------------------- +# Panel widget smoke (synchronous paths only — no worker-thread actions) +# --------------------------------------------------------------------------- + +_panel_mod = pytest.importorskip( + "je_auto_control.gui.usb_passthrough_panel", + reason="gui stack (webrtc extra) not importable", +) + + +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() or QApplication([]) + yield app + + +def _make_panel(qapp, tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json") + backend = FakeUsbBackend(devices=[_SAMPLE]) + factory = lambda: UsbLoopback(backend=backend, acl=acl, viewer_id="test") + panel = _panel_mod.UsbPassthroughPanel( + acl=acl, loopback_factory=factory, + ) + return panel, acl + + +def test_panel_builds_and_starts_not_sharing(qapp, tmp_path): + panel, _acl = _make_panel(qapp, tmp_path) + try: + assert panel._loopback is None + finally: + panel.deleteLater() + + +def test_panel_enable_then_disable_sharing(qapp, tmp_path): + panel, _acl = _make_panel(qapp, tmp_path) + try: + panel._enable_sharing() + assert panel._loopback is not None + panel._disable_sharing() + assert panel._loopback is None + finally: + panel.deleteLater() + + +def test_panel_enable_is_idempotent(qapp, tmp_path): + panel, _acl = _make_panel(qapp, tmp_path) + try: + panel._enable_sharing() + first = panel._loopback + panel._enable_sharing() # second call must not replace the loopback + assert panel._loopback is first + finally: + panel._disable_sharing() + panel.deleteLater() diff --git a/test/unit_test/headless/test_usb_platform_backends.py b/test/unit_test/headless/test_usb_platform_backends.py index e384c303..4b55e97e 100644 --- a/test/unit_test/headless/test_usb_platform_backends.py +++ b/test/unit_test/headless/test_usb_platform_backends.py @@ -1,14 +1,18 @@ -"""Tests for the WinUSB / IOKit backend skeletons (round 42).""" +"""Tests for the WinUSB / IOKit backends + the per-OS factory.""" import platform import pytest +from je_auto_control.utils.usb.passthrough import ( + UsbBackend, default_passthrough_backend, +) from je_auto_control.utils.usb.passthrough.winusb_backend import WinusbBackend from je_auto_control.utils.usb.passthrough.iokit_backend import IokitBackend _IS_WINDOWS = platform.system() == "Windows" _IS_DARWIN = platform.system() == "Darwin" +_IS_LINUX = platform.system() == "Linux" # --------------------------------------------------------------------------- @@ -76,14 +80,52 @@ def test_iokit_construct_rejects_non_darwin(): @pytest.mark.skipif(not _IS_DARWIN, reason="Darwin-only path") -def test_iokit_list_raises_not_implemented(): +def test_iokit_list_returns_a_list_without_crashing(): + """Native IOKit enumeration walks the registry and returns a list + (possibly empty) with contract-mandated 4-hex-digit VID/PID.""" backend = IokitBackend() - with pytest.raises(NotImplementedError): - backend.list() + result = backend.list() + assert isinstance(result, list) + for device in result: + assert isinstance(device.vendor_id, str) + assert isinstance(device.product_id, str) + assert len(device.vendor_id) == 4 + assert len(device.product_id) == 4 @pytest.mark.skipif(not _IS_DARWIN, reason="Darwin-only path") -def test_iokit_open_raises_not_implemented(): +def test_iokit_open_absent_device_raises_runtime_error(): + """open() delegates the claim to libusb; an absent VID/PID raises + RuntimeError (or a clear message if libusb isn't installed).""" backend = IokitBackend() - with pytest.raises(NotImplementedError): - backend.open(vendor_id="1050", product_id="0407") + with pytest.raises(RuntimeError): + backend.open(vendor_id="dead", product_id="beef") + + +# --------------------------------------------------------------------------- +# default_passthrough_backend factory +# --------------------------------------------------------------------------- + + +def test_factory_returns_usb_backend_for_current_os(): + """The factory picks a backend for whichever OS the test runs on. + + Construction can fail if the platform's USB libs are absent (e.g. + libusb on a headless Linux CI box) — that surfaces as RuntimeError, + which is itself the documented contract. + """ + try: + backend = default_passthrough_backend() + except RuntimeError: + pytest.skip("platform USB backend dependencies unavailable here") + assert isinstance(backend, UsbBackend) + + +@pytest.mark.skipif(not _IS_WINDOWS, reason="Windows-only path") +def test_factory_picks_winusb_on_windows(): + assert isinstance(default_passthrough_backend(), WinusbBackend) + + +@pytest.mark.skipif(not _IS_DARWIN, reason="Darwin-only path") +def test_factory_picks_iokit_on_macos(): + assert isinstance(default_passthrough_backend(), IokitBackend) From 06c5e0b97fa9322c467f8dbd1d6409732480eb3b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 1 Jun 2026 00:13:28 +0800 Subject: [PATCH 02/10] Add USB descriptor parsing, ACL import/export, rate-limit lockout, key providers Round out the passthrough subsystem with security and usability extensions, all headless-testable and behind the default-off flag. - Parse the 18-byte device descriptor into a readable summary; the GUI shows "1050:0407 HID (USB 2.00)" instead of raw hex after a claim. - UsbAcl.export_rules/import_rules + file helpers; panel gains Import / Export buttons so ACLs can be backed up and shared (re-signed on load). - Session abuse tracker: a viewer that provokes repeated protocol failures is locked out for a cool-down (leaky-bucket strikes, audited once), closing the threat-model rate/lockout gap. - Pluggable ACL HMAC key sources (open question 8 hardening): Windows DPAPI-protected key file (ctypes, no plaintext on disk) and a vault-backed provider whose key needs the passphrase to recover. No new dependencies; i18n across all four languages; import je_auto_control stays Qt-free. --- .../gui/language_wrapper/english.py | 5 + .../gui/language_wrapper/japanese.py | 5 + .../language_wrapper/simplified_chinese.py | 5 + .../language_wrapper/traditional_chinese.py | 5 + je_auto_control/gui/usb_browser_tab.py | 3 +- je_auto_control/gui/usb_passthrough_panel.py | 52 ++++++- .../utils/usb/passthrough/__init__.py | 14 +- je_auto_control/utils/usb/passthrough/acl.py | 59 ++++++++ .../utils/usb/passthrough/descriptor.py | 132 ++++++++++++++++++ .../utils/usb/passthrough/key_provider.py | 122 ++++++++++++++++ .../utils/usb/passthrough/session.py | 76 +++++++++- test/unit_test/headless/test_usb_acl.py | 46 ++++++ .../unit_test/headless/test_usb_descriptor.py | 72 ++++++++++ .../headless/test_usb_key_provider.py | 93 ++++++++++++ .../headless/test_usb_passthrough.py | 39 ++++++ 15 files changed, 721 insertions(+), 7 deletions(-) create mode 100644 je_auto_control/utils/usb/passthrough/descriptor.py create mode 100644 je_auto_control/utils/usb/passthrough/key_provider.py create mode 100644 test/unit_test/headless/test_usb_descriptor.py create mode 100644 test/unit_test/headless/test_usb_key_provider.py diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 90bfc2b7..1fb5b88d 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -150,6 +150,11 @@ "usb_share_remote_url": "REST URL:", "usb_share_remote_token": "Token:", "usb_share_remote_fetch": "Fetch remote", + "usb_share_export_acl": "Export ACL", + "usb_share_import_acl": "Import ACL", + "usb_share_acl_exported": "ACL exported.", + "usb_share_acl_imported": "Imported {count} rule(s).", + "usb_share_acl_import_failed": "ACL I/O failed: {error}", # Inspector tab "inspector_metrics_group": "Rolling metrics", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 549473a5..37413155 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -148,6 +148,11 @@ "usb_share_remote_url": "REST URL:", "usb_share_remote_token": "トークン:", "usb_share_remote_fetch": "遠隔を取得", + "usb_share_export_acl": "ACL をエクスポート", + "usb_share_import_acl": "ACL をインポート", + "usb_share_acl_exported": "ACL をエクスポートしました。", + "usb_share_acl_imported": "{count} 件のルールをインポートしました。", + "usb_share_acl_import_failed": "ACL 入出力に失敗:{error}", # 監視タブ "inspector_metrics_group": "集約メトリクス", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index c894ef9f..16fa444a 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -139,6 +139,11 @@ "usb_share_remote_url": "REST URL:", "usb_share_remote_token": "令牌:", "usb_share_remote_fetch": "获取远程", + "usb_share_export_acl": "导出 ACL", + "usb_share_import_acl": "导入 ACL", + "usb_share_acl_exported": "ACL 已导出。", + "usb_share_acl_imported": "已导入 {count} 条规则。", + "usb_share_acl_import_failed": "ACL 读写失败:{error}", # 包监测分页 "inspector_metrics_group": "汇总指标", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index c450aa15..b38698dc 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -140,6 +140,11 @@ "usb_share_remote_url": "REST URL:", "usb_share_remote_token": "權杖:", "usb_share_remote_fetch": "取得遠端", + "usb_share_export_acl": "匯出 ACL", + "usb_share_import_acl": "匯入 ACL", + "usb_share_acl_exported": "ACL 已匯出。", + "usb_share_acl_imported": "已匯入 {count} 條規則。", + "usb_share_acl_import_failed": "ACL 讀寫失敗:{error}", # 封包監測分頁 "inspector_metrics_group": "彙整指標", diff --git a/je_auto_control/gui/usb_browser_tab.py b/je_auto_control/gui/usb_browser_tab.py index 5830c91f..166dde06 100644 --- a/je_auto_control/gui/usb_browser_tab.py +++ b/je_auto_control/gui/usb_browser_tab.py @@ -295,9 +295,10 @@ def _on_open_done(self) -> None: self._open_thread = None def _on_local_opened(self, vid: str, pid: str, descriptor: bytes) -> None: + from je_auto_control.utils.usb.passthrough import describe_descriptor self._status_label.setText( _t("usb_browser_open_loopback").format( - vid=vid, pid=pid, hex=descriptor.hex() or "(empty)", + vid=vid, pid=pid, hex=describe_descriptor(descriptor), ), ) diff --git a/je_auto_control/gui/usb_passthrough_panel.py b/je_auto_control/gui/usb_passthrough_panel.py index 56cda735..701bb3f2 100644 --- a/je_auto_control/gui/usb_passthrough_panel.py +++ b/je_auto_control/gui/usb_passthrough_panel.py @@ -21,8 +21,9 @@ from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( - QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, - QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, + QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, + QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -32,7 +33,8 @@ from je_auto_control.gui.remote_desktop._helpers import _StatusBadge from je_auto_control.gui.usb_browser_tab import fetch_remote_devices from je_auto_control.utils.usb.passthrough import ( - AclRule, UsbAcl, UsbLoopback, enable_usb_passthrough, + AclRule, UsbAcl, UsbLoopback, describe_descriptor, enable_usb_passthrough, + export_acl_to_file, import_acl_from_file, ) from je_auto_control.utils.usb.usb_devices import list_usb_devices @@ -132,6 +134,14 @@ def _build_host_section(self) -> QWidget: acl_row.addWidget(allow_btn) acl_row.addWidget(block_btn) layout.addLayout(acl_row) + io_row = QHBoxLayout() + export_btn = self._tr(QPushButton(), "usb_share_export_acl") + export_btn.clicked.connect(self._export_acl) + import_btn = self._tr(QPushButton(), "usb_share_import_acl") + import_btn.clicked.connect(self._import_acl) + io_row.addWidget(export_btn) + io_row.addWidget(import_btn) + layout.addLayout(io_row) return group def _build_viewer_section(self) -> QWidget: @@ -258,6 +268,40 @@ def _set_policy(self, allow: bool) -> None: self._viewer_status.setText(_t(key).format(vid=vid, pid=pid)) self._refresh_local_devices() + def _export_acl(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, _t("usb_share_export_acl"), "usb_acl_export.json", + "JSON (*.json)", + ) + if not path: + return + try: + export_acl_to_file(self._acl, path) + except OSError as error: + self._viewer_status.setText( + _t("usb_share_acl_import_failed").format(error=str(error)), + ) + return + self._viewer_status.setText(_t("usb_share_acl_exported")) + + def _import_acl(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, _t("usb_share_import_acl"), "", "JSON (*.json)", + ) + if not path: + return + try: + count = import_acl_from_file(self._acl, path) + except (OSError, ValueError) as error: + self._viewer_status.setText( + _t("usb_share_acl_import_failed").format(error=str(error)), + ) + return + self._viewer_status.setText( + _t("usb_share_acl_imported").format(count=count), + ) + self._refresh_local_devices() + # --- use (loopback) ---------------------------------------------------- def _list_shared(self) -> None: @@ -305,7 +349,7 @@ def _open_selected(self) -> None: def _opened(self, vid: str, pid: str, descriptor: bytes) -> None: self._viewer_status.setText( _t("usb_share_opened").format( - vid=vid, pid=pid, hex=descriptor.hex() or "(empty)", + vid=vid, pid=pid, hex=describe_descriptor(descriptor), ), ) diff --git a/je_auto_control/utils/usb/passthrough/__init__.py b/je_auto_control/utils/usb/passthrough/__init__.py index eebeb377..4c0afd31 100644 --- a/je_auto_control/utils/usb/passthrough/__init__.py +++ b/je_auto_control/utils/usb/passthrough/__init__.py @@ -11,15 +11,23 @@ ``docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst``. """ from je_auto_control.utils.usb.passthrough.acl import ( - AclRule, UsbAcl, default_acl_path, + AclRule, UsbAcl, default_acl_path, export_acl_to_file, + import_acl_from_file, ) from je_auto_control.utils.usb.passthrough.backend import ( FakeUsbBackend, LibusbBackend, UsbBackend, UsbHandle, default_passthrough_backend, ) +from je_auto_control.utils.usb.passthrough.descriptor import ( + DescriptorError, DeviceDescriptor, describe_descriptor, + parse_device_descriptor, +) from je_auto_control.utils.usb.passthrough.flags import ( enable_usb_passthrough, is_usb_passthrough_enabled, ) +from je_auto_control.utils.usb.passthrough.key_provider import ( + VaultKeyProvider, dpapi_available, load_or_create_dpapi_key, +) from je_auto_control.utils.usb.passthrough.loopback import ( LoopbackTransport, UsbLoopback, ) @@ -39,6 +47,8 @@ "FakeUsbBackend", "LibusbBackend", "UsbBackend", "UsbHandle", "default_passthrough_backend", "LoopbackTransport", "UsbLoopback", + "DescriptorError", "DeviceDescriptor", "describe_descriptor", + "parse_device_descriptor", "enable_usb_passthrough", "is_usb_passthrough_enabled", "FLAG_EOF", "Frame", "Opcode", "ProtocolError", "decode_frame", "encode_frame", "fragment_payload", @@ -47,4 +57,6 @@ "ClientHandle", "UsbClientClosed", "UsbClientError", "UsbClientTimeout", "UsbPassthroughClient", "AclRule", "UsbAcl", "default_acl_path", + "export_acl_to_file", "import_acl_from_file", + "VaultKeyProvider", "dpapi_available", "load_or_create_dpapi_key", ] diff --git a/je_auto_control/utils/usb/passthrough/acl.py b/je_auto_control/utils/usb/passthrough/acl.py index f2ec7b52..38c85fec 100644 --- a/je_auto_control/utils/usb/passthrough/acl.py +++ b/je_auto_control/utils/usb/passthrough/acl.py @@ -184,6 +184,47 @@ def remove_rule(self, *, vendor_id: str, product_id: str, self._save() return removed + def export_rules(self) -> dict: + """Return a portable snapshot (no signature/key) for sharing/backup.""" + with self._lock: + return { + "version": _ACL_VERSION, + "default": self._state.default, + "rules": [r.to_dict() for r in self._state.rules], + } + + def import_rules(self, payload: dict, *, replace: bool = False, + persist: bool = True) -> int: + """Merge (or replace) rules from an exported snapshot. + + Returns the number of rules imported. Validates the schema; an + unsupported version raises ``ValueError``. With ``replace=True`` + the existing rules and default policy are overwritten, otherwise + imported rules are appended. Re-signs the file on persist. + """ + if not isinstance(payload, dict): + raise ValueError("import payload must be a JSON object") + if int(payload.get("version", 0)) != _ACL_VERSION: + raise ValueError( + f"unsupported ACL version {payload.get('version')!r}", + ) + raw_rules = payload.get("rules", []) + if not isinstance(raw_rules, list): + raise ValueError("'rules' must be a list") + imported = [AclRule.from_dict(r) for r in raw_rules + if isinstance(r, dict)] + with self._lock: + if replace: + default = str(payload.get("default", self._state.default)) + if default not in _VALID_DEFAULTS: + default = "deny" + self._state = _AclState(default=default, rules=list(imported)) + else: + self._state.rules.extend(imported) + if persist: + self._save() + return len(imported) + def set_default_policy(self, policy: str, *, persist: bool = True) -> None: if policy not in _VALID_DEFAULTS: raise ValueError( @@ -361,3 +402,21 @@ def _save(self) -> None: __all__ = [ "AclRule", "UsbAcl", "default_acl_path", ] + + +def export_acl_to_file(acl: "UsbAcl", path: Path) -> None: + """Write ``acl``'s rules to ``path`` as plain JSON (no signature).""" + Path(path).write_text( + json.dumps(acl.export_rules(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def import_acl_from_file(acl: "UsbAcl", path: Path, *, + replace: bool = False) -> int: + """Load rules from ``path`` into ``acl``; return the number imported.""" + payload = json.loads(Path(path).read_text(encoding="utf-8")) + return acl.import_rules(payload, replace=replace) + + +__all__ += ["export_acl_to_file", "import_acl_from_file"] diff --git a/je_auto_control/utils/usb/passthrough/descriptor.py b/je_auto_control/utils/usb/passthrough/descriptor.py new file mode 100644 index 00000000..38e47686 --- /dev/null +++ b/je_auto_control/utils/usb/passthrough/descriptor.py @@ -0,0 +1,132 @@ +"""Parse a USB standard *device descriptor* into a readable structure. + +The 18-byte device descriptor is what a host reads first from any USB +device (``GET_DESCRIPTOR`` / type ``0x01``). The passthrough GUI reads +it right after a claim as a liveness probe; this module turns the raw +bytes into a :class:`DeviceDescriptor` plus a one-line human summary so +the panel can show "Vendor 1050 / HID device" instead of raw hex. + +Pure data — no I/O, no Qt. +""" +from __future__ import annotations + +import struct +from dataclasses import dataclass + +_DEVICE_DESCRIPTOR_TYPE = 0x01 +_DEVICE_DESCRIPTOR_LEN = 18 +_DEVICE_DESCRIPTOR_FORMAT = " str: + return _USB_CLASS_NAMES.get(self.device_class, "unknown") + + def summary(self) -> str: + """One-line human summary for a status label.""" + return ( + f"{self.vendor_id}:{self.product_id} " + f"{self.class_name} (USB {self.usb_version}, " + f"{self.num_configurations} config)" + ) + + +def _bcd_to_str(value: int) -> str: + """Render a BCD-coded version word (e.g. 0x0200) as "2.00".""" + major = (value >> 8) & 0xFF + minor = (value >> 4) & 0x0F + patch = value & 0x0F + return f"{major}.{minor}{patch}" + + +def parse_device_descriptor(data: bytes) -> DeviceDescriptor: + """Decode an 18-byte device descriptor; raise on malformed input.""" + if not isinstance(data, (bytes, bytearray, memoryview)): + raise DescriptorError("descriptor must be bytes-like") + raw = bytes(data) + if len(raw) < _DEVICE_DESCRIPTOR_LEN: + raise DescriptorError( + f"descriptor too short ({len(raw)}B); need " + f"{_DEVICE_DESCRIPTOR_LEN}", + ) + fields = struct.unpack(_DEVICE_DESCRIPTOR_FORMAT, raw[:_DEVICE_DESCRIPTOR_LEN]) + (b_length, b_type, bcd_usb, dev_class, dev_subclass, dev_protocol, + max_packet, vendor, product, bcd_device, i_manuf, i_product, + i_serial, num_configs) = fields + if b_type != _DEVICE_DESCRIPTOR_TYPE: + raise DescriptorError( + f"not a device descriptor (bDescriptorType=0x{b_type:02x})", + ) + if b_length != _DEVICE_DESCRIPTOR_LEN: + raise DescriptorError( + f"unexpected bLength {b_length} (want {_DEVICE_DESCRIPTOR_LEN})", + ) + return DeviceDescriptor( + usb_version=_bcd_to_str(bcd_usb), + device_class=dev_class, + device_subclass=dev_subclass, + device_protocol=dev_protocol, + max_packet_size0=max_packet, + vendor_id=f"{vendor:04x}", + product_id=f"{product:04x}", + device_version=_bcd_to_str(bcd_device), + manufacturer_index=i_manuf, + product_index=i_product, + serial_index=i_serial, + num_configurations=num_configs, + ) + + +def describe_descriptor(data: bytes) -> str: + """Best-effort one-line summary; falls back to hex on parse failure.""" + try: + return parse_device_descriptor(data).summary() + except DescriptorError: + return data.hex() or "(empty)" + + +__all__ = [ + "DescriptorError", "DeviceDescriptor", + "parse_device_descriptor", "describe_descriptor", +] diff --git a/je_auto_control/utils/usb/passthrough/key_provider.py b/je_auto_control/utils/usb/passthrough/key_provider.py new file mode 100644 index 00000000..d4b03484 --- /dev/null +++ b/je_auto_control/utils/usb/passthrough/key_provider.py @@ -0,0 +1,122 @@ +"""Pluggable HMAC-key sources for the USB ACL (open question 8 hardening). + +:class:`UsbAcl` accepts ``hmac_key=``; this module supplies that +key from a stronger store than the default plaintext ``.key`` file: + +* :func:`load_or_create_dpapi_key` — Windows DPAPI. The key is encrypted + at rest with ``CryptProtectData`` and bound to the current user + + machine, so the on-disk blob is useless if copied elsewhere. No + plaintext key ever touches disk. (Defense in depth; a same-user + process can still call ``CryptUnprotectData``.) +* :class:`VaultKeyProvider` — stores the key inside the existing + passphrase-encrypted secret vault. A same-user process *without* the + passphrase cannot recover the key, which closes the residual risk the + plaintext key file leaves open. + +No new third-party dependency: DPAPI is reached through ``ctypes`` and +the vault reuses the project's :class:`SecretManager`. +""" +from __future__ import annotations + +import base64 +import platform +import secrets +from pathlib import Path +from typing import Optional + +_KEY_BYTES = 32 +_CRYPTPROTECT_UI_FORBIDDEN = 0x01 + + +def dpapi_available() -> bool: + """True on Windows where ``crypt32`` can be loaded.""" + if platform.system() != "Windows": + return False + try: + import ctypes + ctypes.WinDLL("crypt32") + return True + except OSError: + return False + + +def _dpapi_call(func_name: str, data: bytes) -> bytes: + import ctypes + import ctypes.wintypes as wintypes + + class _Blob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + func = getattr(crypt32, func_name) + func.restype = wintypes.BOOL + + src_buf = ctypes.create_string_buffer(bytes(data), len(data)) + src = _Blob(len(data), ctypes.cast(src_buf, ctypes.POINTER(ctypes.c_char))) + out = _Blob() + ok = func( + ctypes.byref(src), None, None, None, None, + _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(out), + ) + if not ok: + raise RuntimeError( + f"{func_name} failed: {ctypes.get_last_error()}", + ) + try: + return ctypes.string_at(out.pbData, out.cbData) + finally: + kernel32.LocalFree(out.pbData) + + +def dpapi_protect(data: bytes) -> bytes: + """Encrypt ``data`` with the current user's DPAPI master key.""" + return _dpapi_call("CryptProtectData", data) + + +def dpapi_unprotect(blob: bytes) -> bytes: + """Reverse :func:`dpapi_protect`.""" + return _dpapi_call("CryptUnprotectData", blob) + + +def load_or_create_dpapi_key(path: Path) -> bytes: + """Return a 32-byte key, DPAPI-protected at ``path`` (Windows only).""" + if not dpapi_available(): + raise RuntimeError("DPAPI is only available on Windows") + target = Path(path) + if target.exists(): + return dpapi_unprotect(target.read_bytes()) + key = secrets.token_bytes(_KEY_BYTES) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(dpapi_protect(key)) + return key + + +class VaultKeyProvider: + """HMAC key stored inside the passphrase-encrypted secret vault. + + The vault must be unlocked (``manager.unlock(passphrase)``) before + :meth:`get_or_create` is called. The key is held as base64 text under + ``name`` in the vault, so it is only recoverable with the passphrase. + """ + + def __init__(self, manager: object, *, name: str = "usb_acl_hmac") -> None: + self._manager = manager + self._name = name + + def get_or_create(self) -> bytes: + existing: Optional[str] = self._manager.get(self._name) # type: ignore[attr-defined] + if existing: + return base64.b64decode(existing) + key = secrets.token_bytes(_KEY_BYTES) + self._manager.set( # type: ignore[attr-defined] + self._name, base64.b64encode(key).decode("ascii"), + ) + return key + + +__all__ = [ + "VaultKeyProvider", "dpapi_available", "dpapi_protect", + "dpapi_unprotect", "load_or_create_dpapi_key", +] diff --git a/je_auto_control/utils/usb/passthrough/session.py b/je_auto_control/utils/usb/passthrough/session.py index 4359f628..45d8672e 100644 --- a/je_auto_control/utils/usb/passthrough/session.py +++ b/je_auto_control/utils/usb/passthrough/session.py @@ -60,6 +60,7 @@ import base64 import json import threading +import time from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional @@ -74,12 +75,50 @@ _DEFAULT_MAX_CLAIMS = 4 _DEFAULT_INITIAL_CREDITS = 16 _TOPUP_PER_REPLY = 1 +# Abuse tracking: a viewer that provokes this many protocol failures +# faster than they decay gets locked out for a cool-down. Tuned so +# normal operation (the odd bad frame) never trips it. +_ABUSE_MAX_STRIKES = 20.0 +_ABUSE_DECAY_PER_S = 5.0 +_ABUSE_LOCKOUT_S = 5.0 class SessionError(Exception): """Raised on session-level invariant violations (not protocol parse errors).""" +class _AbuseTracker: + """Leaky-bucket strike counter that locks out a misbehaving peer.""" + + def __init__(self, *, max_strikes: float = _ABUSE_MAX_STRIKES, + decay_per_s: float = _ABUSE_DECAY_PER_S, + lockout_s: float = _ABUSE_LOCKOUT_S) -> None: + self._max = float(max_strikes) + self._decay = float(decay_per_s) + self._lockout_s = float(lockout_s) + self._strikes = 0.0 + self._last = time.monotonic() + self._locked_until = 0.0 + self._lock = threading.Lock() + + def record_strike(self) -> bool: + """Count one protocol failure; return True if it triggers lockout.""" + with self._lock: + now = time.monotonic() + self._strikes = max(0.0, self._strikes - (now - self._last) * self._decay) + self._last = now + self._strikes += 1.0 + if self._strikes >= self._max: + self._locked_until = now + self._lockout_s + self._strikes = 0.0 + return True + return False + + def is_locked(self) -> bool: + with self._lock: + return time.monotonic() < self._locked_until + + @dataclass class _ClaimState: """Per-claim handle + credit accounting.""" @@ -111,6 +150,7 @@ def __init__(self, backend: UsbBackend, self._lock = threading.Lock() self._claims: Dict[int, _ClaimState] = {} self._next_claim_id = 1 + self._abuse = _AbuseTracker() @property def active_claim_count(self) -> int: @@ -141,8 +181,22 @@ def close_all(self) -> None: "passthrough close_all: handle.close() raised %r", error, ) + def is_locked_out(self) -> bool: + """True while the peer is in a rate-limit cool-down.""" + return self._abuse.is_locked() + def handle_frame(self, frame: Frame) -> List[Frame]: - """Process one incoming frame; return zero or more reply frames.""" + """Process one frame, with abuse tracking, and return replies.""" + if self._abuse.is_locked(): + return [_error_frame(frame.claim_id, "rate limited; locked out")] + replies = self._dispatch(frame) + if _is_misbehaviour(replies) and self._abuse.record_strike(): + self._audit("usb_rate_limited", "?", "?", None, + detail="viewer locked out for repeated failures") + return replies + + def _dispatch(self, frame: Frame) -> List[Frame]: + """Route one incoming frame; return zero or more reply frames.""" if frame.op == Opcode.OPEN: return [self._handle_open(frame)] if frame.op == Opcode.CLOSE: @@ -443,6 +497,26 @@ def _reply_opcode(request_op: Opcode) -> Opcode: return _REPLY_OPCODES.get(request_op, Opcode.ERROR) +def _is_misbehaviour(replies: List[Frame]) -> bool: + """True if the replies indicate a viewer-caused protocol failure. + + Counts ERROR frames and failed OPENED replies as strikes; a device + transfer that fails at the backend (CTRL/BULK/INT with ok=false) is + the device's fault, not the viewer's, so it is not counted. + """ + for reply in replies: + if reply.op == Opcode.ERROR: + return True + if reply.op == Opcode.OPENED: + try: + body = json.loads(reply.payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return True + if not body.get("ok"): + return True + return False + + def _decode_b64(value: Any) -> bytes: if value is None or value == "": return b"" diff --git a/test/unit_test/headless/test_usb_acl.py b/test/unit_test/headless/test_usb_acl.py index 39d0d589..fca96030 100644 --- a/test/unit_test/headless/test_usb_acl.py +++ b/test/unit_test/headless/test_usb_acl.py @@ -309,3 +309,49 @@ def test_legacy_unsigned_file_still_loads_by_default(tmp_path): assert acl.integrity_ok is True assert acl.default_policy == "allow" assert len(acl.list_rules()) == 1 + + +# --------------------------------------------------------------------------- +# Import / export +# --------------------------------------------------------------------------- + + +def test_export_then_import_round_trip(tmp_path): + from je_auto_control.utils.usb.passthrough import ( + export_acl_to_file, import_acl_from_file, + ) + src = UsbAcl(path=tmp_path / "a.json", default_policy="allow") + src.add_rule(AclRule(vendor_id="1050", product_id="0407", + label="YubiKey", allow=True)) + out = tmp_path / "export.json" + export_acl_to_file(src, out) + # The export is plain JSON with no signature sidecar. + assert out.exists() + assert not out.with_name(out.name + ".sig").exists() + + dst = UsbAcl(path=tmp_path / "b.json") # default deny + count = import_acl_from_file(dst, out, replace=True) + assert count == 1 + assert dst.default_policy == "allow" + assert dst.list_rules()[0].label == "YubiKey" + # Imported file is re-signed so it survives a reload. + reloaded = UsbAcl(path=tmp_path / "b.json") + assert reloaded.integrity_ok is True + assert len(reloaded.list_rules()) == 1 + + +def test_import_merge_appends_rules(tmp_path): + dst = UsbAcl(path=tmp_path / "acl.json") + dst.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + added = dst.import_rules({ + "version": 1, "default": "deny", + "rules": [{"vendor_id": "2222", "product_id": "3333", "allow": True}], + }) + assert added == 1 + assert len(dst.list_rules()) == 2 + + +def test_import_rejects_bad_version(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json") + with pytest.raises(ValueError): + acl.import_rules({"version": 99, "rules": []}) diff --git a/test/unit_test/headless/test_usb_descriptor.py b/test/unit_test/headless/test_usb_descriptor.py new file mode 100644 index 00000000..49e69af5 --- /dev/null +++ b/test/unit_test/headless/test_usb_descriptor.py @@ -0,0 +1,72 @@ +"""Tests for USB device-descriptor parsing.""" +import struct + +import pytest + +from je_auto_control.utils.usb.passthrough import ( + DescriptorError, describe_descriptor, parse_device_descriptor, +) + + +def _descriptor(*, vendor=0x1050, product=0x0407, dev_class=0x03, + bcd_usb=0x0200, bcd_device=0x0100, num_configs=1) -> bytes: + return struct.pack( + " Date: Mon, 1 Jun 2026 00:33:27 +0800 Subject: [PATCH 03/10] Wire USB passthrough over WebRTC channel, add resume tokens and hotplug refresh Make USB passthrough work cross-machine and survive reconnects. - WebRTC usb DataChannel: UsbChannelHost / UsbChannelClient adapters bridge the protocol over an aiortc RTCDataChannel (mirroring the file channel). webrtc_host creates the "usb" channel gated on viewer auth + the feature flag; webrtc_viewer exposes viewer.usb_client(). Adapters are transport-decoupled and unit-tested with a fake channel. - Claim resume: OPENED carries a resume_token; a reconnecting viewer sends RESUME to re-bind a claim the host session still holds, keeping device state and claim_id intact (new 0x0A RESUME opcode). - USB Sharing panel auto-refreshes its local device table from the hotplug watcher when enabled. Docs updated; i18n across four languages; import je_auto_control stays Qt-free and aiortc-free. --- .../usb_passthrough_design.rst | 12 ++ .../usb_passthrough_operator_guide.rst | 12 +- .../usb_passthrough_design.rst | 11 ++ .../usb_passthrough_operator_guide.rst | 9 +- .../gui/language_wrapper/english.py | 1 + .../gui/language_wrapper/japanese.py | 1 + .../language_wrapper/simplified_chinese.py | 1 + .../language_wrapper/traditional_chinese.py | 1 + je_auto_control/gui/usb_passthrough_panel.py | 39 +++- .../utils/remote_desktop/webrtc_host.py | 34 ++++ .../utils/remote_desktop/webrtc_viewer.py | 19 ++ .../utils/usb/passthrough/__init__.py | 4 + .../utils/usb/passthrough/protocol.py | 1 + .../utils/usb/passthrough/session.py | 43 ++++- .../utils/usb/passthrough/viewer_client.py | 47 ++++- .../utils/usb/passthrough/webrtc_channel.py | 167 ++++++++++++++++++ .../headless/test_usb_passthrough.py | 50 ++++++ .../test_usb_passthrough_list_fragment.py | 41 +++++ .../headless/test_usb_passthrough_panel.py | 27 +++ .../headless/test_usb_webrtc_channel.py | 162 +++++++++++++++++ 20 files changed, 666 insertions(+), 16 deletions(-) create mode 100644 je_auto_control/utils/usb/passthrough/webrtc_channel.py create mode 100644 test/unit_test/headless/test_usb_webrtc_channel.py diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst index f50309d5..74ba8da8 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_design.rst @@ -77,6 +77,17 @@ Channel A dedicated WebRTC ``DataChannel`` named ``usb`` per session, with ``ordered=True`` and ``maxRetransmits=None`` (full reliability). + +**Wired up:** the host (``webrtc_host``) creates the ``usb`` channel and +feeds it to a session through ``UsbChannelHost`` (gated on viewer +authentication + the feature flag); the viewer (``webrtc_viewer``) +wraps the incoming channel in ``UsbChannelClient``, exposed via +``viewer.usb_client()`` for ``list_devices`` / ``open`` / ``resume``. +The adapters are transport-decoupled and unit-tested with a fake +channel (see ``test_usb_webrtc_channel``). **Reconnect:** OPENED carries +a ``resume_token``; if the host session outlives the viewer's transport +drop, the viewer sends ``RESUME{resume_token}`` to re-bind the existing +claim — keeping device state and ``claim_id`` — instead of re-OPENing. Bulk and interrupt USB transfers tolerate the latency far better than they tolerate loss; the existing video/audio channels already demonstrate that the underlying SCTP transport handles ordered @@ -130,6 +141,7 @@ Op (hex) Direction Purpose ``0x07 CREDIT`` viewer ↔ host Backpressure window update ``0x08 CLOSE`` viewer → host Release the claim ``0x09 CLOSED`` host → viewer Acknowledgement (or unsolicited on host-side disconnect) +``0x0A RESUME`` viewer → host Re-bind an existing claim after reconnect via resume_token ``0xFF ERROR`` either Protocol error / unsupported op ================ ========================================= ============== diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index c25a6982..a6384332 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -279,11 +279,13 @@ What is *not* shipped yet one (a descriptor read proves the full stack). The *USB Browser* tab's *Open* button now also works against a **localhost** target via the same loopback path. -- Cross-machine open still needs the WebRTC ``usb`` DataChannel, which - the viewer GUI does not yet auto-wire — against a remote host the - *Open* button shows a "not yet wired" message. You can drive the - protocol from Python today (including - ``UsbPassthroughClient.list_devices()`` over the channel). +- Cross-machine transport is now wired: the WebRTC host creates a + ``usb`` DataChannel and the viewer exposes ``viewer.usb_client()`` + (a ``UsbChannelClient`` with ``list_devices`` / ``open`` / ``resume``). + Drive it from Python today over a live WebRTC session. The simple + *USB Browser* / *USB Sharing* panels still use the local loopback + + REST paths; auto-wiring those panels to a live WebRTC viewer session + is the remaining GUI integration step. - Windows WinUSB and macOS IOKit transfer paths are written but not yet validated against real hardware. Do not use in production until the Phase 2e hardware test matrix passes. diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst index 2b41e1a1..f969c86e 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_design.rst @@ -70,6 +70,16 @@ Channel 每個 session 一條專用的 WebRTC ``DataChannel``\ ,名稱 ``usb``\ , ``ordered=True`` 且 ``maxRetransmits=None``\ (完全可靠傳輸)。 + +**已串接:** host(``webrtc_host``)會建立 ``usb`` channel 並以 +``UsbChannelHost`` 餵給 session(綁定 viewer 認證 + feature flag); +viewer(``webrtc_viewer``)收到該 channel 後以 ``UsbChannelClient`` +包裝,透過 ``viewer.usb_client()`` 供上層呼叫 ``list_devices`` / +``open`` / ``resume``。adapter 與 transport 解耦,可用 fake channel +單測(見 ``test_usb_webrtc_channel``)。**斷線續租:** OPENED 會帶 +``resume_token``;若 host session 撐過 viewer 的 transport 中斷, +viewer 重連後送 ``RESUME{resume_token}`` 即可重綁原 claim、保留裝置 +狀態,不需重新 OPEN。 USB 的 bulk 與 interrupt 傳輸對延遲的容忍度遠高於對遺失的容忍度; 既有的 video/audio channel 也已示範底層 SCTP 傳輸足以承擔有序可靠 串流。 @@ -118,6 +128,7 @@ Op (hex) 方向 用途 ``0x07 CREDIT`` viewer ↔ host Backpressure 視窗更新 ``0x08 CLOSE`` viewer → host 釋放 claim ``0x09 CLOSED`` host → viewer 確認(host 端斷線時也可主動發出) +``0x0A RESUME`` viewer → host 重連後以 resume_token 重新綁定既有 claim ``0xFF ERROR`` 雙向 協定錯誤/不支援 op ================ ===================================== ====================== diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index 7803ae71..347ee225 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -258,9 +258,12 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim 做 ACL 允許/封鎖;右側經 in-process channel 列出分享裝置並 *開啟* 其中一個(讀描述元即證明整條堆疊運作)。*USB Browser* 分頁的 *Open* 按鈕現在對 **localhost** 目標也會走同一條 loopback 路徑。 -- 跨機器開啟仍需 WebRTC ``usb`` DataChannel,viewer GUI 尚未自動串接 — - 對遠端主機按 *Open* 會顯示「尚未串接」訊息。今天可以從 Python 驅動 - 協定(含經 channel 的 ``UsbPassthroughClient.list_devices()``\ )。 +- 跨機器 transport 已串接:WebRTC host 會建立 ``usb`` DataChannel, + viewer 以 ``viewer.usb_client()`` 暴露 ``UsbChannelClient``\ (含 + ``list_devices`` / ``open`` / ``resume``\ )。今天即可在 WebRTC session + 上從 Python 驅動。簡易的 *USB Browser* / *USB 分享* 面板目前仍走本機 + loopback + REST;把面板自動接到 live WebRTC viewer session 是剩餘的 + GUI 整合步驟。 - Windows WinUSB 與 macOS IOKit 的 transfer 路徑已寫但尚未對實體硬體 驗證。在 Phase 2e 硬體測試矩陣通過前請勿用於 production。 - Phase 2e 外部安全審查尚未簽核;feature flag 必須維持顯式 opt-in。 diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 1fb5b88d..7aa2acb8 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -155,6 +155,7 @@ "usb_share_acl_exported": "ACL exported.", "usb_share_acl_imported": "Imported {count} rule(s).", "usb_share_acl_import_failed": "ACL I/O failed: {error}", + "usb_share_auto_refresh": "Auto-refresh (hotplug)", # Inspector tab "inspector_metrics_group": "Rolling metrics", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 37413155..5e8d9ccd 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -153,6 +153,7 @@ "usb_share_acl_exported": "ACL をエクスポートしました。", "usb_share_acl_imported": "{count} 件のルールをインポートしました。", "usb_share_acl_import_failed": "ACL 入出力に失敗:{error}", + "usb_share_auto_refresh": "自動更新(ホットプラグ)", # 監視タブ "inspector_metrics_group": "集約メトリクス", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index 16fa444a..1c95d587 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -144,6 +144,7 @@ "usb_share_acl_exported": "ACL 已导出。", "usb_share_acl_imported": "已导入 {count} 条规则。", "usb_share_acl_import_failed": "ACL 读写失败:{error}", + "usb_share_auto_refresh": "自动刷新(热插拔)", # 包监测分页 "inspector_metrics_group": "汇总指标", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index b38698dc..0a0495e6 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -145,6 +145,7 @@ "usb_share_acl_exported": "ACL 已匯出。", "usb_share_acl_imported": "已匯入 {count} 條規則。", "usb_share_acl_import_failed": "ACL 讀寫失敗:{error}", + "usb_share_auto_refresh": "自動刷新(熱插拔)", # 封包監測分頁 "inspector_metrics_group": "彙整指標", diff --git a/je_auto_control/gui/usb_passthrough_panel.py b/je_auto_control/gui/usb_passthrough_panel.py index 701bb3f2..20c10adc 100644 --- a/je_auto_control/gui/usb_passthrough_panel.py +++ b/je_auto_control/gui/usb_passthrough_panel.py @@ -19,11 +19,11 @@ from typing import Any, Callable, List, Optional -from PySide6.QtCore import QObject, QThread, Signal +from PySide6.QtCore import QObject, QThread, QTimer, Signal from PySide6.QtWidgets import ( - QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, - QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, - QWidget, + QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, + QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -37,6 +37,7 @@ export_acl_to_file, import_acl_from_file, ) from je_auto_control.utils.usb.usb_devices import list_usb_devices +from je_auto_control.utils.usb.usb_watcher import default_usb_watcher def _t(key: str) -> str: @@ -84,6 +85,12 @@ def __init__(self, parent: Optional[QWidget] = None, *, self._thread: Optional[QThread] = None self._host_badge = _StatusBadge() self._viewer_status = QLabel("") + self._auto_check = QCheckBox() + self._auto_check.toggled.connect(self._on_auto_toggled) + self._hotplug_timer = QTimer(self) + self._hotplug_timer.setInterval(2000) + self._hotplug_timer.timeout.connect(self._poll_hotplug) + self._last_seen_seq = 0 self._local_table = _make_table(5) self._shared_table = _make_table(4) self._remote_url = QLineEdit("http://127.0.0.1:9939") @@ -133,6 +140,8 @@ def _build_host_section(self) -> QWidget: acl_row.addWidget(refresh_btn) acl_row.addWidget(allow_btn) acl_row.addWidget(block_btn) + self._tr(self._auto_check, "usb_share_auto_refresh") + acl_row.addWidget(self._auto_check) layout.addLayout(acl_row) io_row = QHBoxLayout() export_btn = self._tr(QPushButton(), "usb_share_export_acl") @@ -251,6 +260,25 @@ def _refresh_local_devices(self) -> None: for col, text in enumerate(cells): self._local_table.setItem(row, col, QTableWidgetItem(text)) + def _on_auto_toggled(self, on: bool) -> None: + watcher = default_usb_watcher() + if on: + watcher.start() + self._hotplug_timer.start() + else: + self._hotplug_timer.stop() + watcher.stop() + + def _poll_hotplug(self) -> None: + watcher = default_usb_watcher() + if not watcher.is_running: + return + events = watcher.recent_events(since=self._last_seen_seq, limit=20) + if not events: + return + self._last_seen_seq = events[-1]["seq"] + self._refresh_local_devices() + def _set_policy(self, allow: bool) -> None: row = _selected_row(self._local_table) if row is None: @@ -408,6 +436,9 @@ def _cell(table: QTableWidget, row: int, col: int) -> str: return "" if text == "-" else text def closeEvent(self, event) -> None: # noqa: N802 # Qt override name + self._hotplug_timer.stop() + if self._auto_check.isChecked(): + default_usb_watcher().stop() self._disable_sharing() super().closeEvent(event) diff --git a/je_auto_control/utils/remote_desktop/webrtc_host.py b/je_auto_control/utils/remote_desktop/webrtc_host.py index 16f14e90..cc741f50 100644 --- a/je_auto_control/utils/remote_desktop/webrtc_host.py +++ b/je_auto_control/utils/remote_desktop/webrtc_host.py @@ -99,6 +99,8 @@ def __init__(self, *, token: str, # NOSONAR python:S107 # public constructor; self._mic_receiver = None # Optional[MicUplinkReceiver] self._files_channel = None self._files_receiver = None # Optional[FileTransferReceiver] + self._usb_channel = None + self._usb_host = None # Optional[UsbChannelHost] self._on_file_received: Optional[Callable] = None self._on_viewer_video_frame: Optional[Callable] = None self._viewer_video_task = None @@ -187,6 +189,8 @@ async def _async_create_offer(self) -> str: self._wire_mic_channel(self._mic_channel) self._files_channel = self._pc.createDataChannel("files") self._wire_files_channel(self._files_channel) + self._usb_channel = self._pc.createDataChannel("usb") + self._wire_usb_channel(self._usb_channel) self._wire_state_handlers(self._pc) self._wire_viewer_video_handler(self._pc) offer = await self._pc.createOffer() @@ -378,6 +382,36 @@ def _on_message(message) -> None: on_done=self._on_file_done, ) + def _wire_usb_channel(self, channel) -> None: + """Carry USB passthrough over the ``usb`` DataChannel. + + Gated on viewer authentication *and* the global passthrough flag + (default off); per-device access is governed by the host's + ``UsbAcl`` (default deny). The session is built lazily on the + first frame so a host without the USB backend installed pays + nothing until a viewer actually uses the channel. + """ + from je_auto_control.utils.usb.passthrough import UsbChannelHost + + def _factory(): + from je_auto_control.utils.usb.passthrough import ( + UsbAcl, UsbPassthroughSession, default_passthrough_backend, + ) + return UsbPassthroughSession( + default_passthrough_backend(), acl=UsbAcl(), + viewer_id=getattr(self, "_viewer_id", None), + ) + + def _enabled() -> bool: + from je_auto_control.utils.usb.passthrough import ( + is_usb_passthrough_enabled, + ) + return bool(self._authenticated) and is_usb_passthrough_enabled() + + self._usb_host = UsbChannelHost( + channel, session_factory=_factory, enabled_check=_enabled, + ) + def set_file_received_callback(self, callback) -> None: """Register a sync callback ``cb(path: Path)`` for completed transfers.""" self._on_file_received = callback diff --git a/je_auto_control/utils/remote_desktop/webrtc_viewer.py b/je_auto_control/utils/remote_desktop/webrtc_viewer.py index 73cd6aa0..38656131 100644 --- a/je_auto_control/utils/remote_desktop/webrtc_viewer.py +++ b/je_auto_control/utils/remote_desktop/webrtc_viewer.py @@ -59,6 +59,8 @@ def __init__(self, *, token: str, self._mic_sender = None # Optional[MicUplinkSender] self._files_channel = None self._files_receiver = None # Optional[FileTransferReceiver] + self._usb_channel = None + self._usb_client = None # Optional[UsbChannelClient] self._on_file_received = None self._on_inbox_listing: Optional[InboxListingCallback] = None self._on_inbox_op_result: Optional[InboxOpResultCallback] = None @@ -274,6 +276,19 @@ def _on_message(message) -> None: on_done=self._on_viewer_file_done, ) + def _wire_usb_channel(self, channel) -> None: + """Attach a USB passthrough client to the ``usb`` DataChannel. + + Exposed via :meth:`usb_client` so the GUI can drive + ``list_devices`` / ``open`` against the remote host's devices. + """ + from je_auto_control.utils.usb.passthrough import UsbChannelClient + self._usb_client = UsbChannelClient(channel) + + def usb_client(self): + """Return the live USB passthrough client, or None if not wired.""" + return self._usb_client + def _on_viewer_file_done(self, path) -> None: if self._on_file_received is not None: try: @@ -460,6 +475,10 @@ def _on_datachannel(channel) -> None: self._files_channel = channel self._wire_files_channel(channel) return + if channel.label == "usb": + self._usb_channel = channel + self._wire_usb_channel(channel) + return self._control_channel = channel self._wire_control_channel(channel) diff --git a/je_auto_control/utils/usb/passthrough/__init__.py b/je_auto_control/utils/usb/passthrough/__init__.py index 4c0afd31..58eed401 100644 --- a/je_auto_control/utils/usb/passthrough/__init__.py +++ b/je_auto_control/utils/usb/passthrough/__init__.py @@ -42,6 +42,9 @@ ClientHandle, UsbClientClosed, UsbClientError, UsbClientTimeout, UsbPassthroughClient, ) +from je_auto_control.utils.usb.passthrough.webrtc_channel import ( + UsbChannelClient, UsbChannelHost, +) __all__ = [ "FakeUsbBackend", "LibusbBackend", "UsbBackend", "UsbHandle", @@ -56,6 +59,7 @@ "SessionError", "UsbPassthroughSession", "ClientHandle", "UsbClientClosed", "UsbClientError", "UsbClientTimeout", "UsbPassthroughClient", + "UsbChannelClient", "UsbChannelHost", "AclRule", "UsbAcl", "default_acl_path", "export_acl_to_file", "import_acl_from_file", "VaultKeyProvider", "dpapi_available", "load_or_create_dpapi_key", diff --git a/je_auto_control/utils/usb/passthrough/protocol.py b/je_auto_control/utils/usb/passthrough/protocol.py index ae460865..bf240764 100644 --- a/je_auto_control/utils/usb/passthrough/protocol.py +++ b/je_auto_control/utils/usb/passthrough/protocol.py @@ -39,6 +39,7 @@ class Opcode(enum.IntEnum): CREDIT = 0x07 CLOSE = 0x08 CLOSED = 0x09 + RESUME = 0x0A ERROR = 0xFF diff --git a/je_auto_control/utils/usb/passthrough/session.py b/je_auto_control/utils/usb/passthrough/session.py index 45d8672e..38febc8a 100644 --- a/je_auto_control/utils/usb/passthrough/session.py +++ b/je_auto_control/utils/usb/passthrough/session.py @@ -59,6 +59,7 @@ import base64 import json +import secrets import threading import time from dataclasses import dataclass @@ -121,11 +122,12 @@ def is_locked(self) -> bool: @dataclass class _ClaimState: - """Per-claim handle + credit accounting.""" + """Per-claim handle + credit accounting + resume token.""" handle: UsbHandle inbound_credits: int = _DEFAULT_INITIAL_CREDITS outbound_credits: int = _DEFAULT_INITIAL_CREDITS + resume_token: str = "" class UsbPassthroughSession: @@ -149,6 +151,7 @@ def __init__(self, backend: UsbBackend, self._audit_log = audit_log # Late-bound; resolved on first use. self._lock = threading.Lock() self._claims: Dict[int, _ClaimState] = {} + self._resume_index: Dict[str, int] = {} self._next_claim_id = 1 self._abuse = _AbuseTracker() @@ -173,6 +176,7 @@ def close_all(self) -> None: with self._lock: handles = [c.handle for c in self._claims.values()] self._claims.clear() + self._resume_index.clear() for handle in handles: try: handle.close() @@ -199,6 +203,8 @@ def _dispatch(self, frame: Frame) -> List[Frame]: """Route one incoming frame; return zero or more reply frames.""" if frame.op == Opcode.OPEN: return [self._handle_open(frame)] + if frame.op == Opcode.RESUME: + return [self._handle_resume(frame)] if frame.op == Opcode.CLOSE: return [self._handle_close(frame)] if frame.op == Opcode.CTRL: @@ -286,6 +292,7 @@ def _handle_open(self, frame: Frame) -> Frame: self._audit("usb_open_backend_error", vendor_id, product_id, serial, detail=str(error)) return _opened_failure(frame.claim_id, str(error)) + resume_token = secrets.token_hex(16) with self._lock: claim_id = self._next_claim_id self._next_claim_id = (self._next_claim_id % 0xFFFE) + 1 @@ -293,12 +300,42 @@ def _handle_open(self, frame: Frame) -> Frame: handle=handle, inbound_credits=self._initial_credits, outbound_credits=self._initial_credits, + resume_token=resume_token, ) + self._resume_index[resume_token] = claim_id self._audit("usb_open_allowed", vendor_id, product_id, serial, detail=f"claim_id={claim_id}") return Frame( op=Opcode.OPENED, claim_id=claim_id, - payload=_encode_json_payload({"ok": True, "claim_id": claim_id}), + payload=_encode_json_payload({ + "ok": True, "claim_id": claim_id, + "resume_token": resume_token, + }), + ) + + def _handle_resume(self, frame: Frame) -> Frame: + """Re-bind a still-open claim to a reconnecting viewer. + + Resolves claim continuity (open question / Phase 2 resilience): + if the host session outlived a viewer transport drop, the viewer + replays its ``resume_token`` instead of re-OPENing, keeping the + device state and claim_id intact. + """ + try: + token = str(_decode_json_payload(frame.payload)["resume_token"]) + except (KeyError, ValueError, TypeError) as error: + return _opened_failure(frame.claim_id, f"bad RESUME payload: {error}") + with self._lock: + claim_id = self._resume_index.get(token) + claim = self._claims.get(claim_id) if claim_id is not None else None + if claim is None: + return _opened_failure(frame.claim_id, "unknown or expired resume token") + self._audit("usb_resume", "?", "?", None, detail=f"claim_id={claim_id}") + return Frame( + op=Opcode.OPENED, claim_id=claim_id, + payload=_encode_json_payload({ + "ok": True, "claim_id": claim_id, "resume_token": token, + }), ) def _acl_decision(self, vendor_id: str, product_id: str, @@ -360,6 +397,8 @@ def _audit(self, event_type: str, vendor_id: str, product_id: str, def _handle_close(self, frame: Frame) -> Frame: with self._lock: claim = self._claims.pop(int(frame.claim_id), None) + if claim is not None and claim.resume_token: + self._resume_index.pop(claim.resume_token, None) if claim is None: return _error_frame( frame.claim_id, f"unknown claim_id {frame.claim_id}", diff --git a/je_auto_control/utils/usb/passthrough/viewer_client.py b/je_auto_control/utils/usb/passthrough/viewer_client.py index 8e69f9af..7b045582 100644 --- a/je_auto_control/utils/usb/passthrough/viewer_client.py +++ b/je_auto_control/utils/usb/passthrough/viewer_client.py @@ -91,9 +91,11 @@ class ClientHandle: and return ``bytes``. Backend errors raise :class:`UsbClientError`. """ - def __init__(self, client: "UsbPassthroughClient", claim_id: int) -> None: + def __init__(self, client: "UsbPassthroughClient", claim_id: int, + resume_token: str = "") -> None: self._client = client self._claim_id = claim_id + self._resume_token = resume_token self._closed = False self._lock = threading.Lock() @@ -101,6 +103,11 @@ def __init__(self, client: "UsbPassthroughClient", claim_id: int) -> None: def claim_id(self) -> int: return self._claim_id + @property + def resume_token(self) -> str: + """Opaque token to re-bind this claim after a transport reconnect.""" + return self._resume_token + @property def closed(self) -> bool: with self._lock: @@ -282,11 +289,47 @@ def open(self, *, vendor_id: str, product_id: str, body = _decode_json(request.reply_payload) if not body.get("ok"): raise UsbClientError(body.get("error", "open failed")) + return self._bind_claim(body) + + def resume(self, resume_token: str) -> ClientHandle: + """Re-bind a claim after a reconnect using a token from ``open``. + + The host session must still hold the claim (it outlived the + viewer's transport drop). Returns a fresh :class:`ClientHandle` + for the same ``claim_id``; raises :class:`UsbClientError` if the + token is unknown or expired. + """ + request = _PendingRequest( + expected_op=Opcode.OPENED, event=threading.Event(), + ) + with self._lock: + if self._closed: + raise UsbClientClosed(_CLIENT_SHUT_DOWN_MSG) + if self._open_pending is not None: + raise UsbClientError("another open is in progress") + self._open_pending = request + self._send(Frame( + op=Opcode.RESUME, + payload=json.dumps({"resume_token": resume_token}).encode("utf-8"), + )) + if not request.event.wait(timeout=self._reply_timeout): + with self._lock: + if self._open_pending is request: + self._open_pending = None + raise UsbClientTimeout("RESUME timed out") + if request.cancelled: + raise UsbClientClosed("client shut down before RESUME reply") + body = _decode_json(request.reply_payload) + if not body.get("ok"): + raise UsbClientError(body.get("error", "resume failed")) + return self._bind_claim(body) + + def _bind_claim(self, body: Dict[str, Any]) -> ClientHandle: claim_id = int(body["claim_id"]) with self._lock: self._credits[claim_id] = self._initial_credit_guess self._credit_events[claim_id] = threading.Event() - return ClientHandle(self, claim_id) + return ClientHandle(self, claim_id, str(body.get("resume_token", ""))) def list_devices(self) -> List[Dict[str, Any]]: """Ask the host for the ACL-visible device list (open question 3). diff --git a/je_auto_control/utils/usb/passthrough/webrtc_channel.py b/je_auto_control/utils/usb/passthrough/webrtc_channel.py new file mode 100644 index 00000000..693d1ffc --- /dev/null +++ b/je_auto_control/utils/usb/passthrough/webrtc_channel.py @@ -0,0 +1,167 @@ +"""Bridge the USB passthrough protocol onto a WebRTC ``usb`` DataChannel. + +The protocol, session, and client are transport-agnostic; this module +is the thin adapter that carries their binary frames over an aiortc +``RTCDataChannel`` (the same pattern ``webrtc_files`` uses for file +transfer). Two adapters: + +* :class:`UsbChannelHost` — wraps a host-side channel + a + :class:`UsbPassthroughSession`. Incoming frames are decoded, gated on + the feature flag, fed to the session, and the replies are sent back. +* :class:`UsbChannelClient` — wraps a viewer-side channel + a + :class:`UsbPassthroughClient`, exposing ``list_devices`` / ``open`` / + ``resume``. + +Sends are marshalled onto the aiortc event-loop thread via a bridge +with ``call_soon(fn, *args)`` (defaulting to the project's shared +WebRTC bridge). The bridge is injectable so the adapters can be tested +with a synchronous stand-in and a fake channel — no aiortc required to +exercise the bridging logic. +""" +from __future__ import annotations + +import json +import threading +from typing import Any, Callable, Optional + +from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.usb.passthrough.flags import ( + is_usb_passthrough_enabled, +) +from je_auto_control.utils.usb.passthrough.protocol import ( + Frame, Opcode, ProtocolError, decode_frame, encode_frame, +) +from je_auto_control.utils.usb.passthrough.session import UsbPassthroughSession +from je_auto_control.utils.usb.passthrough.viewer_client import ( + ClientHandle, UsbPassthroughClient, +) + +_USB_CHANNEL_LABEL = "usb" + + +class _ImmediateBridge: + """Fallback bridge that runs the send inline (used by tests).""" + + @staticmethod + def call_soon(fn: Callable[..., Any], *args: Any) -> None: + fn(*args) + + +def _resolve_bridge(bridge: Optional[Any]) -> Any: + if bridge is not None: + return bridge + from je_auto_control.utils.remote_desktop.webrtc_transport import ( + get_bridge, + ) + return get_bridge() + + +def _as_bytes(raw: Any) -> bytes: + if isinstance(raw, (bytes, bytearray, memoryview)): + return bytes(raw) + raise TypeError(f"usb channel expects binary frames, got {type(raw).__name__}") + + +def _error_bytes(message: str) -> bytes: + return encode_frame(Frame( + op=Opcode.ERROR, + payload=json.dumps({"error": message}).encode("utf-8"), + )) + + +class UsbChannelHost: + """Carry a :class:`UsbPassthroughSession` over a host DataChannel.""" + + def __init__(self, channel: Any, *, + session: Optional[UsbPassthroughSession] = None, + session_factory: Optional[ + Callable[[], UsbPassthroughSession] + ] = None, + bridge: Optional[Any] = None, + enabled_check: Optional[Callable[[], bool]] = None) -> None: + self._channel = channel + self._session = session + self._factory = session_factory + self._bridge = bridge + self._enabled = enabled_check or is_usb_passthrough_enabled + self._lock = threading.Lock() + channel.on("message")(self._on_message) + + @property + def session(self) -> Optional[UsbPassthroughSession]: + return self._session + + def _ensure_session(self) -> Optional[UsbPassthroughSession]: + with self._lock: + if self._session is None and self._factory is not None: + self._session = self._factory() + return self._session + + def _on_message(self, raw: Any) -> None: + if not self._enabled(): + self._reply(_error_bytes("usb passthrough disabled")) + return + try: + frame = decode_frame(_as_bytes(raw)) + except (ProtocolError, TypeError) as error: + self._reply(_error_bytes(f"bad frame: {error}")) + return + try: + session = self._ensure_session() + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: backend construction can fail many ways; report, don't crash the channel + self._reply(_error_bytes(f"usb backend unavailable: {error}")) + return + if session is None: + self._reply(_error_bytes("no usb session on host")) + return + for reply in session.handle_frame(frame): + self._reply(encode_frame(reply)) + + def _reply(self, data: bytes) -> None: + _resolve_bridge(self._bridge).call_soon(self._channel.send, data) + + +class UsbChannelClient: + """Drive a :class:`UsbPassthroughClient` over a viewer DataChannel.""" + + def __init__(self, channel: Any, *, bridge: Optional[Any] = None, + reply_timeout_s: float = 10.0) -> None: + self._channel = channel + self._bridge = bridge + self.client = UsbPassthroughClient( + send_frame=self._send_frame, reply_timeout_s=reply_timeout_s, + ) + channel.on("message")(self._on_message) + + def _send_frame(self, frame: Frame) -> None: + _resolve_bridge(self._bridge).call_soon( + self._channel.send, encode_frame(frame), + ) + + def _on_message(self, raw: Any) -> None: + try: + self.client.feed_frame(decode_frame(_as_bytes(raw))) + except (ProtocolError, TypeError) as error: + autocontrol_logger.warning( + "usb channel client: dropping bad frame: %r", error, + ) + + def list_devices(self) -> Any: + return self.client.list_devices() + + def open(self, *, vendor_id: str, product_id: str, + serial: Optional[str] = None) -> ClientHandle: + return self.client.open( + vendor_id=vendor_id, product_id=product_id, serial=serial, + ) + + def resume(self, resume_token: str) -> ClientHandle: + return self.client.resume(resume_token) + + def shutdown(self) -> None: + self.client.shutdown() + + +__all__ = [ + "UsbChannelClient", "UsbChannelHost", "_USB_CHANNEL_LABEL", +] diff --git a/test/unit_test/headless/test_usb_passthrough.py b/test/unit_test/headless/test_usb_passthrough.py index bfd1356b..aa81e2cf 100644 --- a/test/unit_test/headless/test_usb_passthrough.py +++ b/test/unit_test/headless/test_usb_passthrough.py @@ -399,6 +399,56 @@ def test_backend_handle_close_is_idempotent(): handle.close() # second call must not raise +# --------------------------------------------------------------------------- +# Resume tokens (claim continuity across reconnect) +# --------------------------------------------------------------------------- + + +def _resume_frame(token: str) -> Frame: + return Frame(op=Opcode.RESUME, + payload=json.dumps({"resume_token": token}).encode("utf-8")) + + +def test_open_emits_resume_token(): + session = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE_DEVICE])) + body = json.loads(session.handle_frame(_make_open_frame())[0].payload) + assert body["ok"] is True + assert isinstance(body["resume_token"], str) and body["resume_token"] + + +def test_resume_rebinds_same_claim(): + backend = FakeUsbBackend(devices=[_SAMPLE_DEVICE]) + session = UsbPassthroughSession(backend) + opened = json.loads(session.handle_frame(_make_open_frame())[0].payload) + token = opened["resume_token"] + claim_id = opened["claim_id"] + # Simulate a viewer reconnect: same session, replay the token. + reply = session.handle_frame(_resume_frame(token))[0] + body = json.loads(reply.payload.decode("utf-8")) + assert reply.op == Opcode.OPENED + assert body["ok"] is True + assert body["claim_id"] == claim_id + # The claim was never closed, so it is still serviceable. + assert session.active_claim_count == 1 + + +def test_resume_unknown_token_fails(): + session = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE_DEVICE])) + body = json.loads(session.handle_frame(_resume_frame("deadbeef"))[0].payload) + assert body["ok"] is False + assert "resume token" in body["error"] + + +def test_resume_after_close_fails(): + backend = FakeUsbBackend(devices=[_SAMPLE_DEVICE]) + session = UsbPassthroughSession(backend) + opened = json.loads(session.handle_frame(_make_open_frame())[0].payload) + token, claim_id = opened["resume_token"], opened["claim_id"] + session.handle_frame(Frame(op=Opcode.CLOSE, claim_id=claim_id)) + body = json.loads(session.handle_frame(_resume_frame(token))[0].payload) + assert body["ok"] is False + + # --------------------------------------------------------------------------- # Abuse tracking / lockout # --------------------------------------------------------------------------- diff --git a/test/unit_test/headless/test_usb_passthrough_list_fragment.py b/test/unit_test/headless/test_usb_passthrough_list_fragment.py index f175d897..7aa8019b 100644 --- a/test/unit_test/headless/test_usb_passthrough_list_fragment.py +++ b/test/unit_test/headless/test_usb_passthrough_list_fragment.py @@ -1,6 +1,8 @@ """Tests for open questions 2 (fragmentation) and 3 (LIST over channel).""" import json +import pytest + from je_auto_control.utils.usb.passthrough import ( AclRule, FLAG_EOF, Frame, MAX_PAYLOAD_BYTES, Opcode, UsbAcl, UsbPassthroughClient, UsbPassthroughSession, fragment_payload, @@ -123,6 +125,45 @@ def test_client_list_devices_round_trip(tmp_path): loop.client.shutdown() +def test_client_resume_after_reconnect_keeps_claim(): + """A new client RESUMEs a claim the host session still holds.""" + host = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE])) + loop1 = _SyncLoop(host) + handle = loop1.client.open(vendor_id="1050", product_id="0407") + token = handle.resume_token + claim_id = handle.claim_id + assert token + # Simulate a transport drop: tear down the viewer client only; the + # host session keeps the claim alive (no close_all). + loop1.client.shutdown() + assert host.active_claim_count == 1 + + # Reconnect with a brand-new client over the same host session. + loop2 = _SyncLoop(host) + try: + resumed = loop2.client.resume(token) + assert resumed.claim_id == claim_id + backend_handle = next( + iter(host._backend._open_handles.values()) # type: ignore[attr-defined] + ) + backend_handle.transfer_hook = lambda kind, kwargs: b"\x01" + assert resumed.control_transfer( + bm_request_type=0xC0, b_request=6, length=1, + ) == b"\x01" + finally: + loop2.client.shutdown() + + +def test_client_resume_unknown_token_raises(): + host = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE])) + loop = _SyncLoop(host) + try: + with pytest.raises(Exception): + loop.client.resume("not-a-real-token") + finally: + loop.client.shutdown() + + def test_client_reassembles_oversize_bulk_in(): host = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE])) loop = _SyncLoop(host) diff --git a/test/unit_test/headless/test_usb_passthrough_panel.py b/test/unit_test/headless/test_usb_passthrough_panel.py index d93db8b5..10eee779 100644 --- a/test/unit_test/headless/test_usb_passthrough_panel.py +++ b/test/unit_test/headless/test_usb_passthrough_panel.py @@ -119,3 +119,30 @@ def test_panel_enable_is_idempotent(qapp, tmp_path): finally: panel._disable_sharing() panel.deleteLater() + + +def test_panel_hotplug_toggle_starts_and_stops(qapp, tmp_path): + panel, _acl = _make_panel(qapp, tmp_path) + try: + panel._auto_check.setChecked(True) + assert panel._hotplug_timer.isActive() is True + panel._poll_hotplug() # must not raise even with no events + panel._auto_check.setChecked(False) + assert panel._hotplug_timer.isActive() is False + finally: + panel.deleteLater() + + +def test_panel_export_import_acl_round_trip(qapp, tmp_path): + panel, acl = _make_panel(qapp, tmp_path) + try: + acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + out = tmp_path / "exp.json" + from je_auto_control.utils.usb.passthrough import ( + export_acl_to_file, import_acl_from_file, + ) + export_acl_to_file(acl, out) + fresh = UsbAcl(path=tmp_path / "fresh.json") + assert import_acl_from_file(fresh, out) == 1 + finally: + panel.deleteLater() diff --git a/test/unit_test/headless/test_usb_webrtc_channel.py b/test/unit_test/headless/test_usb_webrtc_channel.py new file mode 100644 index 00000000..5def63ed --- /dev/null +++ b/test/unit_test/headless/test_usb_webrtc_channel.py @@ -0,0 +1,162 @@ +"""Tests for the WebRTC usb-channel adapters (no aiortc needed). + +A pair of LinkedChannels plus an immediate bridge gives a synchronous +loopback over the channel abstraction, so the host/client adapters are +exercised end to end exactly as they would be over a real DataChannel. +""" +import pytest + +from je_auto_control.utils.usb.passthrough import ( + AclRule, Opcode, UsbAcl, UsbChannelClient, UsbChannelHost, + UsbClientError, UsbPassthroughSession, decode_frame, +) +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") + + +class _ImmediateBridge: + @staticmethod + def call_soon(fn, *args): + fn(*args) + + +class _LinkedChannel: + """Fake RTCDataChannel whose ``send`` delivers to its peer's handler.""" + + def __init__(self): + self._peer = None + self._handlers = {} + + def link(self, peer): + self._peer = peer + + def on(self, event): + def _register(fn): + self._handlers[event] = fn + return fn + return _register + + def send(self, data): + handler = self._peer._handlers.get("message") + if handler is not None: + handler(data) + + +def _linked_pair(): + host_ch, client_ch = _LinkedChannel(), _LinkedChannel() + host_ch.link(client_ch) + client_ch.link(host_ch) + return host_ch, client_ch + + +def _wire(session, *, enabled=True): + host_ch, client_ch = _linked_pair() + host = UsbChannelHost( + host_ch, session=session, bridge=_ImmediateBridge(), + enabled_check=lambda: enabled, + ) + client = UsbChannelClient(client_ch, bridge=_ImmediateBridge()) + return host, client + + +def _allow_acl(tmp_path, *devices): + acl = UsbAcl(path=tmp_path / "acl.json") + for dev in devices: + acl.add_rule(AclRule(vendor_id=dev.vendor_id, + product_id=dev.product_id, allow=True)) + return acl + + +def test_channel_list_devices_round_trip(tmp_path): + session = UsbPassthroughSession( + FakeUsbBackend(devices=[_SAMPLE]), acl=_allow_acl(tmp_path, _SAMPLE), + ) + _host, client = _wire(session) + try: + devices = client.list_devices() + assert [d["vendor_id"] for d in devices] == ["1050"] + finally: + client.shutdown() + + +def test_channel_open_and_transfer(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + session = UsbPassthroughSession(backend, acl=_allow_acl(tmp_path, _SAMPLE)) + _host, client = _wire(session) + try: + handle = client.open(vendor_id="1050", product_id="0407") + backend_handle = next(iter(backend._open_handles.values())) + backend_handle.transfer_hook = lambda kind, kwargs: b"\xaa\xbb" + assert handle.control_transfer( + bm_request_type=0xC0, b_request=6, length=2, + ) == b"\xaa\xbb" + assert handle.resume_token + finally: + client.shutdown() + + +def test_channel_resume_after_reconnect(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + session = UsbPassthroughSession(backend, acl=_allow_acl(tmp_path, _SAMPLE)) + _host1, client1 = _wire(session) + handle = client1.open(vendor_id="1050", product_id="0407") + token = handle.resume_token + client1.shutdown() # transport drop; host keeps the claim + assert session.active_claim_count == 1 + _host2, client2 = _wire(session) + try: + resumed = client2.resume(token) + assert resumed.claim_id == handle.claim_id + finally: + client2.shutdown() + + +def test_channel_disabled_flag_rejects(tmp_path): + session = UsbPassthroughSession( + FakeUsbBackend(devices=[_SAMPLE]), acl=_allow_acl(tmp_path, _SAMPLE), + ) + _host, client = _wire(session, enabled=False) + try: + with pytest.raises(UsbClientError): + client.list_devices() + finally: + client.shutdown() + + +def test_host_lazy_session_factory(tmp_path): + backend = FakeUsbBackend(devices=[_SAMPLE]) + built = [] + + def factory(): + built.append(1) + return UsbPassthroughSession(backend, acl=_allow_acl(tmp_path, _SAMPLE)) + + host_ch, client_ch = _linked_pair() + UsbChannelHost(host_ch, session_factory=factory, + bridge=_ImmediateBridge(), enabled_check=lambda: True) + client = UsbChannelClient(client_ch, bridge=_ImmediateBridge()) + try: + assert client.list_devices() == [ + {"vendor_id": "1050", "product_id": "0407", + "serial": "ABC123", "bus_location": None}, + ] + assert built == [1] # session built lazily on first message + finally: + client.shutdown() + + +def test_host_rejects_non_binary_frame(tmp_path): + session = UsbPassthroughSession(FakeUsbBackend(devices=[_SAMPLE])) + host_ch, client_ch = _linked_pair() + sent = [] + client_ch.on("message")(sent.append) + UsbChannelHost(host_ch, session=session, bridge=_ImmediateBridge(), + enabled_check=lambda: True) + # A str (not bytes) is invalid on the binary usb channel → ERROR back. + host_ch._handlers["message"]("not-binary") + assert len(sent) == 1 + reply = decode_frame(sent[0]) + assert reply.op == Opcode.ERROR From 216c02157272f66cc9f3dbfb9fb22bb9a1afb0bd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 1 Jun 2026 01:09:58 +0800 Subject: [PATCH 04/10] Wire USB Sharing panel to the live WebRTC viewer session Add a Source selector (Local loopback / Remote WebRTC) to the panel's use section. When Remote is selected, List / Open run against the live WebRTC viewer's host via a new registry.webrtc_usb_client() accessor; both sources share the same list_devices/open API so the actions stay source-agnostic. The provider is injectable for testing. i18n across four languages; docs updated; import je_auto_control stays Qt-free and aiortc-free. --- .../usb_passthrough_operator_guide.rst | 14 +-- .../usb_passthrough_operator_guide.rst | 12 +-- .../gui/language_wrapper/english.py | 4 + .../gui/language_wrapper/japanese.py | 4 + .../language_wrapper/simplified_chinese.py | 4 + .../language_wrapper/traditional_chinese.py | 4 + je_auto_control/gui/usb_passthrough_panel.py | 89 +++++++++++++++---- .../utils/remote_desktop/registry.py | 13 +++ .../headless/test_usb_passthrough_panel.py | 54 +++++++++++ .../headless/test_usb_registry_client.py | 33 +++++++ 10 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 test/unit_test/headless/test_usb_registry_client.py diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index a6384332..688b9404 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -279,13 +279,13 @@ What is *not* shipped yet one (a descriptor read proves the full stack). The *USB Browser* tab's *Open* button now also works against a **localhost** target via the same loopback path. -- Cross-machine transport is now wired: the WebRTC host creates a - ``usb`` DataChannel and the viewer exposes ``viewer.usb_client()`` - (a ``UsbChannelClient`` with ``list_devices`` / ``open`` / ``resume``). - Drive it from Python today over a live WebRTC session. The simple - *USB Browser* / *USB Sharing* panels still use the local loopback + - REST paths; auto-wiring those panels to a live WebRTC viewer session - is the remaining GUI integration step. +- Cross-machine is fully wired: the WebRTC host creates a ``usb`` + DataChannel and the viewer exposes ``viewer.usb_client()`` (a + ``UsbChannelClient`` with ``list_devices`` / ``open`` / ``resume``). + The *USB Sharing* panel has a **Source** selector — pick *Remote + (WebRTC)* and the List / Open buttons run against the live WebRTC + viewer's host (via ``registry.webrtc_usb_client()``); pick *Local + (loopback)* for same-machine use. You can also drive it from Python. - Windows WinUSB and macOS IOKit transfer paths are written but not yet validated against real hardware. Do not use in production until the Phase 2e hardware test matrix passes. diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index 347ee225..ecf6ea09 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -258,12 +258,12 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim 做 ACL 允許/封鎖;右側經 in-process channel 列出分享裝置並 *開啟* 其中一個(讀描述元即證明整條堆疊運作)。*USB Browser* 分頁的 *Open* 按鈕現在對 **localhost** 目標也會走同一條 loopback 路徑。 -- 跨機器 transport 已串接:WebRTC host 會建立 ``usb`` DataChannel, - viewer 以 ``viewer.usb_client()`` 暴露 ``UsbChannelClient``\ (含 - ``list_devices`` / ``open`` / ``resume``\ )。今天即可在 WebRTC session - 上從 Python 驅動。簡易的 *USB Browser* / *USB 分享* 面板目前仍走本機 - loopback + REST;把面板自動接到 live WebRTC viewer session 是剩餘的 - GUI 整合步驟。 +- 跨機器已完整串接:WebRTC host 建立 ``usb`` DataChannel,viewer 以 + ``viewer.usb_client()`` 暴露 ``UsbChannelClient``\ (含 ``list_devices`` + / ``open`` / ``resume``\ )。*USB 分享* 面板有 **來源** 下拉:選 + *遠端(WebRTC)* 時 List / Open 會對 live WebRTC viewer 的主機操作 + (經 ``registry.webrtc_usb_client()``\ );選 *本機(loopback)* 則走同機。 + 亦可從 Python 驅動。 - Windows WinUSB 與 macOS IOKit 的 transfer 路徑已寫但尚未對實體硬體 驗證。在 Phase 2e 硬體測試矩陣通過前請勿用於 production。 - Phase 2e 外部安全審查尚未簽核;feature flag 必須維持顯式 opt-in。 diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 7aa2acb8..9f45be93 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -156,6 +156,10 @@ "usb_share_acl_imported": "Imported {count} rule(s).", "usb_share_acl_import_failed": "ACL I/O failed: {error}", "usb_share_auto_refresh": "Auto-refresh (hotplug)", + "usb_share_source_label": "Source:", + "usb_share_source_local": "Local (loopback)", + "usb_share_source_remote": "Remote (WebRTC)", + "usb_share_no_webrtc": "No live WebRTC session — connect a WebRTC viewer first.", # Inspector tab "inspector_metrics_group": "Rolling metrics", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 5e8d9ccd..31766b8b 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -154,6 +154,10 @@ "usb_share_acl_imported": "{count} 件のルールをインポートしました。", "usb_share_acl_import_failed": "ACL 入出力に失敗:{error}", "usb_share_auto_refresh": "自動更新(ホットプラグ)", + "usb_share_source_label": "ソース:", + "usb_share_source_local": "ローカル(loopback)", + "usb_share_source_remote": "リモート(WebRTC)", + "usb_share_no_webrtc": "ライブ WebRTC セッションがありません — 先に WebRTC viewer に接続してください。", # 監視タブ "inspector_metrics_group": "集約メトリクス", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index 1c95d587..ef006239 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -145,6 +145,10 @@ "usb_share_acl_imported": "已导入 {count} 条规则。", "usb_share_acl_import_failed": "ACL 读写失败:{error}", "usb_share_auto_refresh": "自动刷新(热插拔)", + "usb_share_source_label": "来源:", + "usb_share_source_local": "本机(loopback)", + "usb_share_source_remote": "远程(WebRTC)", + "usb_share_no_webrtc": "没有 live WebRTC session — 请先连接 WebRTC viewer。", # 包监测分页 "inspector_metrics_group": "汇总指标", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 0a0495e6..1aeae883 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -146,6 +146,10 @@ "usb_share_acl_imported": "已匯入 {count} 條規則。", "usb_share_acl_import_failed": "ACL 讀寫失敗:{error}", "usb_share_auto_refresh": "自動刷新(熱插拔)", + "usb_share_source_label": "來源:", + "usb_share_source_local": "本機(loopback)", + "usb_share_source_remote": "遠端(WebRTC)", + "usb_share_no_webrtc": "沒有 live WebRTC session — 請先連線 WebRTC viewer。", # 封包監測分頁 "inspector_metrics_group": "彙整指標", diff --git a/je_auto_control/gui/usb_passthrough_panel.py b/je_auto_control/gui/usb_passthrough_panel.py index 20c10adc..70404c40 100644 --- a/je_auto_control/gui/usb_passthrough_panel.py +++ b/je_auto_control/gui/usb_passthrough_panel.py @@ -21,9 +21,9 @@ from PySide6.QtCore import QObject, QThread, QTimer, Signal from PySide6.QtWidgets import ( - QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel, - QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, - QVBoxLayout, QWidget, + QCheckBox, QComboBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, + QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -76,15 +76,22 @@ class UsbPassthroughPanel(TranslatableMixin, QWidget): def __init__(self, parent: Optional[QWidget] = None, *, acl: Optional[UsbAcl] = None, loopback_factory: Optional[Callable[[], UsbLoopback]] = None, + remote_client_provider: Optional[ + Callable[[], Optional[Any]] + ] = None, ) -> None: super().__init__(parent) self._tr_init() self._acl = acl if acl is not None else UsbAcl() self._loopback_factory = loopback_factory or self._default_loopback + self._remote_client_provider = ( + remote_client_provider or _default_remote_client + ) self._loopback: Optional[UsbLoopback] = None self._thread: Optional[QThread] = None self._host_badge = _StatusBadge() self._viewer_status = QLabel("") + self._source_combo = QComboBox() self._auto_check = QCheckBox() self._auto_check.toggled.connect(self._on_auto_toggled) self._hotplug_timer = QTimer(self) @@ -97,6 +104,7 @@ def __init__(self, parent: Optional[QWidget] = None, *, self._remote_token = QLineEdit() self._remote_token.setEchoMode(QLineEdit.EchoMode.Password) self._build_layout() + self._populate_source_combo() self._apply_local_headers() self._apply_shared_headers() self._refresh_local_devices() @@ -161,6 +169,10 @@ def _build_viewer_section(self) -> QWidget: intro = self._tr(QLabel(), "usb_share_intro") intro.setWordWrap(True) layout.addWidget(intro) + source_row = QHBoxLayout() + source_row.addWidget(self._tr(QLabel(), "usb_share_source_label")) + source_row.addWidget(self._source_combo, stretch=1) + layout.addLayout(source_row) layout.addWidget(self._shared_table, stretch=1) use_row = QHBoxLayout() list_btn = self._tr(QPushButton(), "usb_share_fetch_shared") @@ -204,8 +216,18 @@ def _apply_shared_headers(self) -> None: _t("usb_share_col_product"), _t("usb_share_col_serial"), ]) + def _populate_source_combo(self) -> None: + index = max(0, self._source_combo.currentIndex()) + self._source_combo.blockSignals(True) + self._source_combo.clear() + self._source_combo.addItem(_t("usb_share_source_local")) + self._source_combo.addItem(_t("usb_share_source_remote")) + self._source_combo.setCurrentIndex(index) + self._source_combo.blockSignals(False) + def retranslate(self) -> None: TranslatableMixin.retranslate(self) + self._populate_source_combo() self._apply_local_headers() self._apply_shared_headers() self._refresh_host_badge() @@ -330,15 +352,40 @@ def _import_acl(self) -> None: ) self._refresh_local_devices() - # --- use (loopback) ---------------------------------------------------- + # --- use (loopback or live WebRTC) ------------------------------------- + + def _source_is_remote(self) -> bool: + return self._source_combo.currentIndex() == 1 + + def _active_use_client(self) -> Any: + """Return the client for the selected source, or raise a friendly error. + + Both the loopback bundle and the WebRTC ``UsbChannelClient`` + expose ``list_devices`` and ``open`` with the same signatures, so + the use actions are source-agnostic. + """ + if self._source_is_remote(): + client = self._remote_client_provider() + if client is None: + raise RuntimeError(_t("usb_share_no_webrtc")) + return client + if self._loopback is None: + raise RuntimeError(_t("usb_share_enable_first")) + return self._loopback + + def _use_client_or_warn(self) -> Any: + try: + return self._active_use_client() + except RuntimeError as error: + self._info(str(error)) + return None def _list_shared(self) -> None: - loop = self._loopback - if loop is None: - self._info(_t("usb_share_enable_first")) + client = self._use_client_or_warn() + if client is None: return self._viewer_status.setText(_t("usb_share_listing")) - self._run_async(loop.list_devices, self._apply_shared, self._fail) + self._run_async(client.list_devices, self._apply_shared, self._fail) def _apply_shared(self, devices: List[dict]) -> None: self._viewer_status.setText( @@ -354,9 +401,8 @@ def _apply_shared(self, devices: List[dict]) -> None: self._shared_table.setItem(row, col, QTableWidgetItem(str(text))) def _open_selected(self) -> None: - loop = self._loopback - if loop is None: - self._info(_t("usb_share_enable_first")) + client = self._use_client_or_warn() + if client is None: return row = _selected_row(self._shared_table) if row is None: @@ -369,7 +415,7 @@ def _open_selected(self) -> None: _t("usb_share_opening").format(vid=vid, pid=pid), ) self._run_async( - lambda: _probe_device(loop, vid, pid, serial), + lambda: _probe_device(client, vid, pid, serial), lambda descriptor: self._opened(vid, pid, descriptor), self._fail, ) @@ -458,10 +504,23 @@ def _selected_row(table: QTableWidget) -> Optional[int]: return rows[0] if rows else None -def _probe_device(loop: UsbLoopback, vid: str, pid: str, +def _default_remote_client() -> Optional[Any]: + """Return the live WebRTC viewer's USB client, or None if unavailable.""" + try: + from je_auto_control.utils.remote_desktop.registry import registry + except ImportError: + return None + return registry.webrtc_usb_client() + + +def _probe_device(client: Any, vid: str, pid: str, serial: Optional[str]) -> bytes: - """Open the device, read its descriptor as a liveness proof, close.""" - handle = loop.open(vendor_id=vid, product_id=pid, serial=serial) + """Open the device, read its descriptor as a liveness proof, close. + + ``client`` is either a :class:`UsbLoopback` or a WebRTC + ``UsbChannelClient`` — both expose the same ``open`` signature. + """ + handle = client.open(vendor_id=vid, product_id=pid, serial=serial) try: return handle.control_transfer( bm_request_type=_DESC_REQUEST_TYPE, b_request=_DESC_REQUEST, diff --git a/je_auto_control/utils/remote_desktop/registry.py b/je_auto_control/utils/remote_desktop/registry.py index e7357fe0..daf1fd4e 100644 --- a/je_auto_control/utils/remote_desktop/registry.py +++ b/je_auto_control/utils/remote_desktop/registry.py @@ -352,5 +352,18 @@ def webrtc_viewer_status(self) -> Dict[str, Any]: "authenticated": getattr(viewer, "authenticated", False), } + def webrtc_usb_client(self): + """Return the live WebRTC viewer's USB passthrough client, or None. + + The viewer exposes ``usb_client()`` once the host has opened the + ``usb`` DataChannel. Returns None when no WebRTC viewer is active + or the channel hasn't been negotiated yet. + """ + viewer = self._webrtc_viewer + if viewer is None: + return None + getter = getattr(viewer, "usb_client", None) + return getter() if callable(getter) else None + registry = _RemoteDesktopRegistry() diff --git a/test/unit_test/headless/test_usb_passthrough_panel.py b/test/unit_test/headless/test_usb_passthrough_panel.py index 10eee779..b83ebccc 100644 --- a/test/unit_test/headless/test_usb_passthrough_panel.py +++ b/test/unit_test/headless/test_usb_passthrough_panel.py @@ -133,6 +133,60 @@ def test_panel_hotplug_toggle_starts_and_stops(qapp, tmp_path): panel.deleteLater() +def test_panel_remote_source_uses_provider(qapp, tmp_path): + """When 'Remote (WebRTC)' is selected, the panel routes to the + injected provider's client instead of the local loopback.""" + backend = FakeUsbBackend(devices=[_SAMPLE]) + acl = UsbAcl(path=tmp_path / "acl.json") + acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True)) + remote = UsbLoopback(backend=backend, acl=acl, viewer_id="remote") + panel = _panel_mod.UsbPassthroughPanel( + acl=UsbAcl(path=tmp_path / "host_acl.json"), + loopback_factory=lambda: UsbLoopback( + backend=FakeUsbBackend(devices=[]), acl=acl, + ), + remote_client_provider=lambda: remote, + ) + try: + # Index 1 == "Remote (WebRTC)". + panel._source_combo.setCurrentIndex(1) + client = panel._active_use_client() + assert client is remote + assert [d["vendor_id"] for d in client.list_devices()] == ["1050"] + finally: + remote.close() + panel.deleteLater() + + +def test_panel_remote_source_without_session_warns(qapp, tmp_path): + panel = _panel_mod.UsbPassthroughPanel( + acl=UsbAcl(path=tmp_path / "acl.json"), + loopback_factory=lambda: UsbLoopback( + backend=FakeUsbBackend(devices=[]), + acl=UsbAcl(path=tmp_path / "acl.json"), + ), + remote_client_provider=lambda: None, # no live WebRTC session + ) + try: + panel._source_combo.setCurrentIndex(1) + import pytest as _pytest + with _pytest.raises(RuntimeError): + panel._active_use_client() + finally: + panel.deleteLater() + + +def test_panel_local_source_is_default(qapp, tmp_path): + panel, _acl = _make_panel(qapp, tmp_path) + try: + assert panel._source_combo.currentIndex() == 0 + panel._enable_sharing() + assert panel._active_use_client() is panel._loopback + finally: + panel._disable_sharing() + panel.deleteLater() + + def test_panel_export_import_acl_round_trip(qapp, tmp_path): panel, acl = _make_panel(qapp, tmp_path) try: diff --git a/test/unit_test/headless/test_usb_registry_client.py b/test/unit_test/headless/test_usb_registry_client.py new file mode 100644 index 00000000..6c1f5d9d --- /dev/null +++ b/test/unit_test/headless/test_usb_registry_client.py @@ -0,0 +1,33 @@ +"""registry.webrtc_usb_client() exposes the live WebRTC viewer's USB client.""" +from je_auto_control.utils.remote_desktop.registry import registry + + +def test_webrtc_usb_client_none_when_no_viewer(): + # No WebRTC viewer is active in a fresh process. + registry._webrtc_viewer = None # noqa: SLF001 test setup + assert registry.webrtc_usb_client() is None + + +def test_webrtc_usb_client_delegates_to_viewer(): + sentinel = object() + + class _FakeViewer: + def usb_client(self): + return sentinel + + registry._webrtc_viewer = _FakeViewer() # noqa: SLF001 test setup + try: + assert registry.webrtc_usb_client() is sentinel + finally: + registry._webrtc_viewer = None # noqa: SLF001 cleanup + + +def test_webrtc_usb_client_tolerates_viewer_without_method(): + class _OldViewer: + pass + + registry._webrtc_viewer = _OldViewer() # noqa: SLF001 test setup + try: + assert registry.webrtc_usb_client() is None + finally: + registry._webrtc_viewer = None # noqa: SLF001 cleanup From 3b35b479a5ff55f8bf385b999b589b4e918efe75 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 1 Jun 2026 01:16:51 +0800 Subject: [PATCH 05/10] Add AC_usb_* executor commands for headless USB passthrough control Every GUI action now has an executor command so JSON action files, the socket server, and the scheduler can drive USB passthrough without a GUI: feature flag, ACL CRUD + export/import, local loopback list/open, and remote (live WebRTC) list/open. Each returns a JSON-able dict; the descriptor probe summarises the device. Stubs added to actions.pyi. --- .../usb_passthrough_operator_guide.rst | 32 ++++ .../usb_passthrough_operator_guide.rst | 31 ++++ je_auto_control/actions.pyi | 36 +++++ .../utils/executor/action_executor.py | 144 ++++++++++++++++++ .../headless/test_usb_passthrough_executor.py | 95 ++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 test/unit_test/headless/test_usb_passthrough_executor.py diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index 688b9404..c90ee95c 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -270,6 +270,38 @@ Audit chain shows ``broken_at_id`` Someone edited ``audit.db`` directly ========================================== ===================================================== +Headless control (``AC_usb_*`` commands) +======================================== + +Everything the GUI does is also an executor command, so JSON action +files, the socket server, and the scheduler can drive USB passthrough +with no GUI: + +================================ ============================================ +Command Purpose +================================ ============================================ +``AC_usb_passthrough_enable`` Toggle the feature flag (``enabled`` bool) +``AC_usb_passthrough_status`` Report whether passthrough is enabled +``AC_usb_acl_list`` List ACL rules + default + integrity state +``AC_usb_acl_add`` Add a per-device rule +``AC_usb_acl_remove`` Remove a rule +``AC_usb_acl_set_default`` Set the default policy (allow/deny) +``AC_usb_acl_export`` / ``_import`` Back up / restore the ACL as JSON +``AC_usb_loopback_list`` List ACL-visible devices over loopback +``AC_usb_loopback_open`` Claim a local device + read its descriptor +``AC_usb_remote_list`` List a remote host's devices (live WebRTC) +``AC_usb_remote_open`` Claim a remote device + read its descriptor +================================ ============================================ + +Example JSON action:: + + [ + ["AC_usb_passthrough_enable", {"enabled": true}], + ["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}], + ["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}] + ] + + What is *not* shipped yet ========================= diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index ecf6ea09..b0e60de0 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -251,6 +251,37 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim ========================================== ===================================================== +無 GUI 控制(``AC_usb_*`` 指令) +================================ + +GUI 能做的事都有對應的 executor 指令,所以 JSON action 檔、socket +server 與排程器都能在沒有 GUI 的情況下驅動 USB passthrough: + +================================ ============================================ +指令 用途 +================================ ============================================ +``AC_usb_passthrough_enable`` 切換 feature flag(``enabled`` 布林) +``AC_usb_passthrough_status`` 回報是否已啟用 +``AC_usb_acl_list`` 列出 ACL 規則 + 預設 + 完整性狀態 +``AC_usb_acl_add`` 新增 per-device 規則 +``AC_usb_acl_remove`` 移除規則 +``AC_usb_acl_set_default`` 設定預設政策(allow/deny) +``AC_usb_acl_export`` / ``_import`` 以 JSON 備份/還原 ACL +``AC_usb_loopback_list`` 經 loopback 列出 ACL 可見裝置 +``AC_usb_loopback_open`` claim 本機裝置並讀描述元 +``AC_usb_remote_list`` 列出遠端主機裝置(live WebRTC) +``AC_usb_remote_open`` claim 遠端裝置並讀描述元 +================================ ============================================ + +JSON action 範例:: + + [ + ["AC_usb_passthrough_enable", {"enabled": true}], + ["AC_usb_acl_add", {"vendor_id": "1050", "product_id": "0407"}], + ["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}] + ] + + 尚未發布的部分 ============== diff --git a/je_auto_control/actions.pyi b/je_auto_control/actions.pyi index 12287ca3..f42f85e6 100644 --- a/je_auto_control/actions.pyi +++ b/je_auto_control/actions.pyi @@ -468,6 +468,42 @@ def AC_usb_watch_start(poll_interval_s: float = ...) -> Dict[str, Any]: def AC_usb_watch_stop() -> Dict[str, Any]: ... +def AC_usb_passthrough_enable(enabled: bool = ...) -> Dict[str, Any]: + """Toggle the USB passthrough feature flag (default off).""" + +def AC_usb_passthrough_status() -> Dict[str, Any]: + ... + +def AC_usb_acl_list() -> Dict[str, Any]: + ... + +def AC_usb_acl_add(vendor_id: str, product_id: str, serial: str | None = ..., allow: bool = ..., prompt_on_open: bool = ..., label: str = ...) -> Dict[str, Any]: + """Add a per-device rule to the USB ACL.""" + +def AC_usb_acl_remove(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]: + ... + +def AC_usb_acl_set_default(policy: str) -> Dict[str, Any]: + ... + +def AC_usb_acl_export(path: str) -> Dict[str, Any]: + ... + +def AC_usb_acl_import(path: str, replace: bool = ...) -> Dict[str, Any]: + ... + +def AC_usb_loopback_list() -> Dict[str, Any]: + """List ACL-visible devices over the in-process loopback channel.""" + +def AC_usb_loopback_open(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]: + """Claim a local device over loopback and read its descriptor.""" + +def AC_usb_remote_list() -> Dict[str, Any]: + """List the remote host's devices over the live WebRTC usb channel.""" + +def AC_usb_remote_open(vendor_id: str, product_id: str, serial: str | None = ...) -> Dict[str, Any]: + """Claim a remote device over the live WebRTC usb channel and probe it.""" + def AC_vlm_click(description: str, screen_region: List[int] | None = ..., model: str | None = ..., backend: Any = ...) -> bool: """Locate by description, then click the center of the match.""" diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index a502f098..daf38b05 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -957,6 +957,136 @@ def _usb_recent_events(since: int = 0, ) +# --- USB passthrough (Phase 2) ------------------------------------------ + + +def _usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]: + """Toggle the USB passthrough feature flag (default off).""" + from je_auto_control.utils.usb.passthrough import ( + enable_usb_passthrough, is_usb_passthrough_enabled, + ) + enable_usb_passthrough(bool(enabled)) + return {"enabled": is_usb_passthrough_enabled()} + + +def _usb_passthrough_status() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled + return {"enabled": is_usb_passthrough_enabled()} + + +def _usb_acl_list() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + acl = UsbAcl() + return { + "default": acl.default_policy, + "integrity_ok": acl.integrity_ok, + "rules": [r.to_dict() for r in acl.list_rules()], + } + + +def _usb_acl_add(vendor_id: str, product_id: str, + serial: Optional[str] = None, allow: bool = True, + prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import AclRule, UsbAcl + acl = UsbAcl() + acl.add_rule(AclRule( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + label=str(label), allow=bool(allow), + prompt_on_open=bool(prompt_on_open), + )) + return {"added": True, "rules": len(acl.list_rules())} + + +def _usb_acl_remove(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + removed = UsbAcl().remove_rule( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + ) + return {"removed": removed} + + +def _usb_acl_set_default(policy: str) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + acl = UsbAcl() + acl.set_default_policy(str(policy)) + return {"default": acl.default_policy} + + +def _usb_acl_export(path: str) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl, export_acl_to_file + from pathlib import Path + export_acl_to_file(UsbAcl(), Path(path)) + return {"exported": True, "path": str(path)} + + +def _usb_acl_import(path: str, replace: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl, import_acl_from_file + from pathlib import Path + count = import_acl_from_file(UsbAcl(), Path(path), replace=bool(replace)) + return {"imported": count} + + +def _usb_descriptor_probe(client: Any, vendor_id: str, product_id: str, + serial: Optional[str]) -> Dict[str, Any]: + """Open a claim, read the device descriptor, close — return a summary.""" + from je_auto_control.utils.usb.passthrough import describe_descriptor + handle = client.open( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + ) + try: + descriptor = handle.control_transfer( + bm_request_type=0x80, b_request=0x06, w_value=0x0100, length=18, + ) + finally: + handle.close() + return { + "ok": True, "vendor_id": str(vendor_id), "product_id": str(product_id), + "descriptor_hex": descriptor.hex(), + "descriptor": describe_descriptor(descriptor), + } + + +def _usb_loopback_list() -> Dict[str, Any]: + """List ACL-visible devices over the in-process loopback channel.""" + from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback + with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop: + return {"devices": loop.list_devices()} + + +def _usb_loopback_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + """Claim a local device over loopback and read its descriptor.""" + from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback + with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop: + return _usb_descriptor_probe(loop, vendor_id, product_id, serial) + + +def _usb_remote_client() -> Any: + """Return the live WebRTC viewer's USB client or raise a clear error.""" + from je_auto_control.utils.remote_desktop.registry import registry + client = registry.webrtc_usb_client() + if client is None: + raise RuntimeError("no live WebRTC viewer with a usb channel") + return client + + +def _usb_remote_list() -> Dict[str, Any]: + """List the remote host's devices over the live WebRTC usb channel.""" + return {"devices": _usb_remote_client().list_devices()} + + +def _usb_remote_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + """Claim a remote device over the live WebRTC usb channel + probe.""" + return _usb_descriptor_probe( + _usb_remote_client(), vendor_id, product_id, serial, + ) + + def _ac_web_run(action: Optional[Dict[str, Any]] = None, **action_kwargs: Any) -> Any: """Bridge one WR_* action into the WebRunner executor (Phase 7.7). @@ -1858,6 +1988,20 @@ def __init__(self): "AC_usb_watch_stop": _usb_watch_stop, "AC_usb_recent_events": _usb_recent_events, + # USB passthrough (Phase 2) — flag, ACL, local + remote use + "AC_usb_passthrough_enable": _usb_passthrough_enable, + "AC_usb_passthrough_status": _usb_passthrough_status, + "AC_usb_acl_list": _usb_acl_list, + "AC_usb_acl_add": _usb_acl_add, + "AC_usb_acl_remove": _usb_acl_remove, + "AC_usb_acl_set_default": _usb_acl_set_default, + "AC_usb_acl_export": _usb_acl_export, + "AC_usb_acl_import": _usb_acl_import, + "AC_usb_loopback_list": _usb_loopback_list, + "AC_usb_loopback_open": _usb_loopback_open, + "AC_usb_remote_list": _usb_remote_list, + "AC_usb_remote_open": _usb_remote_open, + # System diagnostics "AC_diagnose": _diagnose, diff --git a/test/unit_test/headless/test_usb_passthrough_executor.py b/test/unit_test/headless/test_usb_passthrough_executor.py new file mode 100644 index 00000000..cc90d81a --- /dev/null +++ b/test/unit_test/headless/test_usb_passthrough_executor.py @@ -0,0 +1,95 @@ +"""Tests for the AC_usb_* passthrough executor commands.""" +import pytest + +from je_auto_control.utils.executor import action_executor as ax +from je_auto_control.utils.executor.action_executor import executor +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") + +_NEW_COMMANDS = [ + "AC_usb_passthrough_enable", "AC_usb_passthrough_status", + "AC_usb_acl_list", "AC_usb_acl_add", "AC_usb_acl_remove", + "AC_usb_acl_set_default", "AC_usb_acl_export", "AC_usb_acl_import", + "AC_usb_loopback_list", "AC_usb_loopback_open", + "AC_usb_remote_list", "AC_usb_remote_open", +] + + +@pytest.fixture() +def temp_acl(monkeypatch, tmp_path): + """Point UsbAcl at a temp path so the user's real ACL is untouched.""" + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.acl.default_acl_path", + lambda: tmp_path / "usb_acl.json", + ) + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.loopback.default_passthrough_backend", + lambda: FakeUsbBackend(devices=[_SAMPLE]), + ) + return tmp_path + + +def test_all_commands_registered(): + known = executor.known_commands() + for name in _NEW_COMMANDS: + assert name in known, name + + +def test_flag_enable_and_status(): + try: + assert ax._usb_passthrough_enable(True)["enabled"] is True + assert ax._usb_passthrough_status()["enabled"] is True + assert ax._usb_passthrough_enable(False)["enabled"] is False + finally: + ax._usb_passthrough_enable(False) + + +def test_acl_add_list_remove(temp_acl): + assert ax._usb_acl_add("1050", "0407", allow=True)["added"] is True + listed = ax._usb_acl_list() + assert listed["default"] == "deny" + assert len(listed["rules"]) == 1 + assert listed["rules"][0]["vendor_id"] == "1050" + assert ax._usb_acl_remove("1050", "0407")["removed"] is True + assert ax._usb_acl_list()["rules"] == [] + + +def test_acl_set_default(temp_acl): + assert ax._usb_acl_set_default("allow")["default"] == "allow" + assert ax._usb_acl_list()["default"] == "allow" + + +def test_acl_export_import(temp_acl): + ax._usb_acl_add("1050", "0407", allow=True) + out = temp_acl / "exp.json" + assert ax._usb_acl_export(str(out))["exported"] is True + ax._usb_acl_remove("1050", "0407") + assert ax._usb_acl_import(str(out))["imported"] == 1 + assert len(ax._usb_acl_list()["rules"]) == 1 + + +def test_loopback_list_and_open(temp_acl): + ax._usb_acl_add("1050", "0407", allow=True) + devices = ax._usb_loopback_list()["devices"] + assert [d["vendor_id"] for d in devices] == ["1050"] + opened = ax._usb_loopback_open("1050", "0407", serial="ABC123") + assert opened["ok"] is True + assert "descriptor" in opened + assert isinstance(opened["descriptor_hex"], str) + + +def test_loopback_open_denied_without_rule(temp_acl): + from je_auto_control.utils.usb.passthrough import UsbClientError + # No allow rule → default deny → open fails closed. + with pytest.raises(UsbClientError): + ax._usb_loopback_open("1050", "0407") + + +def test_remote_list_without_session_raises(): + from je_auto_control.utils.remote_desktop.registry import registry + registry._webrtc_viewer = None # noqa: SLF001 test setup + with pytest.raises(RuntimeError): + ax._usb_remote_list() From 46065c907f0160aa5491ebb27457eb10f5c8b4b2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 1 Jun 2026 01:32:25 +0800 Subject: [PATCH 06/10] Expose USB passthrough over REST API and first-class MCP tools Extract the passthrough commands into a single source of truth (passthrough/commands.py) and drive every surface from it: - REST: /usb/passthrough/{status,enable}, /usb/acl{,/add,/remove,/default}, /usb/loopback/{devices,open}, /usb/remote/{devices,open} (bearer-gated; ACL export/import deliberately omitted to avoid server-side file paths). OpenAPI spec updated. - MCP: first-class ac_usb_* tools with JSON Schemas and read-only/non- destructive annotations, so an agent calls them directly instead of via ac_execute_actions. - The AC_usb_* executor adapters now delegate to the same module. Tests cover REST (auth, 400/500 paths) and MCP (registration, schema, read-only filtering). Docs updated; import je_auto_control stays Qt-free. --- .../usb_passthrough_operator_guide.rst | 9 ++ .../usb_passthrough_operator_guide.rst | 9 ++ .../utils/executor/action_executor.py | 116 ++++---------- .../utils/mcp_server/tools/_factories.py | 108 +++++++++++++ .../utils/mcp_server/tools/_handlers.py | 61 +++++++ .../utils/rest_api/rest_handlers.py | 114 +++++++++++++ .../utils/rest_api/rest_openapi.py | 89 +++++++++++ je_auto_control/utils/rest_api/rest_server.py | 15 ++ .../utils/usb/passthrough/commands.py | 150 ++++++++++++++++++ .../headless/test_usb_passthrough_mcp.py | 72 +++++++++ .../headless/test_usb_passthrough_rest.py | 120 ++++++++++++++ 11 files changed, 775 insertions(+), 88 deletions(-) create mode 100644 je_auto_control/utils/usb/passthrough/commands.py create mode 100644 test/unit_test/headless/test_usb_passthrough_mcp.py create mode 100644 test/unit_test/headless/test_usb_passthrough_rest.py diff --git a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst index c90ee95c..46d36790 100644 --- a/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -301,6 +301,15 @@ Example JSON action:: ["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}] ] +The same operations are exposed over two more surfaces: + +* **REST API** — ``GET/POST /usb/passthrough/...``, ``/usb/acl...``, + ``/usb/loopback/...``, ``/usb/remote/...`` (bearer-token gated; see + ``/openapi.json``). ACL export/import are intentionally *not* on REST + (server-side file paths). +* **MCP** — first-class ``ac_usb_*`` tools (``ac_usb_loopback_open`` …) + with JSON Schemas, so an agent can call them directly. + What is *not* shipped yet ========================= diff --git a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst index b0e60de0..17fd7d68 100644 --- a/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst +++ b/docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst @@ -281,6 +281,15 @@ JSON action 範例:: ["AC_usb_loopback_open", {"vendor_id": "1050", "product_id": "0407"}] ] +同樣的操作另外提供兩個介面: + +* **REST API** — ``GET/POST /usb/passthrough/...``、``/usb/acl...``、 + ``/usb/loopback/...``、``/usb/remote/...``\ (需 bearer token;見 + ``/openapi.json``\ )。ACL 匯入/匯出刻意 **不** 開 REST(伺服器端 + 檔案路徑風險)。 +* **MCP** — 一級 ``ac_usb_*`` 工具(``ac_usb_loopback_open`` …),帶 + JSON Schema,agent 可直接呼叫。 + 尚未發布的部分 ============== diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index daf38b05..064d2db1 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -957,134 +957,74 @@ def _usb_recent_events(since: int = 0, ) -# --- USB passthrough (Phase 2) ------------------------------------------ - +# --- USB passthrough (Phase 2) — delegate to the shared command module -- def _usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]: - """Toggle the USB passthrough feature flag (default off).""" - from je_auto_control.utils.usb.passthrough import ( - enable_usb_passthrough, is_usb_passthrough_enabled, - ) - enable_usb_passthrough(bool(enabled)) - return {"enabled": is_usb_passthrough_enabled()} + from je_auto_control.utils.usb.passthrough import commands + return commands.passthrough_enable(enabled) def _usb_passthrough_status() -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled - return {"enabled": is_usb_passthrough_enabled()} + from je_auto_control.utils.usb.passthrough import commands + return commands.passthrough_status() def _usb_acl_list() -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import UsbAcl - acl = UsbAcl() - return { - "default": acl.default_policy, - "integrity_ok": acl.integrity_ok, - "rules": [r.to_dict() for r in acl.list_rules()], - } + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_list() def _usb_acl_add(vendor_id: str, product_id: str, serial: Optional[str] = None, allow: bool = True, prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import AclRule, UsbAcl - acl = UsbAcl() - acl.add_rule(AclRule( - vendor_id=str(vendor_id), product_id=str(product_id), - serial=(None if serial is None else str(serial)), - label=str(label), allow=bool(allow), - prompt_on_open=bool(prompt_on_open), - )) - return {"added": True, "rules": len(acl.list_rules())} + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_add( + vendor_id, product_id, serial=serial, allow=allow, + prompt_on_open=prompt_on_open, label=label, + ) def _usb_acl_remove(vendor_id: str, product_id: str, serial: Optional[str] = None) -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import UsbAcl - removed = UsbAcl().remove_rule( - vendor_id=str(vendor_id), product_id=str(product_id), - serial=(None if serial is None else str(serial)), - ) - return {"removed": removed} + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_remove(vendor_id, product_id, serial=serial) def _usb_acl_set_default(policy: str) -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import UsbAcl - acl = UsbAcl() - acl.set_default_policy(str(policy)) - return {"default": acl.default_policy} + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_set_default(policy) def _usb_acl_export(path: str) -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import UsbAcl, export_acl_to_file - from pathlib import Path - export_acl_to_file(UsbAcl(), Path(path)) - return {"exported": True, "path": str(path)} + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_export(path) def _usb_acl_import(path: str, replace: bool = False) -> Dict[str, Any]: - from je_auto_control.utils.usb.passthrough import UsbAcl, import_acl_from_file - from pathlib import Path - count = import_acl_from_file(UsbAcl(), Path(path), replace=bool(replace)) - return {"imported": count} - - -def _usb_descriptor_probe(client: Any, vendor_id: str, product_id: str, - serial: Optional[str]) -> Dict[str, Any]: - """Open a claim, read the device descriptor, close — return a summary.""" - from je_auto_control.utils.usb.passthrough import describe_descriptor - handle = client.open( - vendor_id=str(vendor_id), product_id=str(product_id), - serial=(None if serial is None else str(serial)), - ) - try: - descriptor = handle.control_transfer( - bm_request_type=0x80, b_request=0x06, w_value=0x0100, length=18, - ) - finally: - handle.close() - return { - "ok": True, "vendor_id": str(vendor_id), "product_id": str(product_id), - "descriptor_hex": descriptor.hex(), - "descriptor": describe_descriptor(descriptor), - } + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_import(path, replace=replace) def _usb_loopback_list() -> Dict[str, Any]: - """List ACL-visible devices over the in-process loopback channel.""" - from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback - with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop: - return {"devices": loop.list_devices()} + from je_auto_control.utils.usb.passthrough import commands + return commands.loopback_list() def _usb_loopback_open(vendor_id: str, product_id: str, serial: Optional[str] = None) -> Dict[str, Any]: - """Claim a local device over loopback and read its descriptor.""" - from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback - with UsbLoopback(acl=UsbAcl(), viewer_id="executor") as loop: - return _usb_descriptor_probe(loop, vendor_id, product_id, serial) - - -def _usb_remote_client() -> Any: - """Return the live WebRTC viewer's USB client or raise a clear error.""" - from je_auto_control.utils.remote_desktop.registry import registry - client = registry.webrtc_usb_client() - if client is None: - raise RuntimeError("no live WebRTC viewer with a usb channel") - return client + from je_auto_control.utils.usb.passthrough import commands + return commands.loopback_open(vendor_id, product_id, serial=serial) def _usb_remote_list() -> Dict[str, Any]: - """List the remote host's devices over the live WebRTC usb channel.""" - return {"devices": _usb_remote_client().list_devices()} + from je_auto_control.utils.usb.passthrough import commands + return commands.remote_list() def _usb_remote_open(vendor_id: str, product_id: str, serial: Optional[str] = None) -> Dict[str, Any]: - """Claim a remote device over the live WebRTC usb channel + probe.""" - return _usb_descriptor_probe( - _usb_remote_client(), vendor_id, product_id, serial, - ) + from je_auto_control.utils.usb.passthrough import commands + return commands.remote_open(vendor_id, product_id, serial=serial) def _ac_web_run(action: Optional[Dict[str, Any]] = None, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 3ddf902c..e170f72b 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1779,6 +1779,113 @@ def gamepad_tools() -> List[MCPTool]: ] +_VID_PID = { + "vendor_id": {"type": "string"}, + "product_id": {"type": "string"}, + "serial": {"type": "string"}, +} + + +def usb_passthrough_tools() -> List[MCPTool]: + """First-class MCP tools for USB passthrough (default-off feature).""" + return [ + MCPTool( + name="ac_usb_passthrough_enable", + description=("Toggle the USB passthrough feature flag. Default " + "off; must be enabled before any usb channel is " + "honoured."), + input_schema=schema({"enabled": {"type": "boolean"}}), + handler=h.usb_passthrough_enable, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_usb_passthrough_status", + description="Report whether USB passthrough is enabled.", + input_schema=schema({}), + handler=h.usb_passthrough_status, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_usb_acl_list", + description=("List USB ACL rules plus the default policy and the " + "HMAC integrity state."), + input_schema=schema({}), + handler=h.usb_acl_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_usb_acl_add", + description=("Add a per-device USB ACL rule (allow/deny, optional " + "prompt-on-open). vendor_id/product_id are 4 hex " + "digits, e.g. 1050/0407."), + input_schema=schema({ + **_VID_PID, + "allow": {"type": "boolean"}, + "prompt_on_open": {"type": "boolean"}, + "label": {"type": "string"}, + }, required=["vendor_id", "product_id"]), + handler=h.usb_acl_add, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_usb_acl_remove", + description="Remove a per-device USB ACL rule.", + input_schema=schema( + dict(_VID_PID), required=["vendor_id", "product_id"], + ), + handler=h.usb_acl_remove, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_usb_acl_set_default", + description="Set the USB ACL default policy (allow | deny).", + input_schema=schema({ + "policy": {"type": "string", "enum": ["allow", "deny"]}, + }, required=["policy"]), + handler=h.usb_acl_set_default, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_usb_loopback_list", + description=("List ACL-visible USB devices on this machine over " + "the in-process loopback channel."), + input_schema=schema({}), + handler=h.usb_loopback_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_usb_loopback_open", + description=("Claim a local USB device over loopback and read its " + "device descriptor (a full protocol-stack probe). " + "Fails closed if the ACL denies it."), + input_schema=schema( + dict(_VID_PID), required=["vendor_id", "product_id"], + ), + handler=h.usb_loopback_open, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_usb_remote_list", + description=("List the remote host's USB devices over the live " + "WebRTC usb channel. Requires a connected WebRTC " + "viewer."), + input_schema=schema({}), + handler=h.usb_remote_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_usb_remote_open", + description=("Claim a remote USB device over the live WebRTC usb " + "channel and read its descriptor."), + input_schema=schema( + dict(_VID_PID), required=["vendor_id", "product_id"], + ), + handler=h.usb_remote_open, + annotations=NON_DESTRUCTIVE, + ), + ] + + ALL_FACTORIES = ( mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools, window_tools, system_tools, recording_tools, drag_and_send_tools, @@ -1789,4 +1896,5 @@ def gamepad_tools() -> List[MCPTool]: redaction_tools, android_widget_tools, ios_tools, webrunner_tools, scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, + usb_passthrough_tools, ) diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 19653054..13eaec7b 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1535,3 +1535,64 @@ def gamepad_reset() -> Dict[str, Any]: from je_auto_control.utils.gamepad import default_gamepad default_gamepad().reset() return {"reset": True} + + +# --- USB passthrough — delegate to the shared command module ------------- + + +def usb_passthrough_enable(enabled: bool = True) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.passthrough_enable(enabled) + + +def usb_passthrough_status() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.passthrough_status() + + +def usb_acl_list() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_list() + + +def usb_acl_add(vendor_id: str, product_id: str, + serial: Optional[str] = None, allow: bool = True, + prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_add( + vendor_id, product_id, serial=serial, allow=allow, + prompt_on_open=prompt_on_open, label=label, + ) + + +def usb_acl_remove(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_remove(vendor_id, product_id, serial=serial) + + +def usb_acl_set_default(policy: str) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.acl_set_default(policy) + + +def usb_loopback_list() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.loopback_list() + + +def usb_loopback_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.loopback_open(vendor_id, product_id, serial=serial) + + +def usb_remote_list() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.remote_list() + + +def usb_remote_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import commands + return commands.remote_open(vendor_id, product_id, serial=serial) diff --git a/je_auto_control/utils/rest_api/rest_handlers.py b/je_auto_control/utils/rest_api/rest_handlers.py index 99e1e382..708f2b7b 100644 --- a/je_auto_control/utils/rest_api/rest_handlers.py +++ b/je_auto_control/utils/rest_api/rest_handlers.py @@ -296,6 +296,115 @@ def handle_usb_events(ctx: RouteContext) -> HandlerResult: } +def _usb_body(ctx: RouteContext) -> Dict[str, Any]: + return ctx.body if isinstance(ctx.body, dict) else {} + + +def _usb_vid_pid(body: Dict[str, Any]) -> Tuple[str, str, Optional[str]]: + vendor_id = body.get("vendor_id") + product_id = body.get("product_id") + if not vendor_id or not product_id: + raise ValueError("vendor_id and product_id are required") + serial = body.get("serial") + return str(vendor_id), str(product_id), ( + None if serial is None else str(serial) + ) + + +def _usb_command(thunk) -> HandlerResult: + """Run a passthrough command thunk and map errors to status codes.""" + try: + return 200, thunk() + except ValueError as error: + return 400, {"error": str(error)} + except Exception as error: # noqa: BLE001 # pylint: disable=broad-except # reason: REST boundary returns the domain error (ACL deny / no device); never raw secrets + autocontrol_logger.error("rest usb passthrough failed: %r", error) + return 500, {"error": str(error)} + + +def handle_usb_passthrough_status(_ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + return _usb_command(commands.passthrough_status) + + +def handle_usb_passthrough_enable(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + enabled = bool(_usb_body(ctx).get("enabled", True)) + return _usb_command(lambda: commands.passthrough_enable(enabled)) + + +def handle_usb_acl_list(_ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + return _usb_command(commands.acl_list) + + +def handle_usb_acl_add(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + body = _usb_body(ctx) + try: + vendor_id, product_id, serial = _usb_vid_pid(body) + except ValueError as error: + return 400, {"error": str(error)} + return _usb_command(lambda: commands.acl_add( + vendor_id, product_id, serial=serial, + allow=bool(body.get("allow", True)), + prompt_on_open=bool(body.get("prompt_on_open", False)), + label=str(body.get("label", "")), + )) + + +def handle_usb_acl_remove(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + body = _usb_body(ctx) + try: + vendor_id, product_id, serial = _usb_vid_pid(body) + except ValueError as error: + return 400, {"error": str(error)} + return _usb_command( + lambda: commands.acl_remove(vendor_id, product_id, serial=serial), + ) + + +def handle_usb_acl_default(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + policy = str(_usb_body(ctx).get("policy", "")) + if policy not in ("allow", "deny"): + return 400, {"error": "policy must be 'allow' or 'deny'"} + return _usb_command(lambda: commands.acl_set_default(policy)) + + +def handle_usb_loopback_devices(_ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + return _usb_command(commands.loopback_list) + + +def handle_usb_loopback_open(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + try: + vendor_id, product_id, serial = _usb_vid_pid(_usb_body(ctx)) + except ValueError as error: + return 400, {"error": str(error)} + return _usb_command( + lambda: commands.loopback_open(vendor_id, product_id, serial=serial), + ) + + +def handle_usb_remote_devices(_ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + return _usb_command(commands.remote_list) + + +def handle_usb_remote_open(ctx: RouteContext) -> HandlerResult: + from je_auto_control.utils.usb.passthrough import commands + try: + vendor_id, product_id, serial = _usb_vid_pid(_usb_body(ctx)) + except ValueError as error: + return 400, {"error": str(error)} + return _usb_command( + lambda: commands.remote_open(vendor_id, product_id, serial=serial), + ) + + def handle_inspector_summary(_ctx: RouteContext) -> HandlerResult: try: from je_auto_control.utils.remote_desktop.webrtc_inspector import ( @@ -333,4 +442,9 @@ def handle_audit_verify(_ctx: RouteContext) -> HandlerResult: "handle_inspector_recent", "handle_inspector_summary", "handle_usb_devices", "handle_usb_events", "handle_diagnose", "handle_openapi", "handle_config_export", "handle_config_import", + "handle_usb_passthrough_status", "handle_usb_passthrough_enable", + "handle_usb_acl_list", "handle_usb_acl_add", "handle_usb_acl_remove", + "handle_usb_acl_default", "handle_usb_loopback_devices", + "handle_usb_loopback_open", "handle_usb_remote_devices", + "handle_usb_remote_open", ] diff --git a/je_auto_control/utils/rest_api/rest_openapi.py b/je_auto_control/utils/rest_api/rest_openapi.py index fba977fa..f6747507 100644 --- a/je_auto_control/utils/rest_api/rest_openapi.py +++ b/je_auto_control/utils/rest_api/rest_openapi.py @@ -111,6 +111,95 @@ "schema": {"type": "integer"}}, ], }, + ("GET", "/usb/passthrough/status"): { + "summary": "Report whether USB passthrough is enabled.", + "tag": "usb", + }, + ("POST", "/usb/passthrough/enable"): { + "summary": "Toggle the USB passthrough feature flag (default off).", + "tag": "usb", + "request_body": { + "type": "object", + "properties": {"enabled": {"type": "boolean"}}, + }, + }, + ("GET", "/usb/acl"): { + "summary": "List USB ACL rules, default policy, and integrity state.", + "tag": "usb", + }, + ("POST", "/usb/acl/add"): { + "summary": "Add a per-device USB ACL rule.", + "tag": "usb", + "request_body": { + "type": "object", + "required": ["vendor_id", "product_id"], + "properties": { + "vendor_id": {"type": "string"}, + "product_id": {"type": "string"}, + "serial": {"type": "string"}, + "allow": {"type": "boolean"}, + "prompt_on_open": {"type": "boolean"}, + "label": {"type": "string"}, + }, + }, + }, + ("POST", "/usb/acl/remove"): { + "summary": "Remove a per-device USB ACL rule.", + "tag": "usb", + "request_body": { + "type": "object", + "required": ["vendor_id", "product_id"], + "properties": { + "vendor_id": {"type": "string"}, + "product_id": {"type": "string"}, + "serial": {"type": "string"}, + }, + }, + }, + ("POST", "/usb/acl/default"): { + "summary": "Set the USB ACL default policy (allow | deny).", + "tag": "usb", + "request_body": { + "type": "object", + "required": ["policy"], + "properties": {"policy": {"type": "string", + "enum": ["allow", "deny"]}}, + }, + }, + ("GET", "/usb/loopback/devices"): { + "summary": "List ACL-visible devices over the loopback channel.", + "tag": "usb", + }, + ("POST", "/usb/loopback/open"): { + "summary": "Claim a local device over loopback; read its descriptor.", + "tag": "usb", + "request_body": { + "type": "object", + "required": ["vendor_id", "product_id"], + "properties": { + "vendor_id": {"type": "string"}, + "product_id": {"type": "string"}, + "serial": {"type": "string"}, + }, + }, + }, + ("GET", "/usb/remote/devices"): { + "summary": "List the remote host's devices over the live WebRTC channel.", + "tag": "usb", + }, + ("POST", "/usb/remote/open"): { + "summary": "Claim a remote device over the live WebRTC channel; probe it.", + "tag": "usb", + "request_body": { + "type": "object", + "required": ["vendor_id", "product_id"], + "properties": { + "vendor_id": {"type": "string"}, + "product_id": {"type": "string"}, + "serial": {"type": "string"}, + }, + }, + }, ("GET", "/diagnose"): { "summary": "Run subsystem diagnostics; return per-check results.", "tag": "system", diff --git a/je_auto_control/utils/rest_api/rest_server.py b/je_auto_control/utils/rest_api/rest_server.py index 98962395..9444b340 100644 --- a/je_auto_control/utils/rest_api/rest_server.py +++ b/je_auto_control/utils/rest_api/rest_server.py @@ -29,6 +29,11 @@ handle_inspector_summary, handle_jobs, handle_mouse_position, handle_openapi, handle_remote_sessions, handle_screen_size, handle_screenshot, handle_usb_devices, handle_usb_events, handle_windows, + handle_usb_passthrough_status, handle_usb_passthrough_enable, + handle_usb_acl_list, handle_usb_acl_add, handle_usb_acl_remove, + handle_usb_acl_default, handle_usb_loopback_devices, + handle_usb_loopback_open, handle_usb_remote_devices, + handle_usb_remote_open, ) from je_auto_control.utils.rest_api.rest_metrics import RestMetrics @@ -51,6 +56,10 @@ "/inspector/summary": handle_inspector_summary, "/usb/devices": handle_usb_devices, "/usb/events": handle_usb_events, + "/usb/passthrough/status": handle_usb_passthrough_status, + "/usb/acl": handle_usb_acl_list, + "/usb/loopback/devices": handle_usb_loopback_devices, + "/usb/remote/devices": handle_usb_remote_devices, "/diagnose": handle_diagnose, "/openapi.json": handle_openapi, } @@ -60,6 +69,12 @@ "/execute_file": handle_execute_file, "/config/export": handle_config_export, "/config/import": handle_config_import, + "/usb/passthrough/enable": handle_usb_passthrough_enable, + "/usb/acl/add": handle_usb_acl_add, + "/usb/acl/remove": handle_usb_acl_remove, + "/usb/acl/default": handle_usb_acl_default, + "/usb/loopback/open": handle_usb_loopback_open, + "/usb/remote/open": handle_usb_remote_open, } # /health is intentionally unauthenticated so probes / load balancers diff --git a/je_auto_control/utils/usb/passthrough/commands.py b/je_auto_control/utils/usb/passthrough/commands.py new file mode 100644 index 00000000..24f09aad --- /dev/null +++ b/je_auto_control/utils/usb/passthrough/commands.py @@ -0,0 +1,150 @@ +"""Headless USB passthrough commands — one source of truth. + +These plain functions implement the feature flag, ACL management, and +local/remote claim+probe operations. They return JSON-able dicts and +contain no transport glue, so the executor (``AC_usb_*``), the REST API +(``/usb/...``), the socket server, and the scheduler all call the same +code. No Qt, no aiortc at import time. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +# Standard USB GET_DESCRIPTOR (device) — a harmless liveness probe. +_DESC_REQUEST_TYPE = 0x80 +_DESC_REQUEST = 0x06 +_DESC_VALUE = 0x0100 +_DESC_LENGTH = 18 + + +def passthrough_enable(enabled: bool = True) -> Dict[str, Any]: + """Toggle the USB passthrough feature flag (default off).""" + from je_auto_control.utils.usb.passthrough import ( + enable_usb_passthrough, is_usb_passthrough_enabled, + ) + enable_usb_passthrough(bool(enabled)) + return {"enabled": is_usb_passthrough_enabled()} + + +def passthrough_status() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import is_usb_passthrough_enabled + return {"enabled": is_usb_passthrough_enabled()} + + +def acl_list() -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + acl = UsbAcl() + return { + "default": acl.default_policy, + "integrity_ok": acl.integrity_ok, + "rules": [r.to_dict() for r in acl.list_rules()], + } + + +def acl_add(vendor_id: str, product_id: str, + serial: Optional[str] = None, allow: bool = True, + prompt_on_open: bool = False, label: str = "") -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import AclRule, UsbAcl + acl = UsbAcl() + acl.add_rule(AclRule( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + label=str(label), allow=bool(allow), + prompt_on_open=bool(prompt_on_open), + )) + return {"added": True, "rules": len(acl.list_rules())} + + +def acl_remove(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + removed = UsbAcl().remove_rule( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + ) + return {"removed": removed} + + +def acl_set_default(policy: str) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl + acl = UsbAcl() + acl.set_default_policy(str(policy)) + return {"default": acl.default_policy} + + +def acl_export(path: str) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl, export_acl_to_file + export_acl_to_file(UsbAcl(), Path(path)) + return {"exported": True, "path": str(path)} + + +def acl_import(path: str, replace: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.usb.passthrough import UsbAcl, import_acl_from_file + count = import_acl_from_file(UsbAcl(), Path(path), replace=bool(replace)) + return {"imported": count} + + +def _descriptor_probe(client: Any, vendor_id: str, product_id: str, + serial: Optional[str]) -> Dict[str, Any]: + """Open a claim, read the device descriptor, close — return a summary.""" + from je_auto_control.utils.usb.passthrough import describe_descriptor + handle = client.open( + vendor_id=str(vendor_id), product_id=str(product_id), + serial=(None if serial is None else str(serial)), + ) + try: + descriptor = handle.control_transfer( + bm_request_type=_DESC_REQUEST_TYPE, b_request=_DESC_REQUEST, + w_value=_DESC_VALUE, length=_DESC_LENGTH, + ) + finally: + handle.close() + return { + "ok": True, "vendor_id": str(vendor_id), "product_id": str(product_id), + "descriptor_hex": descriptor.hex(), + "descriptor": describe_descriptor(descriptor), + } + + +def loopback_list() -> Dict[str, Any]: + """List ACL-visible devices over the in-process loopback channel.""" + from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback + with UsbLoopback(acl=UsbAcl(), viewer_id="command") as loop: + return {"devices": loop.list_devices()} + + +def loopback_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + """Claim a local device over loopback and read its descriptor.""" + from je_auto_control.utils.usb.passthrough import UsbAcl, UsbLoopback + with UsbLoopback(acl=UsbAcl(), viewer_id="command") as loop: + return _descriptor_probe(loop, vendor_id, product_id, serial) + + +def _remote_client() -> Any: + """Return the live WebRTC viewer's USB client or raise a clear error.""" + from je_auto_control.utils.remote_desktop.registry import registry + client = registry.webrtc_usb_client() + if client is None: + raise RuntimeError("no live WebRTC viewer with a usb channel") + return client + + +def remote_list() -> Dict[str, Any]: + """List the remote host's devices over the live WebRTC usb channel.""" + return {"devices": _remote_client().list_devices()} + + +def remote_open(vendor_id: str, product_id: str, + serial: Optional[str] = None) -> Dict[str, Any]: + """Claim a remote device over the live WebRTC usb channel + probe.""" + return _descriptor_probe(_remote_client(), vendor_id, product_id, serial) + + +__all__ = [ + "passthrough_enable", "passthrough_status", + "acl_list", "acl_add", "acl_remove", "acl_set_default", + "acl_export", "acl_import", + "loopback_list", "loopback_open", "remote_list", "remote_open", +] diff --git a/test/unit_test/headless/test_usb_passthrough_mcp.py b/test/unit_test/headless/test_usb_passthrough_mcp.py new file mode 100644 index 00000000..05ad5f02 --- /dev/null +++ b/test/unit_test/headless/test_usb_passthrough_mcp.py @@ -0,0 +1,72 @@ +"""Tests for the first-class ac_usb_* MCP passthrough tools.""" +import pytest + +from je_auto_control.utils.mcp_server.tools import build_default_tool_registry +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") + +_USB_TOOLS = { + "ac_usb_passthrough_enable", "ac_usb_passthrough_status", + "ac_usb_acl_list", "ac_usb_acl_add", "ac_usb_acl_remove", + "ac_usb_acl_set_default", "ac_usb_loopback_list", "ac_usb_loopback_open", + "ac_usb_remote_list", "ac_usb_remote_open", +} + + +@pytest.fixture(autouse=True) +def isolated_usb(monkeypatch, tmp_path): + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.acl.default_acl_path", + lambda: tmp_path / "usb_acl.json", + ) + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.loopback.default_passthrough_backend", + lambda: FakeUsbBackend(devices=[_SAMPLE]), + ) + + +def _by_name(read_only=False): + return {t.name: t for t in build_default_tool_registry( + read_only=read_only, aliases=False, + )} + + +def test_all_usb_tools_registered(): + names = set(_by_name()) + assert _USB_TOOLS <= names + + +def test_usb_tools_have_valid_schema_and_handler(): + tools = _by_name() + for name in _USB_TOOLS: + tool = tools[name] + assert isinstance(tool.input_schema, dict) + assert callable(tool.handler) + + +def test_acl_add_then_list_via_handlers(): + tools = _by_name() + added = tools["ac_usb_acl_add"].handler(vendor_id="1050", product_id="0407") + assert added["added"] is True + listed = tools["ac_usb_acl_list"].handler() + assert any(r["vendor_id"] == "1050" for r in listed["rules"]) + + +def test_loopback_list_via_handler(): + tools = _by_name() + tools["ac_usb_acl_add"].handler(vendor_id="1050", product_id="0407") + devices = tools["ac_usb_loopback_list"].handler()["devices"] + assert [d["vendor_id"] for d in devices] == ["1050"] + + +def test_read_only_registry_keeps_reads_drops_writes(): + ro = _by_name(read_only=True) + # Reads survive the read-only filter… + assert "ac_usb_acl_list" in ro + assert "ac_usb_passthrough_status" in ro + # …state-changing tools are filtered out. + assert "ac_usb_acl_add" not in ro + assert "ac_usb_passthrough_enable" not in ro diff --git a/test/unit_test/headless/test_usb_passthrough_rest.py b/test/unit_test/headless/test_usb_passthrough_rest.py new file mode 100644 index 00000000..f4587766 --- /dev/null +++ b/test/unit_test/headless/test_usb_passthrough_rest.py @@ -0,0 +1,120 @@ +"""Tests for the /usb/passthrough, /usb/acl, /usb/loopback, /usb/remote REST API.""" +import json +import urllib.error +import urllib.request + +import pytest + +from je_auto_control.utils.rest_api.rest_server import RestApiServer +from je_auto_control.utils.usb.passthrough.backend import ( + BackendDevice, FakeUsbBackend, +) + +_TEST_SCHEME = "http" # NOSONAR localhost-only ephemeral test server +_SAMPLE = BackendDevice(vendor_id="1050", product_id="0407", serial="ABC123") + + +@pytest.fixture(autouse=True) +def isolated_usb(monkeypatch, tmp_path): + """Temp ACL + fake backend so REST calls never touch real hardware/ACL.""" + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.acl.default_acl_path", + lambda: tmp_path / "usb_acl.json", + ) + monkeypatch.setattr( + "je_auto_control.utils.usb.passthrough.loopback.default_passthrough_backend", + lambda: FakeUsbBackend(devices=[_SAMPLE]), + ) + + +@pytest.fixture() +def server(): + s = RestApiServer(host="127.0.0.1", port=0, enable_audit=False) + s.start() + yield s + s.stop(timeout=1.0) + + +def _get(server, path, *, token=None): + host, port = server.address + headers = {"Authorization": f"Bearer {token}"} if token else {} + req = urllib.request.Request( + f"{_TEST_SCHEME}://{host}:{port}{path}", headers=headers, method="GET", + ) + with urllib.request.urlopen(req, timeout=3) as response: # nosec B310 + return response.status, json.loads(response.read().decode("utf-8")) + + +def _post(server, path, body, *, token=None): + host, port = server.address + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{_TEST_SCHEME}://{host}:{port}{path}", data=data, + headers=headers, method="POST", + ) + with urllib.request.urlopen(req, timeout=3) as response: # nosec B310 + return response.status, json.loads(response.read().decode("utf-8")) + + +def test_passthrough_status_get(server): + status, payload = _get(server, "/usb/passthrough/status", token=server.token) + assert status == 200 + assert "enabled" in payload + + +def test_acl_add_then_list(server): + status, payload = _post(server, "/usb/acl/add", { + "vendor_id": "1050", "product_id": "0407", "allow": True, + }, token=server.token) + assert status == 200 and payload["added"] is True + status, listed = _get(server, "/usb/acl", token=server.token) + assert status == 200 + assert listed["default"] == "deny" + assert any(r["vendor_id"] == "1050" for r in listed["rules"]) + + +def test_acl_add_missing_params_is_400(server): + with pytest.raises(urllib.error.HTTPError) as exc: + _post(server, "/usb/acl/add", {"vendor_id": "1050"}, token=server.token) + assert exc.value.code == 400 + + +def test_acl_default_validates_policy(server): + with pytest.raises(urllib.error.HTTPError) as exc: + _post(server, "/usb/acl/default", {"policy": "maybe"}, token=server.token) + assert exc.value.code == 400 + status, payload = _post(server, "/usb/acl/default", {"policy": "allow"}, + token=server.token) + assert status == 200 and payload["default"] == "allow" + + +def test_loopback_list_and_open(server): + _post(server, "/usb/acl/add", {"vendor_id": "1050", "product_id": "0407"}, + token=server.token) + status, payload = _get(server, "/usb/loopback/devices", token=server.token) + assert status == 200 + assert [d["vendor_id"] for d in payload["devices"]] == ["1050"] + status, opened = _post(server, "/usb/loopback/open", { + "vendor_id": "1050", "product_id": "0407", "serial": "ABC123", + }, token=server.token) + assert status == 200 and opened["ok"] is True + assert "descriptor" in opened + + +def test_remote_devices_without_session_is_500(server): + from je_auto_control.utils.remote_desktop.registry import registry + registry._webrtc_viewer = None # noqa: SLF001 + with pytest.raises(urllib.error.HTTPError) as exc: + _get(server, "/usb/remote/devices", token=server.token) + assert exc.value.code == 500 + + +def test_passthrough_endpoints_reject_anonymous(server): + for path in ("/usb/passthrough/status", "/usb/acl", + "/usb/loopback/devices", "/usb/remote/devices"): + with pytest.raises(urllib.error.HTTPError) as exc: + _get(server, path) + assert exc.value.code == 401, path From aa192dd86bf93e20dcbb862040cf9497f8dd89f5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 1 Jun 2026 01:38:46 +0800 Subject: [PATCH 07/10] Update READMEs to reflect completed USB passthrough subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh the EN / zh-TW / zh-CN feature bullets, architecture diagram node, and directory tree: passthrough is no longer "deferred" — all backends, resolved open questions (HMAC ACL, LIST, fragmentation, resume), the in-process loopback, WebRTC wiring, AnyDesk GUI panel, and the five driving surfaces (GUI / AC_usb_* / REST / MCP / Python) are documented. --- README.md | 8 ++++---- README/README_zh-CN.md | 8 ++++---- README/README_zh-TW.md | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c734b341..dbf32021 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,12 @@ sense) a Qt GUI tab. Full reference page: - **Multi-Host Admin Console** — register N AutoControl REST endpoints in one address book, poll them in parallel for health/sessions/jobs, broadcast actions to all of them. Persisted to `~/.je_auto_control/admin_hosts.json` (mode 0600 on POSIX). Bad-token hosts surface as unhealthy with the actual HTTP error - **Tamper-Evident Audit Log** — SQLite events table with SHA-256 hash chain (`prev_hash` + `row_hash` per row); editing any past row breaks the chain. `verify_chain()` walks rows top-down and reports the first broken link. Legacy tables get backfilled at startup ("trust on first use") - **WebRTC Packet Inspector** — process-global rolling window of `StatsSnapshot` samples (default 600 / ~10 min @ 1Hz) fed by the existing WebRTC stats pollers. Per-metric `last/min/max/avg/p95` for RTT, FPS, bitrate, packet loss, jitter -- **USB Device Enumeration** — read-only cross-platform device listing. Tries pyusb (libusb) first; falls back to platform-specific (Windows `Get-PnpDevice`, macOS `system_profiler`, Linux `/sys/bus/usb/devices`). Phase 2 (passthrough) intentionally deferred pending design review +- **USB Device Enumeration** — read-only cross-platform device listing. Tries pyusb (libusb) first; falls back to platform-specific (Windows `Get-PnpDevice`, macOS `system_profiler`, Linux `/sys/bus/usb/devices`). Phase 2 passthrough builds on this (see below) - **System Diagnostics** — single-command "is everything OK?" probe across platform, optional deps, executor command count, audit chain, screenshot, mouse, disk space, REST registry. CLI exits 0 if all green / 1 otherwise; REST `/diagnose`; severity-tagged GUI tab - **USB Hotplug Events** — polling-based hotplug watcher (`UsbHotplugWatcher`) with bounded ring buffer + sequence-numbered events; `GET /usb/events?since=N` lets late subscribers catch up. GUI auto-refresh toggle on the USB tab. - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json` (auth-gated, generated from the live route table) + `GET /docs` (browser Swagger UI with bearer token bar). Drift test in CI catches new routes added without metadata. - **Configuration Bundle** — single-file JSON export/import of user config (admin hosts, address book, trusted viewers, known hosts, host service, IDs). Atomic write with `.bak.` backups; CLI `python -m je_auto_control.utils.config_bundle export|import`; `POST /config/{export,import}`; GUI buttons on the REST API tab. -- **USB Passthrough (experimental, opt-in)** — wire-level protocol over a WebRTC `usb` DataChannel (10 opcodes, CREDIT-based flow control, 16 KiB payload cap). Host-side `UsbPassthroughSession` end-to-end on the Linux libusb backend; Windows `WinUSB` backend with full ctypes wiring (hardware-unverified); macOS `IOKit` skeleton. Viewer-side blocking client (`UsbPassthroughClient` → `ClientHandle.control_transfer / bulk_transfer / interrupt_transfer`). Persistent ACL (`~/.je_auto_control/usb_acl.json`, default deny, mode 0600) with host-side prompt QDialog and tamper-evident audit-log integration. Default off — opt-in via `enable_usb_passthrough(True)` or `JE_AUTOCONTROL_USB_PASSTHROUGH=1`. Phase 2e external security review checklist included; default-on requires sign-off. +- **USB Passthrough (opt-in)** — let a remote viewer use a USB device physically attached to the host, over a WebRTC `usb` DataChannel. Wire-level protocol (11 opcodes incl. `RESUME`, CREDIT-based flow control, 16 KiB payload cap with EOF fragmentation for oversize transfers). All eight original open questions resolved: reliable-ordered channel, LIST-over-channel (ACL-filtered), per-claim credits, Linux kernel-driver detach/reattach, and ACL **HMAC-SHA256 integrity** (fail-closed on tamper; pluggable key — Windows DPAPI or passphrase vault). **Backends:** `LibusbBackend` (production), `WinusbBackend` (ctypes) and `IokitBackend` (native IOKit enumeration + libusb transfers) — Windows/macOS *hardware-unverified*; `default_passthrough_backend()` picks per-OS. Viewer-side blocking client (`control/bulk/interrupt_transfer`, `list_devices`, `resume`); in-process `UsbLoopback` so one machine can share + use a device through the full stack. **Wired into WebRTC** host/viewer (`viewer.usb_client()`) plus claim **resume tokens** that survive a reconnect. Persistent ACL (default deny, mode 0600) with host-side prompt dialog, abuse **rate-limit / lockout**, and tamper-evident audit integration. Five driving surfaces: AnyDesk-style **GUI panel** (share + ACL allow/block + local/remote use), `AC_usb_*` executor commands (JSON / socket / scheduler), **REST** `/usb/...`, first-class **MCP** `ac_usb_*` tools, and the Python API. Default off — opt-in via `enable_usb_passthrough(True)` or `JE_AUTOCONTROL_USB_PASSTHROUGH=1`; default-on still pending Phase 2e external security sign-off + real-hardware verification. - **Observability (Prometheus + OpenTelemetry)** — stdlib-only `Counter` / `Gauge` / `Histogram` registry with a tiny built-in HTTP exporter on `/metrics`, plus an OpenTelemetry-compatible tracer that upgrades to real OTel spans when the SDK is installed. The executor and agent loop emit `autocontrol_action_calls_total{action,outcome}`, `autocontrol_action_duration_seconds`, and `autocontrol_agent_steps_total{tool,outcome}` automatically — drop the URL into a Prometheus scrape config and you have a Grafana dashboard with zero per-script wiring. --- @@ -217,7 +217,7 @@ flowchart LR subgraph USB["USB"] direction TB UsbEnum["usb/
list + hotplug events"] - UsbPass["usb/passthrough/
session · client · ACL ·
libusb · WinUSB · IOKit"] + UsbPass["usb/passthrough/
session · client · ACL(HMAC) ·
libusb · WinUSB · IOKit ·
loopback · webrtc channel · commands"] end subgraph Remote["Remote Desktop (utils/remote_desktop/)"] @@ -317,7 +317,7 @@ je_auto_control/ ├── admin/ # Multi-host AdminConsoleClient (poll + broadcast) ├── diagnostics/ # System self-test runner + CLI ├── config_bundle/ # Single-file user-config export / import - ├── usb/ # Cross-platform enumeration, hotplug events, passthrough/{protocol, session, viewer client, ACL, libusb / WinUSB / IOKit} + ├── usb/ # Cross-platform enumeration, hotplug events, passthrough/{protocol, session, viewer client, loopback, webrtc channel, ACL+HMAC, descriptor, key providers, commands, libusb / WinUSB / IOKit} ├── remote_desktop/ # WebRTC host + viewer, signalling, multi-viewer, file/clipboard/audio sync, audit log (hash chain), trust list, TURN config, mDNS discovery, WebRTC stats inspector ├── plugin_loader/ # Dynamic AC_* plugin discovery ├── socket_server/ # TCP socket server for remote automation diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 7734d306..4952bce7 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -141,12 +141,12 @@ - **多主机管理控制台** — 在一份通讯录中注册 N 个远程 AutoControl REST 端点,并行轮询 health/sessions/jobs,把同一份动作清单广播给全部主机。储存于 `~/.je_auto_control/admin_hosts.json`(POSIX 上模式 0600)。Token 错误的主机会以实际 HTTP 错误显示为不健康 - **可检测篡改的审计日志** — SQLite events 表加上 SHA-256 哈希链(每条记录含 `prev_hash` + `row_hash`);修改任何过去记录都会打断哈希链。`verify_chain()` 自顶向下走访并报告第一个断点。既有数据表会在启动时回填("初次使用即信任") - **WebRTC 包监测** — 由既有 WebRTC stats 轮询喂入的进程级 `StatsSnapshot` 滚动窗口(默认 600 条 / 1 Hz 约 10 分钟)。对 RTT、FPS、bitrate、丢包率、jitter 各回 `last/min/max/avg/p95` -- **USB 设备列举** — 只读的跨平台 USB 设备列举。优先尝试 pyusb(libusb);若无则退回平台特定命令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二阶段(passthrough)刻意延后待设计审查 +- **USB 设备列举** — 只读的跨平台 USB 设备列举。优先尝试 pyusb(libusb);若无则退回平台特定命令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二阶段 passthrough 构建于此(见下) - **系统诊断** — 一键"目前正常吗?"探测:平台、可选依赖包、executor 命令数、审计链、截图、鼠标、磁盘空间、REST registry。CLI 全绿 exit 0/否则 1;REST `/diagnose`;按严重度上色的 GUI 分页 - **USB Hotplug 事件** — 轮询式 hotplug 监测(`UsbHotplugWatcher`),含 bounded ring buffer 与带序号的事件;`GET /usb/events?since=N` 让晚加入的订阅者补上进度。USB 分页有自动刷新切换钮。 - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json`(auth-gated,从活的路由表生成)+ `GET /docs`(浏览器版 Swagger UI 含 bearer token 栏)。CI 上有 drift 测试,新加路由忘记写 metadata 会被拦下。 - **配置包导出/导入** — 单一 JSON 文件,导出/导入用户配置(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子写入加 `.bak.<时间戳>` 备份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分页有按钮。 -- **USB Passthrough(实验性、需主动启用)** — wire-level 协议走 WebRTC `usb` DataChannel(10 个 opcode、CREDIT 流量控制、16 KiB payload 上限)。Host 端 `UsbPassthroughSession` 在 Linux libusb backend 上端到端运行;Windows `WinUSB` backend 含完整 ctypes 接线(硬件未验证);macOS `IOKit` 为骨架。Viewer 端阻塞式 client(`UsbPassthroughClient` → `ClientHandle.control_transfer / bulk_transfer / interrupt_transfer`)。持久化 ACL(`~/.je_auto_control/usb_acl.json`,默认 deny,POSIX mode 0600),含 host 端 prompt QDialog 与可检测篡改审计日志整合。默认 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 启用。Phase 2e 外部安全审查清单已附;默认启用前需要签核。 +- **USB Passthrough(需主动启用)** — 让远端 viewer 使用实体插在 host 上的 USB 设备,走 WebRTC `usb` DataChannel。Wire-level 协议(11 个 opcode 含 `RESUME`、CREDIT 流量控制、16 KiB payload 上限,超量传输以 EOF 分片)。八个原始未决问题全部解决:可靠有序 channel、LIST 走 channel(ACL 过滤)、per-claim credit、Linux kernel driver detach/reattach、ACL **HMAC-SHA256 完整性**(篡改 fail-closed;密钥可插拔 — Windows DPAPI 或 passphrase vault)。**Backend:**`LibusbBackend`(production)、`WinusbBackend`(ctypes)、`IokitBackend`(原生 IOKit 列举 + libusb 传输)— Windows/macOS *硬件未验证*;`default_passthrough_backend()` 依 OS 自动挑。Viewer 端阻塞式 client(`control/bulk/interrupt_transfer`、`list_devices`、`resume`);in-process `UsbLoopback` 让同机可走完整堆栈 share+use。**已接入 WebRTC** host/viewer(`viewer.usb_client()`)并含断线可续租的 **resume token**。持久化 ACL(默认 deny、mode 0600),含 host 端 prompt 对话框、滥用 **rate-limit / lockout** 与可检测篡改审计整合。五个驱动面:AnyDesk 风 **GUI 面板**(分享 + ACL 允许/封锁 + 本机/远端使用)、`AC_usb_*` executor 命令(JSON / socket / 调度器)、**REST** `/usb/...`、一级 **MCP** `ac_usb_*` 工具、以及 Python API。默认 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 启用;默认启用仍待 Phase 2e 外部安全签核 + 实机硬件验证。 --- @@ -213,7 +213,7 @@ flowchart LR subgraph USB["USB"] direction TB UsbEnum["usb/
列举 + hotplug"] - UsbPass["usb/passthrough/
session · client · ACL ·
libusb · WinUSB · IOKit"] + UsbPass["usb/passthrough/
session · client · ACL(HMAC) ·
libusb · WinUSB · IOKit ·
loopback · webrtc channel · commands"] end subgraph Remote["远程桌面 (utils/remote_desktop/)"] @@ -313,7 +313,7 @@ je_auto_control/ ├── admin/ # 多主机 AdminConsoleClient(轮询 + 广播) ├── diagnostics/ # 系统自我诊断 + CLI ├── config_bundle/ # 单文件用户配置导出/导入 - ├── usb/ # 跨平台列举、hotplug 事件、passthrough/{protocol, session, viewer client, ACL, libusb / WinUSB / IOKit} + ├── usb/ # 跨平台列举、hotplug 事件、passthrough/{protocol, session, viewer client, loopback, webrtc channel, ACL+HMAC, descriptor, key providers, commands, libusb / WinUSB / IOKit} ├── remote_desktop/ # WebRTC host + viewer、signalling、multi-viewer、文件/剪贴板/音频同步、审计日志(哈希链)、信任列表、TURN 配置、mDNS 发现、WebRTC stats inspector ├── plugin_loader/ # 动态 AC_* 插件搜索与注册 ├── socket_server/ # TCP Socket 服务器(远程自动化) diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6712907d..257df980 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -141,12 +141,12 @@ - **多主機管理主控台** — 在一份通訊錄中註冊 N 個遠端 AutoControl REST 端點,並行輪詢 health/sessions/jobs,把同一份動作清單廣播給全部主機。儲存於 `~/.je_auto_control/admin_hosts.json`(POSIX 上模式 0600)。Token 錯誤的主機會以實際 HTTP 錯誤呈現為不健康 - **可偵測竄改的稽核紀錄** — SQLite events 表加上 SHA-256 雜湊鏈(每筆紀錄含 `prev_hash` + `row_hash`);修改任何過去紀錄都會打斷雜湊鏈。`verify_chain()` 由上往下走訪並回報第一個斷點。既有資料表會在啟動時回填(「初次使用即信任」) - **WebRTC 封包監測** — 由既有 WebRTC stats 輪詢餵入的程序級 `StatsSnapshot` 滾動視窗(預設 600 筆 / 1 Hz 約 10 分鐘)。對 RTT、FPS、bitrate、封包遺失、jitter 各回 `last/min/max/avg/p95` -- **USB 裝置列舉** — 唯讀的跨平台 USB 裝置列舉。優先嘗試 pyusb(libusb);若無則退回平台特定指令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二階段(passthrough)刻意延後待設計審查 +- **USB 裝置列舉** — 唯讀的跨平台 USB 裝置列舉。優先嘗試 pyusb(libusb);若無則退回平台特定指令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二階段 passthrough 建構於此(見下) - **系統診斷** — 一鍵「目前正常嗎?」探測:平台、選用相依套件、executor 指令數、稽核鏈、截圖、滑鼠、硬碟空間、REST registry。CLI 全綠 exit 0/否則 1;REST `/diagnose`;依嚴重度上色的 GUI 分頁 - **USB Hotplug 事件** — 輪詢式 hotplug 監測(`UsbHotplugWatcher`),含 bounded ring buffer 與帶序號的事件;`GET /usb/events?since=N` 讓晚加入的訂閱者補上進度。USB 分頁有自動更新切換鈕。 - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json`(auth-gated,從活的路由表生成)+ `GET /docs`(瀏覽器版 Swagger UI 含 bearer token 列)。CI 上有 drift 測試,新加路由忘記寫 metadata 會被擋下。 - **設定包匯出/匯入** — 單一 JSON 檔,匯出/匯入使用者設定(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子寫入加 `.bak.<時間戳>` 備份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分頁有按鈕。 -- **USB Passthrough(實驗中、需主動啟用)** — wire-level 協定走 WebRTC `usb` DataChannel(10 個 opcode、CREDIT 流量控制、16 KiB payload 上限)。Host 端 `UsbPassthroughSession` 在 Linux libusb backend 上端到端運作;Windows `WinUSB` backend 含完整 ctypes 接線(硬體未驗證);macOS `IOKit` 為骨架。Viewer 端阻塞式 client(`UsbPassthroughClient` → `ClientHandle.control_transfer / bulk_transfer / interrupt_transfer`)。持久化 ACL(`~/.je_auto_control/usb_acl.json`,預設 deny,POSIX mode 0600),含 host 端 prompt QDialog 與可偵測竄改稽核紀錄整合。預設 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 開啟。Phase 2e 外部安全審查清單已附;預設啟用前需要簽核。 +- **USB Passthrough(需主動啟用)** — 讓遠端 viewer 使用實體插在 host 上的 USB 裝置,走 WebRTC `usb` DataChannel。Wire-level 協定(11 個 opcode 含 `RESUME`、CREDIT 流量控制、16 KiB payload 上限,超量傳輸以 EOF 分片)。八個原始未決問題全部解決:可靠有序 channel、LIST 走 channel(ACL 過濾)、per-claim credit、Linux kernel driver detach/reattach、ACL **HMAC-SHA256 完整性**(竄改 fail-closed;金鑰可插拔 — Windows DPAPI 或 passphrase vault)。**Backend:**`LibusbBackend`(production)、`WinusbBackend`(ctypes)、`IokitBackend`(原生 IOKit 列舉 + libusb 傳輸)— Windows/macOS *硬體未驗證*;`default_passthrough_backend()` 依 OS 自動挑。Viewer 端阻塞式 client(`control/bulk/interrupt_transfer`、`list_devices`、`resume`);in-process `UsbLoopback` 讓同機可走完整堆疊 share+use。**已接入 WebRTC** host/viewer(`viewer.usb_client()`)並含斷線可續租的 **resume token**。持久化 ACL(預設 deny、mode 0600),含 host 端 prompt 對話框、濫用 **rate-limit / lockout** 與可偵測竄改稽核整合。五個驅動面:AnyDesk 風 **GUI 面板**(分享 + ACL 允許/封鎖 + 本機/遠端使用)、`AC_usb_*` executor 指令(JSON / socket / 排程器)、**REST** `/usb/...`、一級 **MCP** `ac_usb_*` 工具、以及 Python API。預設 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 開啟;預設啟用仍待 Phase 2e 外部安全簽核 + 實機硬體驗證。 --- @@ -213,7 +213,7 @@ flowchart LR subgraph USB["USB"] direction TB UsbEnum["usb/
列舉 + hotplug"] - UsbPass["usb/passthrough/
session · client · ACL ·
libusb · WinUSB · IOKit"] + UsbPass["usb/passthrough/
session · client · ACL(HMAC) ·
libusb · WinUSB · IOKit ·
loopback · webrtc channel · commands"] end subgraph Remote["遠端桌面 (utils/remote_desktop/)"] @@ -313,7 +313,7 @@ je_auto_control/ ├── admin/ # 多主機 AdminConsoleClient(輪詢 + 廣播) ├── diagnostics/ # 系統自我診斷 + CLI ├── config_bundle/ # 單檔使用者設定匯出/匯入 - ├── usb/ # 跨平台列舉、hotplug 事件、passthrough/{protocol, session, viewer client, ACL, libusb / WinUSB / IOKit} + ├── usb/ # 跨平台列舉、hotplug 事件、passthrough/{protocol, session, viewer client, loopback, webrtc channel, ACL+HMAC, descriptor, key providers, commands, libusb / WinUSB / IOKit} ├── remote_desktop/ # WebRTC host + viewer、signalling、multi-viewer、檔案/剪貼簿/音訊同步、稽核紀錄(雜湊鏈)、信任清單、TURN 設定、mDNS 發現、WebRTC stats inspector ├── plugin_loader/ # 動態 AC_* 外掛搜尋與註冊 ├── socket_server/ # TCP Socket 伺服器(遠端自動化) From e952408929094f37c708b841504deceb8e3bae56 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 2 Jun 2026 20:26:24 +0800 Subject: [PATCH 08/10] Add QA/test framework layer: assertions, data-driven, suites, flaky, audit, device matrix, media Turn the automation primitives into a full test framework so flows can be verified and scored, not just driven, and consumed by CI: - Assertion DSL (AC_assert_text/image/pixel/window) raising on mismatch - Data-driven execution: load_rows (CSV/JSON/SQLite/Excel) + AC_for_each_row; dotted ${row.col} interpolation into dict keys / list indices - QA suite runner (AC_run_suite) with setup/teardown, tags, per-case scoring - JUnit XML + Allure result output for CI consumption - Flaky detection (AC_flaky_report) + persistent quarantine the runner honours - Accessibility / i18n audit: missing labels, WCAG contrast, truncation - Mobile device matrix: parallel script execution across devices, isolated - Media assertions: audio RMS activity, video segment motion Each feature ships a headless API, AC_* executor command, ac_* MCP tool, Qt GUI tab, and headless tests. Docs: new v3_features_doc (Eng/Zh) wired into the toctrees; READMEs gain a 2026-06 section. --- README.md | 31 ++ README/README_zh-CN.md | 30 ++ README/README_zh-TW.md | 30 ++ .../Eng/doc/new_features/v3_features_doc.rst | 252 ++++++++++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v3_features_doc.rst | 230 +++++++++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 56 ++++ je_auto_control/gui/a11y_audit_tab.py | 110 +++++++ je_auto_control/gui/assertions_tab.py | 120 ++++++++ je_auto_control/gui/data_source_tab.py | 130 +++++++++ je_auto_control/gui/device_matrix_tab.py | 105 +++++++ je_auto_control/gui/flakiness_tab.py | 111 +++++++ .../gui/language_wrapper/english.py | 108 +++++++ je_auto_control/gui/main_widget.py | 21 ++ je_auto_control/gui/media_checks_tab.py | 114 ++++++++ je_auto_control/gui/test_suite_tab.py | 163 +++++++++++ je_auto_control/utils/a11y_audit/__init__.py | 33 +++ je_auto_control/utils/a11y_audit/audit.py | 185 ++++++++++++ je_auto_control/utils/assertion/__init__.py | 25 ++ je_auto_control/utils/assertion/assertions.py | 239 +++++++++++++++ je_auto_control/utils/data_source/__init__.py | 13 + .../utils/data_source/data_source.py | 158 ++++++++++ .../utils/device_matrix/__init__.py | 14 + je_auto_control/utils/device_matrix/matrix.py | 124 ++++++++ je_auto_control/utils/exception/exceptions.py | 4 + .../utils/executor/action_executor.py | 236 +++++++++++++++ .../utils/executor/flow_control.py | 32 ++ je_auto_control/utils/flakiness/__init__.py | 16 + je_auto_control/utils/flakiness/flakiness.py | 134 +++++++++ .../utils/mcp_server/tools/_factories.py | 276 +++++++++++++++++- .../utils/mcp_server/tools/_handlers.py | 206 +++++++++++++ .../utils/media_assert/__init__.py | 29 ++ je_auto_control/utils/media_assert/media.py | 204 +++++++++++++ je_auto_control/utils/quarantine/__init__.py | 25 ++ je_auto_control/utils/quarantine/store.py | 165 +++++++++++ .../utils/script_vars/interpolate.py | 29 +- je_auto_control/utils/test_suite/__init__.py | 24 ++ je_auto_control/utils/test_suite/reports.py | 113 +++++++ je_auto_control/utils/test_suite/result.py | 97 ++++++ je_auto_control/utils/test_suite/runner.py | 168 +++++++++++ test/unit_test/headless/test_a11y_audit.py | 76 +++++ test/unit_test/headless/test_assertions.py | 158 ++++++++++ test/unit_test/headless/test_data_source.py | 112 +++++++ test/unit_test/headless/test_device_matrix.py | 54 ++++ test/unit_test/headless/test_flakiness.py | 75 +++++ test/unit_test/headless/test_media_assert.py | 76 +++++ test/unit_test/headless/test_qa_tabs_b_gui.py | 43 +++ test/unit_test/headless/test_qa_tabs_gui.py | 53 ++++ test/unit_test/headless/test_test_suite.py | 134 +++++++++ 50 files changed, 4940 insertions(+), 3 deletions(-) create mode 100644 docs/source/Eng/doc/new_features/v3_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v3_features_doc.rst create mode 100644 je_auto_control/gui/a11y_audit_tab.py create mode 100644 je_auto_control/gui/assertions_tab.py create mode 100644 je_auto_control/gui/data_source_tab.py create mode 100644 je_auto_control/gui/device_matrix_tab.py create mode 100644 je_auto_control/gui/flakiness_tab.py create mode 100644 je_auto_control/gui/media_checks_tab.py create mode 100644 je_auto_control/gui/test_suite_tab.py create mode 100644 je_auto_control/utils/a11y_audit/__init__.py create mode 100644 je_auto_control/utils/a11y_audit/audit.py create mode 100644 je_auto_control/utils/assertion/__init__.py create mode 100644 je_auto_control/utils/assertion/assertions.py create mode 100644 je_auto_control/utils/data_source/__init__.py create mode 100644 je_auto_control/utils/data_source/data_source.py create mode 100644 je_auto_control/utils/device_matrix/__init__.py create mode 100644 je_auto_control/utils/device_matrix/matrix.py create mode 100644 je_auto_control/utils/flakiness/__init__.py create mode 100644 je_auto_control/utils/flakiness/flakiness.py create mode 100644 je_auto_control/utils/media_assert/__init__.py create mode 100644 je_auto_control/utils/media_assert/media.py create mode 100644 je_auto_control/utils/quarantine/__init__.py create mode 100644 je_auto_control/utils/quarantine/store.py create mode 100644 je_auto_control/utils/test_suite/__init__.py create mode 100644 je_auto_control/utils/test_suite/reports.py create mode 100644 je_auto_control/utils/test_suite/result.py create mode 100644 je_auto_control/utils/test_suite/runner.py create mode 100644 test/unit_test/headless/test_a11y_audit.py create mode 100644 test/unit_test/headless/test_assertions.py create mode 100644 test/unit_test/headless/test_data_source.py create mode 100644 test/unit_test/headless/test_device_matrix.py create mode 100644 test/unit_test/headless/test_flakiness.py create mode 100644 test/unit_test/headless/test_media_assert.py create mode 100644 test/unit_test/headless/test_qa_tabs_b_gui.py create mode 100644 test/unit_test/headless/test_qa_tabs_gui.py create mode 100644 test/unit_test/headless/test_test_suite.py diff --git a/README.md b/README.md index dbf32021..e9a4e656 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06)](#whats-new-2026-06) - [What's new (2026-05)](#whats-new-2026-05) - [Features](#features) - [Architecture](#architecture) @@ -55,6 +56,35 @@ --- +## What's new (2026-06) + +Nine additions that turn the automation primitives into a full **QA / test +framework**: assert screen state, drive scripts from data, detect and +quarantine flaky tests, run a scored suite, emit CI-native reports, audit +accessibility / i18n, fan a script across a device matrix, and assert on +audio / video. Each ships with a headless API, an `AC_*` executor command, +an `ac_*` MCP tool, and a Qt GUI tab. Full reference page: +[`docs/source/Eng/doc/new_features/v3_features_doc.rst`](docs/source/Eng/doc/new_features/v3_features_doc.rst). + +**Assertions** +- **Assertion DSL** — verify screen state instead of only driving it: `assert_text` (OCR, `regex` + `present=False` for absence), `assert_image`, `assert_pixel`, `assert_window`. Returns an `AssertionResult`; raises `AutoControlAssertionException` on mismatch with optional failure screenshot (`AC_assert_text / _image / _pixel / _window`). +- **Media assertions** — `assert_audio_activity` (record + RMS threshold for sound vs silence) and `assert_video_changes` (mean frame-to-frame diff over a segment for motion vs static); pure numeric cores, lazy `sounddevice` / OpenCV (`AC_assert_audio / AC_assert_video_changes`). + +**Data-driven execution** +- **Data sources** — `load_rows` connectors for CSV / JSON / SQLite / Excel / inline; the `AC_for_each_row` block command runs a body once per row with `${row.column}` access. SQLite is single read-only `SELECT`/`WITH` only; paths are `realpath`-validated. `${var}` interpolation now resolves dotted dict-key / list-index paths while preserving types (`AC_load_data`). + +**Flaky detection & quarantine** +- **Flaky report** — score intermittent failures from run history by pass↔fail flip rate, grouped by script / source (`AC_flaky_report`). +- **Quarantine** — a persistent (mode 0600) skip-list the suite runner honours; `auto_quarantine_from_flakiness` auto-populates it above a flip-rate threshold (`AC_quarantine_add / _remove / _list / _clear / _auto`). + +**Suite runner + CI reports** +- **QA suite orchestration** — `run_suite` turns action lists into scored cases with setup / teardown, tags, and data-driven expansion; assertion failures → failed, other exceptions → error, quarantined → skipped (`AC_run_suite`). +- **JUnit / Allure reports** — `write_junit_xml` + `write_allure_results` (or `junit_path` / `allure_dir` on `AC_run_suite`) emit reports Jenkins / GitHub Actions / GitLab CI / Allure parse natively. + +**Audit, matrix, media** +- **Accessibility / i18n audit** — reuse the a11y tree + OCR to find missing accessible names, WCAG contrast-ratio failures, and ellipsis-truncated strings (`AC_audit_accessibility / AC_audit_contrast`). +- **Mobile device matrix** — fan one action list across many Android / iOS devices in parallel, each on an isolated executor, targeting the current device via `${device.*}`; per-device pass/fail, failures isolated (`AC_run_device_matrix`). + ## What's new (2026-05) Twenty-seven additions covering smarter locators, deeper IDE / ops @@ -109,6 +139,7 @@ sense) a Qt GUI tab. Full reference page: ## Features +- **QA / Test Framework** — assertion DSL (`assert_text` / `_image` / `_pixel` / `_window` + audio/video assertions), data-driven execution (CSV / JSON / SQLite / Excel → `AC_for_each_row`), a scored `run_suite` with setup/teardown/tags, JUnit + Allure report output, flaky-test detection with auto-quarantine, accessibility / i18n auditing (missing labels, WCAG contrast, truncation), and a parallel mobile device matrix. See [What's new (2026-06)](#whats-new-2026-06) - **Mouse Automation** — move, click, press, release, drag, and scroll with precise coordinate control - **Keyboard Automation** — press/release individual keys, type strings, hotkey combinations, key state detection - **Image Recognition** — locate UI elements on screen using OpenCV template matching with configurable threshold diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 4952bce7..6182c028 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06)](#本次更新-2026-06) - [本次更新 (2026-05)](#本次更新-2026-05) - [功能特性](#功能特性) - [架构](#架构) @@ -54,6 +55,34 @@ --- +## 本次更新 (2026-06) + +新增 9 个功能,把自动化原语升级成一套完整的 **QA / 测试框架**:验证画面状态、 +用数据驱动脚本、检测并隔离不稳定测试、执行计分套件、输出 CI 原生报告、 +审计无障碍 / i18n、跨设备矩阵并行执行,以及对音频 / 视频做断言。 +每个功能都遵循框架既有模式:headless Python API、`AC_*` executor 命令、 +`ac_*` MCP 工具,以及 Qt GUI 选项卡。完整参考页面: +[`docs/source/Zh/doc/new_features/v3_features_doc.rst`](../docs/source/Zh/doc/new_features/v3_features_doc.rst)。 + +**断言** +- **断言 DSL** — 验证画面状态而不只是操作:`assert_text`(OCR,`regex` + `present=False` 断言不存在)、`assert_image`、`assert_pixel`、`assert_window`。返回 `AssertionResult`,不符时抛出 `AutoControlAssertionException`,可选失败截图(`AC_assert_text / _image / _pixel / _window`)。 +- **媒体断言** — `assert_audio_activity`(录音 + RMS 阈值判断有声 / 静音)与 `assert_video_changes`(视频区段相邻帧平均差异判断动态 / 静止);纯数值核心,`sounddevice` / OpenCV 延迟加载(`AC_assert_audio / AC_assert_video_changes`)。 + +**数据驱动执行** +- **数据源** — `load_rows` 支持 CSV / JSON / SQLite / Excel / 内联;`AC_for_each_row` 块命令每行执行一次 body,字段以 `${row.column}` 取用。SQLite 仅允许单句只读 `SELECT`/`WITH`,路径经 `realpath` 校验。`${var}` 插值现在支持点号路径(dict 键 / list 索引)并保留类型(`AC_load_data`)。 + +**不稳定检测与隔离** +- **不稳定报告** — 从执行历史以通过↔失败翻转率给间歇性失败评分,按 script / source 分组(`AC_flaky_report`)。 +- **隔离区** — 套件执行器会遵守的持久化(0600)跳过清单;`auto_quarantine_from_flakiness` 按翻转率阈值自动填入(`AC_quarantine_add / _remove / _list / _clear / _auto`)。 + +**套件执行器 + CI 报告** +- **QA 套件编排** — `run_suite` 把 action list 变成具 setup / teardown、标签与数据驱动展开的计分用例;断言失败 → failed、其他异常 → error、被隔离 → skipped(`AC_run_suite`)。 +- **JUnit / Allure 报告** — `write_junit_xml` + `write_allure_results`(或 `AC_run_suite` 的 `junit_path` / `allure_dir`),输出 Jenkins / GitHub Actions / GitLab CI / Allure 原生解析的报告。 + +**审计、矩阵、媒体** +- **无障碍 / i18n 审计** — 反向利用 a11y 树 + OCR,找出缺失的可访问名称、WCAG 对比度不足与省略号截断字符串(`AC_audit_accessibility / AC_audit_contrast`)。 +- **移动设备矩阵** — 将单一 action list 并行分发到多台 Android / iOS 设备,每台独立 executor,通过 `${device.*}` 锁定当前设备;逐设备通过 / 失败,失败相互隔离(`AC_run_device_matrix`)。 + ## 本次更新 (2026-05) 新增 27 个功能,覆盖更聪明的定位器、更深的 IDE / 运维工具、 @@ -107,6 +136,7 @@ ## 功能特性 +- **QA / 测试框架** — 断言 DSL(`assert_text` / `_image` / `_pixel` / `_window` 加上音频/视频断言)、数据驱动执行(CSV / JSON / SQLite / Excel → `AC_for_each_row`)、具 setup/teardown/标签的计分 `run_suite`、JUnit + Allure 报告输出、不稳定测试检测与自动隔离、无障碍 / i18n 审计(缺失标签、WCAG 对比度、截断),以及并行的移动设备矩阵。详见 [本次更新 (2026-06)](#本次更新-2026-06) - **鼠标自动化** — 移动、点击、按下、释放、拖拽、滚动,支持精确坐标控制 - **键盘自动化** — 按下/释放单一按键、输入字符串、组合键、按键状态检测 - **图像识别** — 使用 OpenCV 模板匹配在屏幕上定位 UI 元素,支持可配置的检测阈值 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 257df980..dc6f8c1c 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06)](#本次更新-2026-06) - [本次更新 (2026-05)](#本次更新-2026-05) - [功能特色](#功能特色) - [架構](#架構) @@ -54,6 +55,34 @@ --- +## 本次更新 (2026-06) + +新增 9 個功能,把自動化原語升級成一套完整的 **QA / 測試框架**:驗證畫面狀態、 +用資料驅動腳本、偵測並隔離不穩定測試、執行計分套件、輸出 CI 原生報告、 +稽核無障礙 / i18n、跨裝置矩陣並行執行,以及對音訊 / 影片做斷言。 +每個功能都遵循框架既有模式:headless Python API、`AC_*` executor 命令、 +`ac_*` MCP 工具,以及 Qt GUI 分頁。完整參考頁面: +[`docs/source/Zh/doc/new_features/v3_features_doc.rst`](../docs/source/Zh/doc/new_features/v3_features_doc.rst)。 + +**斷言** +- **斷言 DSL** — 驗證畫面狀態而不只是操作:`assert_text`(OCR,`regex` + `present=False` 斷言不存在)、`assert_image`、`assert_pixel`、`assert_window`。回傳 `AssertionResult`,不符時拋出 `AutoControlAssertionException`,可選失敗截圖(`AC_assert_text / _image / _pixel / _window`)。 +- **媒體斷言** — `assert_audio_activity`(錄音 + RMS 門檻判斷有聲 / 靜音)與 `assert_video_changes`(影片區段相鄰影格平均差異判斷動態 / 靜止);純數值核心,`sounddevice` / OpenCV 延遲載入(`AC_assert_audio / AC_assert_video_changes`)。 + +**資料驅動執行** +- **資料來源** — `load_rows` 支援 CSV / JSON / SQLite / Excel / 內嵌;`AC_for_each_row` 區塊命令每列執行一次 body,欄位以 `${row.column}` 取用。SQLite 僅允許單句唯讀 `SELECT`/`WITH`,路徑經 `realpath` 驗證。`${var}` 插值現在支援點號路徑(dict 鍵 / list 索引)並保留型別(`AC_load_data`)。 + +**不穩定偵測與隔離** +- **不穩定報告** — 從執行歷史以通過↔失敗翻轉率評分間歇性失敗,依 script / source 分組(`AC_flaky_report`)。 +- **隔離區** — 套件執行器會遵守的持久化(0600)跳過清單;`auto_quarantine_from_flakiness` 依翻轉率門檻自動填入(`AC_quarantine_add / _remove / _list / _clear / _auto`)。 + +**套件執行器 + CI 報告** +- **QA 套件編排** — `run_suite` 把 action list 變成具 setup / teardown、標籤與資料驅動展開的計分案例;斷言失敗 → failed、其他例外 → error、被隔離 → skipped(`AC_run_suite`)。 +- **JUnit / Allure 報告** — `write_junit_xml` + `write_allure_results`(或 `AC_run_suite` 的 `junit_path` / `allure_dir`),輸出 Jenkins / GitHub Actions / GitLab CI / Allure 原生解析的報告。 + +**稽核、矩陣、媒體** +- **無障礙 / i18n 稽核** — 反向利用 a11y 樹 + OCR,找出缺漏的可存取名稱、WCAG 對比度不足與省略號截斷字串(`AC_audit_accessibility / AC_audit_contrast`)。 +- **行動裝置矩陣** — 將單一 action list 並行分發到多台 Android / iOS 裝置,每台獨立 executor,透過 `${device.*}` 鎖定當前裝置;逐裝置通過 / 失敗,失敗互相隔離(`AC_run_device_matrix`)。 + ## 本次更新 (2026-05) 新增 27 個功能,涵蓋更聰明的定位器、更深的 IDE / 維運工具、 @@ -107,6 +136,7 @@ ## 功能特色 +- **QA / 測試框架** — 斷言 DSL(`assert_text` / `_image` / `_pixel` / `_window` 加上音訊/影片斷言)、資料驅動執行(CSV / JSON / SQLite / Excel → `AC_for_each_row`)、具 setup/teardown/標籤的計分 `run_suite`、JUnit + Allure 報告輸出、不穩定測試偵測與自動隔離、無障礙 / i18n 稽核(缺漏標籤、WCAG 對比度、截斷),以及並行的行動裝置矩陣。詳見 [本次更新 (2026-06)](#本次更新-2026-06) - **滑鼠自動化** — 移動、點擊、按下、釋放、拖曳、滾動,支援精確座標控制 - **鍵盤自動化** — 按下/釋放單一按鍵、輸入字串、組合鍵、按鍵狀態偵測 - **圖像辨識** — 使用 OpenCV 模板匹配在螢幕上定位 UI 元素,支援可設定的偵測閾值 diff --git a/docs/source/Eng/doc/new_features/v3_features_doc.rst b/docs/source/Eng/doc/new_features/v3_features_doc.rst new file mode 100644 index 00000000..71c2f885 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v3_features_doc.rst @@ -0,0 +1,252 @@ +================================== +New Features (2026-06) — QA Layer +================================== + +Nine additions that turn AutoControl's automation primitives into a +full **test framework**: assert screen state, drive scripts from data, +detect and quarantine flaky tests, run a scored suite, emit CI-native +reports, audit accessibility / i18n, fan a script across a device +matrix, and assert on audio / video. Every feature ships with a +headless Python API, an ``AC_*`` executor command, an ``ac_*`` MCP +tool, and a Qt GUI tab — same pattern as the rest of the framework. + +.. contents:: + :local: + :depth: 2 + + +Assertions +========== + +Assertion DSL +------------- + +Verify the screen state instead of only driving it. Each ``assert_*`` +observes the current state, returns an :class:`AssertionResult`, and +(by default) raises ``AutoControlAssertionException`` on mismatch so a +script / test / scheduled run fails loudly at the broken assumption:: + + from je_auto_control import ( + assert_text, assert_image, assert_pixel, assert_window, + ) + + assert_text("Login successful", region=[0, 0, 800, 200]) + assert_image("checkmark.png", threshold=0.9) + assert_pixel(100, 200, [0, 200, 0], tolerance=10) + assert_window("Settings", exists=True) + +``assert_text`` accepts ``regex=True`` and ``present=False`` (assert +*absence*); every helper takes ``raise_on_fail`` and ``capture_on_fail`` +(saves a screenshot of the failing screen under +``~/.je_auto_control/assertions/``). + +Executor: ``AC_assert_text / _image / _pixel / _window``. +MCP: ``ac_assert_*``. GUI: **Assertions** tab. + + +Media assertions (audio / video) +-------------------------------- + +Assert that something actually *played* or *animated*:: + + from je_auto_control import assert_audio_activity, assert_video_changes + + assert_audio_activity(duration_s=1.0, threshold=0.01, expect_sound=True) + assert_video_changes("clip.mp4", start_s=0, end_s=3, expect_motion=True) + +``assert_audio_activity`` records from an input device and compares the +RMS level to a threshold (sound vs silence). ``assert_video_changes`` +measures mean frame-to-frame difference over a video segment (motion vs +static), with an optional ``region`` crop. The numeric cores +(``rms``, ``mean_frame_diff``, ``measure_audio_rms``, +``video_segment_motion``) are public and pure. ``sounddevice`` / +OpenCV are lazy dependencies. + +Executor: ``AC_assert_audio / AC_assert_video_changes``. +MCP: ``ac_assert_audio / ac_assert_video_changes``. GUI: **Media +Checks** tab. + + +Data-driven execution +===================== + +Feed rows from CSV / JSON / SQLite / Excel / inline literals into a +``${var}`` script, then run the same body once per row:: + + from je_auto_control import load_rows + + rows = load_rows({"kind": "csv", "path": "users.csv"}) + +In a JSON action file the new ``AC_for_each_row`` block command loads a +data source and binds each row to a variable whose columns are +addressable as ``${row.column}``:: + + ["AC_for_each_row", { + "source": {"kind": "csv", "path": "users.csv"}, + "as": "row", + "body": [ + ["AC_type_keyboard", {"keys": "${row.username}"}], + ["AC_assert_text", {"text": "${row.expected}"}] + ] + }] + +The SQLite connector accepts a **single read-only** ``SELECT`` / ``WITH`` +statement only (multi-statement / write queries are rejected); all file +paths are ``realpath``-validated. ``${var}`` interpolation now resolves +dotted paths into dict keys and list indices (``${row.user}``, +``${results.0}``) while preserving value types. + +Executor: ``AC_load_data`` + ``AC_for_each_row``. +MCP: ``ac_load_data``. GUI: **Data Sources** tab. + + +Flaky-test detection & quarantine +================================== + +Flaky report +------------ + +Score intermittent failures from the SQLite run-history store. Runs are +grouped by ``script_path`` (or ``source_id``); the report counts +pass/fail outcomes and pass↔fail *flips* in chronological order so a +flaky script ranks above one that is consistently green or red:: + + from je_auto_control import analyze_flakiness + + report = analyze_flakiness(min_runs=3) + for entry in report.entries: + print(entry.key, entry.flip_rate, entry.flaky) + +Executor: ``AC_flaky_report``. MCP: ``ac_flaky_report``. +GUI: **Flaky Tests** tab. + + +Quarantine (closing the loop) +----------------------------- + +A quarantined case name is *skipped* by the suite runner (recorded as +``skipped`` with reason ``quarantined``) so a known-flaky case stops +poisoning the suite's red/green status until it is fixed. The store is a +small JSON file (mode 0600 on POSIX) that persists across restarts:: + + from je_auto_control import ( + default_quarantine_store, auto_quarantine_from_flakiness, + ) + + default_quarantine_store().add("login_suite", reason="under triage") + auto_quarantine_from_flakiness(flip_rate_threshold=0.5) + +``auto_quarantine_from_flakiness`` reads the flakiness report and +quarantines every group above the flip-rate threshold. + +Executor: ``AC_quarantine_add / _remove / _list / _clear / _auto``. +MCP: ``ac_quarantine_*``. GUI: quarantine panel on the **Test Suites** +tab. + + +QA suite runner + CI reports +============================ + +Suite orchestration +------------------- + +Turn flat action lists into scored test cases with setup / teardown, +tags, and per-case pass/fail. A case carrying a ``data`` source expands +to one scored case per row:: + + from je_auto_control import run_suite + + spec = { + "name": "Login", + "setup": [["AC_focus_window", {"title": "MyApp"}]], + "teardown": [["AC_close_window", {"title": "MyApp"}]], + "cases": [ + {"name": "valid login", "tags": ["smoke"], + "actions": [["AC_assert_text", {"text": "Welcome"}]]}, + {"name": "each user", "as": "row", + "data": {"kind": "csv", "path": "users.csv"}, + "actions": [["AC_assert_text", {"text": "${row.expected}"}]]}, + ], + } + result = run_suite(spec, tags=["smoke"]) + print(result.passed, result.failed, result.errored, result.skipped) + +An ``AutoControlAssertionException`` marks a case **failed**; any other +exception marks it **error**; a clean run is **passed**. Quarantined +case names are recorded as **skipped**. + +Executor: ``AC_run_suite``. MCP: ``ac_run_suite``. +GUI: **Test Suites** tab. + + +CI-native reports (JUnit / Allure) +---------------------------------- + +Emit reports that Jenkins, GitHub Actions, GitLab CI, and Allure parse +natively:: + + from je_auto_control import write_junit_xml, write_allure_results + + write_junit_xml(result, "reports/junit.xml") + write_allure_results(result, "reports/allure") + +``AC_run_suite`` writes them inline when given ``junit_path`` / +``allure_dir``:: + + ["AC_run_suite", {"spec": {...}, "junit_path": "reports/junit.xml"}] + +Only report *generation* happens here (never parsing untrusted XML), so +the stdlib ``xml.etree.ElementTree`` writer is safe. + + +Accessibility & i18n audit +========================== + +Reuse the accessibility tree and OCR layer to *inspect* a UI for common +accessibility / localisation defects rather than to drive it:: + + from je_auto_control import run_audit, contrast_ratio + + report = run_audit( + app_name="MyApp", + contrast_pairs=[{"foreground": [120, 120, 120], + "background": [255, 255, 255], "label": "hint"}], + texts=["Save chang…"], # OCR strings to scan for truncation + ) + +Checks: + +* **Missing labels** — interactive widgets (button, menu item, link, + field …) exposed through the a11y tree with no accessible name. +* **Contrast** — WCAG 2.x relative-luminance contrast ratio with AA / + AAA thresholds (``contrast_ratio([0,0,0],[255,255,255]) == 21.0``). +* **Truncation** — OCR strings ending in an ellipsis (clipped after + translation). + +Executor: ``AC_audit_accessibility / AC_audit_contrast``. +MCP: ``ac_audit_*``. GUI: **A11y Audit** tab. + + +Mobile device matrix +==================== + +Fan a single action list out across many Android / iOS devices **in +parallel**, each on its own isolated executor (so runtime variable +scopes never collide between threads). The script targets the current +device through a bound ``${device.*}`` variable:: + + from je_auto_control import run_on_devices + + report = run_on_devices( + actions=[["AC_android_tap", {"x": 100, "y": 200, + "serial": "${device.serial}"}]], + devices=[{"platform": "android", "serial": "emulator-5554"}, + {"platform": "android", "serial": "emulator-5556"}], + max_parallel=4, + ) + print(report.passed, report.failed) + +A failure on one device is isolated — it never aborts the others. + +Executor: ``AC_run_device_matrix``. MCP: ``ac_run_device_matrix``. +GUI: **Device Matrix** tab. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 23c86842..0d2d4a0d 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -25,6 +25,7 @@ Comprehensive guides for all AutoControl features. doc/create_project/create_project_doc doc/new_features/new_features_doc doc/new_features/v2_features_doc + doc/new_features/v3_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v3_features_doc.rst b/docs/source/Zh/doc/new_features/v3_features_doc.rst new file mode 100644 index 00000000..6c048261 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v3_features_doc.rst @@ -0,0 +1,230 @@ +================================== +新功能 (2026-06) — 測試框架層 +================================== + +新增 9 個功能,把 AutoControl 的自動化原語升級成一套完整的**測試框架**: +驗證畫面狀態、用資料驅動腳本、偵測並隔離不穩定測試、執行計分套件、輸出 CI +原生報告、稽核無障礙 / i18n、跨裝置矩陣並行執行,以及對音訊 / 影片做斷言。 +每個功能都遵循框架既有模式:headless Python API、``AC_*`` executor 命令、 +``ac_*`` MCP 工具,以及 Qt GUI 分頁。 + +.. contents:: + :local: + :depth: 2 + + +斷言 +==== + +斷言 DSL +-------- + +驗證畫面狀態,而不只是操作畫面。每個 ``assert_*`` 觀察當前狀態、回傳 +:class:`AssertionResult`,並(預設)在不符時拋出 +``AutoControlAssertionException``,讓腳本 / 測試 / 排程在錯誤假設處明確失敗:: + + from je_auto_control import ( + assert_text, assert_image, assert_pixel, assert_window, + ) + + assert_text("Login successful", region=[0, 0, 800, 200]) + assert_image("checkmark.png", threshold=0.9) + assert_pixel(100, 200, [0, 200, 0], tolerance=10) + assert_window("Settings", exists=True) + +``assert_text`` 支援 ``regex=True`` 與 ``present=False``(斷言「不存在」); +每個函式都接受 ``raise_on_fail`` 與 ``capture_on_fail``(將失敗畫面截圖存到 +``~/.je_auto_control/assertions/``)。 + +Executor:``AC_assert_text / _image / _pixel / _window``。 +MCP:``ac_assert_*``。GUI:**Assertions** 分頁。 + + +媒體斷言(音訊 / 影片) +---------------------- + +斷言某個東西確實「播放」或「動」了:: + + from je_auto_control import assert_audio_activity, assert_video_changes + + assert_audio_activity(duration_s=1.0, threshold=0.01, expect_sound=True) + assert_video_changes("clip.mp4", start_s=0, end_s=3, expect_motion=True) + +``assert_audio_activity`` 從輸入裝置錄音並比較 RMS 音量與門檻(有聲 vs 靜音)。 +``assert_video_changes`` 量測影片區段的相鄰影格平均差異(動態 vs 靜止),可選 +``region`` 裁切。數值核心(``rms``、``mean_frame_diff``、``measure_audio_rms``、 +``video_segment_motion``)為公開純函式。``sounddevice`` / OpenCV 為延遲載入依賴。 + +Executor:``AC_assert_audio / AC_assert_video_changes``。 +MCP:``ac_assert_audio / ac_assert_video_changes``。GUI:**Media Checks** 分頁。 + + +資料驅動執行 +============ + +將 CSV / JSON / SQLite / Excel / 內嵌字面值的列資料餵進 ``${var}`` 腳本, +然後每列執行同一段 body:: + + from je_auto_control import load_rows + + rows = load_rows({"kind": "csv", "path": "users.csv"}) + +在 JSON action 檔中,新的 ``AC_for_each_row`` 區塊命令會載入資料來源, +並把每列綁定到一個變數,其欄位可用 ``${row.column}`` 取用:: + + ["AC_for_each_row", { + "source": {"kind": "csv", "path": "users.csv"}, + "as": "row", + "body": [ + ["AC_type_keyboard", {"keys": "${row.username}"}], + ["AC_assert_text", {"text": "${row.expected}"}] + ] + }] + +SQLite 連接器**只允許單句唯讀** ``SELECT`` / ``WITH``(多語句 / 寫入查詢一律拒絕); +所有檔案路徑皆經 ``realpath`` 驗證。``${var}`` 插值現在能解析點號路徑進 dict 鍵 +與 list 索引(``${row.user}``、``${results.0}``),並保留值的型別。 + +Executor:``AC_load_data`` + ``AC_for_each_row``。 +MCP:``ac_load_data``。GUI:**Data Sources** 分頁。 + + +不穩定測試偵測與隔離 +==================== + +不穩定報告 +---------- + +從 SQLite 執行歷史評分間歇性失敗。執行記錄依 ``script_path``(或 ``source_id``) +分組;報告統計通過 / 失敗次數,以及依時間順序的通過↔失敗*翻轉*次數, +讓不穩定腳本排在持續綠燈或持續紅燈的腳本之上:: + + from je_auto_control import analyze_flakiness + + report = analyze_flakiness(min_runs=3) + for entry in report.entries: + print(entry.key, entry.flip_rate, entry.flaky) + +Executor:``AC_flaky_report``。MCP:``ac_flaky_report``。 +GUI:**Flaky Tests** 分頁。 + + +隔離(閉環處理) +---------------- + +被隔離的案例名稱會被套件執行器*跳過*(記為 ``skipped``,原因 ``quarantined``), +讓已知不穩定的案例在修好前不再污染套件的紅 / 綠狀態。隔離區是一個小型 JSON +檔(POSIX 上為 0600 權限),可跨重啟保存:: + + from je_auto_control import ( + default_quarantine_store, auto_quarantine_from_flakiness, + ) + + default_quarantine_store().add("login_suite", reason="under triage") + auto_quarantine_from_flakiness(flip_rate_threshold=0.5) + +``auto_quarantine_from_flakiness`` 讀取不穩定報告,並隔離所有超過翻轉率門檻的群組。 + +Executor:``AC_quarantine_add / _remove / _list / _clear / _auto``。 +MCP:``ac_quarantine_*``。GUI:**Test Suites** 分頁上的隔離區面板。 + + +QA 套件執行器 + CI 報告 +======================= + +套件編排 +-------- + +把扁平的 action list 變成具備 setup / teardown、標籤與逐案例通過 / 失敗計分的 +測試案例。帶有 ``data`` 來源的案例會展開成每列一個計分案例:: + + from je_auto_control import run_suite + + spec = { + "name": "Login", + "setup": [["AC_focus_window", {"title": "MyApp"}]], + "teardown": [["AC_close_window", {"title": "MyApp"}]], + "cases": [ + {"name": "valid login", "tags": ["smoke"], + "actions": [["AC_assert_text", {"text": "Welcome"}]]}, + {"name": "each user", "as": "row", + "data": {"kind": "csv", "path": "users.csv"}, + "actions": [["AC_assert_text", {"text": "${row.expected}"}]]}, + ], + } + result = run_suite(spec, tags=["smoke"]) + print(result.passed, result.failed, result.errored, result.skipped) + +``AutoControlAssertionException`` 將案例標為**失敗**;其他例外標為**錯誤**; +乾淨執行為**通過**;被隔離的案例名稱記為**跳過**。 + +Executor:``AC_run_suite``。MCP:``ac_run_suite``。GUI:**Test Suites** 分頁。 + + +CI 原生報告(JUnit / Allure) +----------------------------- + +輸出 Jenkins、GitHub Actions、GitLab CI 與 Allure 能原生解析的報告:: + + from je_auto_control import write_junit_xml, write_allure_results + + write_junit_xml(result, "reports/junit.xml") + write_allure_results(result, "reports/allure") + +``AC_run_suite`` 在傳入 ``junit_path`` / ``allure_dir`` 時會直接一併寫出:: + + ["AC_run_suite", {"spec": {...}, "junit_path": "reports/junit.xml"}] + +此處只負責報告*產生*(絕不解析不可信 XML),因此使用標準庫 +``xml.etree.ElementTree`` 寫出是安全的。 + + +無障礙 & i18n 稽核 +================== + +反向利用無障礙樹與 OCR 層,去*檢查*介面常見的無障礙 / 在地化缺陷, +而非用來操作:: + + from je_auto_control import run_audit, contrast_ratio + + report = run_audit( + app_name="MyApp", + contrast_pairs=[{"foreground": [120, 120, 120], + "background": [255, 255, 255], "label": "hint"}], + texts=["Save chang…"], # 用來掃描截斷的 OCR 字串 + ) + +檢查項目: + +* **缺漏標籤** — 無障礙樹中沒有可存取名稱的互動元件(按鈕、選單項、連結、 + 輸入框…)。 +* **對比度** — WCAG 2.x 相對亮度對比率,含 AA / AAA 門檻 + (``contrast_ratio([0,0,0],[255,255,255]) == 21.0``)。 +* **截斷** — 以省略號結尾的 OCR 字串(翻譯後被切掉)。 + +Executor:``AC_audit_accessibility / AC_audit_contrast``。 +MCP:``ac_audit_*``。GUI:**A11y Audit** 分頁。 + + +行動裝置矩陣 +============ + +將單一 action list **並行**分發到多台 Android / iOS 裝置,每台使用各自獨立的 +executor(執行緒間的執行期變數作用域互不衝突)。腳本透過綁定的 ``${device.*}`` +變數鎖定當前裝置:: + + from je_auto_control import run_on_devices + + report = run_on_devices( + actions=[["AC_android_tap", {"x": 100, "y": 200, + "serial": "${device.serial}"}]], + devices=[{"platform": "android", "serial": "emulator-5554"}, + {"platform": "android", "serial": "emulator-5556"}], + max_parallel=4, + ) + print(report.passed, report.failed) + +單台裝置的失敗會被隔離——絕不中斷其他裝置。 + +Executor:``AC_run_device_matrix``。MCP:``ac_run_device_matrix``。 +GUI:**Device Matrix** 分頁。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 8db7726e..86e9c48d 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -25,6 +25,7 @@ AutoControl 所有功能的完整使用指南。 doc/create_project/create_project_doc doc/new_features/new_features_doc doc/new_features/v2_features_doc + doc/new_features/v3_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 967c6d94..83a9a903 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -133,6 +133,40 @@ WaitOutcome, wait_until_pixel_changes, wait_until_region_idle, wait_until_screen_stable, ) +# Assertion DSL (verify screen state; raise on mismatch) +from je_auto_control.utils.assertion import ( + AssertionResult, assert_image, assert_pixel, assert_text, assert_window, +) +# Data-driven execution (load rows from CSV / JSON / SQLite / Excel) +from je_auto_control.utils.data_source import data_source_kinds, load_rows +# Flaky-test detection (analytics over the run-history store) +from je_auto_control.utils.flakiness import ( + FlakinessReport, FlakyEntry, analyze_flakiness, +) +# QA suite orchestration + CI report output (JUnit / Allure) +from je_auto_control.utils.test_suite import ( + TestCaseResult, TestSuiteResult, run_suite, + to_allure_results, to_junit_xml, write_allure_results, write_junit_xml, +) +# Flaky-test quarantine (skip known-unstable cases) +from je_auto_control.utils.quarantine import ( + QuarantineEntry, QuarantineStore, auto_quarantine_from_flakiness, + default_quarantine_store, +) +# Accessibility / i18n audit (missing labels, WCAG contrast, truncation) +from je_auto_control.utils.a11y_audit import ( + AuditIssue, AuditReport, audit_contrast, audit_missing_labels, + contrast_ratio, detect_truncation, run_audit, +) +# Mobile device matrix (parallel script execution across devices) +from je_auto_control.utils.device_matrix import ( + DeviceResult, MatrixReport, run_on_devices, +) +# Media assertions (audio activity, video motion) +from je_auto_control.utils.media_assert import ( + MediaAssertionResult, assert_audio_activity, assert_video_changes, + measure_audio_rms, video_segment_motion, +) # Cost telemetry (per-LLM-call token + USD tracking) from je_auto_control.utils.cost_telemetry import ( CostEvent, CostSummary, default_cost_store, estimate_llm_usd, @@ -470,6 +504,28 @@ def start_autocontrol_gui(*args, **kwargs): # Smart waits "WaitOutcome", "wait_until_pixel_changes", "wait_until_region_idle", "wait_until_screen_stable", + # Assertion DSL + "AssertionResult", "assert_image", "assert_pixel", + "assert_text", "assert_window", + # Data-driven execution + "data_source_kinds", "load_rows", + # Flaky-test detection + "FlakinessReport", "FlakyEntry", "analyze_flakiness", + # QA suite orchestration + CI reports + "TestCaseResult", "TestSuiteResult", "run_suite", + "to_allure_results", "to_junit_xml", + "write_allure_results", "write_junit_xml", + # Flaky quarantine + "QuarantineEntry", "QuarantineStore", + "auto_quarantine_from_flakiness", "default_quarantine_store", + # Accessibility / i18n audit + "AuditIssue", "AuditReport", "audit_contrast", "audit_missing_labels", + "contrast_ratio", "detect_truncation", "run_audit", + # Mobile device matrix + "DeviceResult", "MatrixReport", "run_on_devices", + # Media assertions + "MediaAssertionResult", "assert_audio_activity", "assert_video_changes", + "measure_audio_rms", "video_segment_motion", # Cost telemetry "CostEvent", "CostSummary", "default_cost_store", "estimate_llm_usd", "record_llm_call", "summarise_llm_costs", diff --git a/je_auto_control/gui/a11y_audit_tab.py b/je_auto_control/gui/a11y_audit_tab.py new file mode 100644 index 00000000..078e2dfc --- /dev/null +++ b/je_auto_control/gui/a11y_audit_tab.py @@ -0,0 +1,110 @@ +"""Accessibility Audit tab: surface a11y / i18n defects from the live tree. + +Thin wrapper over :func:`je_auto_control.run_audit` and +:func:`je_auto_control.contrast_ratio`. +""" +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +import je_auto_control as ac + +_COLS = ("audit_col_kind", "audit_col_severity", "audit_col_target", + "audit_col_message") + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +def _ints(raw: str): + return [int(p.strip()) for p in raw.split(",") if p.strip()] + + +class A11yAuditTab(TranslatableMixin, QWidget): + """Run the accessibility / i18n audit and render the issue list.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._app = QLineEdit() + self._fg = QLineEdit() + self._fg.setPlaceholderText("0, 0, 0") + self._bg = QLineEdit() + self._bg.setPlaceholderText("255, 255, 255") + self._table = QTableWidget(0, len(_COLS)) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.horizontalHeader().setStretchLastSection(True) + self._summary = QLabel() + self._apply_headers() + self._build_layout() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_headers() + + def _apply_headers(self) -> None: + self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + row = QHBoxLayout() + row.addWidget(QLabel(_t("audit_app"))) + row.addWidget(self._app, stretch=1) + run_btn = self._tr(QPushButton(), "audit_run") + run_btn.clicked.connect(self._on_run) + row.addWidget(run_btn) + root.addLayout(row) + crow = QHBoxLayout() + crow.addWidget(QLabel(_t("audit_contrast_label"))) + crow.addWidget(self._fg) + crow.addWidget(self._bg) + contrast_btn = self._tr(QPushButton(), "audit_contrast_run") + contrast_btn.clicked.connect(self._on_contrast) + crow.addWidget(contrast_btn) + root.addLayout(crow) + root.addWidget(self._table, stretch=1) + root.addWidget(self._summary) + + def _on_run(self) -> None: + app = self._app.text().strip() or None + try: + report = ac.run_audit(app_name=app) + except (RuntimeError, OSError, ValueError) as error: + self._summary.setText(str(error)) + return + self._render(report.to_dict()) + + def _render(self, report: dict) -> None: + issues = report["issues"] + self._table.setRowCount(len(issues)) + for row, issue in enumerate(issues): + values = (issue["kind"], issue["severity"], issue["target"], + issue["message"]) + for col, text in enumerate(values): + item = QTableWidgetItem(str(text)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) + self._summary.setText( + _t("audit_summary") + .replace("{errors}", str(report["error_count"])) + .replace("{warnings}", str(report["warning_count"])), + ) + + def _on_contrast(self) -> None: + try: + ratio = ac.contrast_ratio(_ints(self._fg.text()), + _ints(self._bg.text())) + except (ValueError, IndexError) as error: + self._summary.setText(str(error)) + return + verdict = "PASS" if ratio >= 4.5 else "FAIL" + self._summary.setText(f"contrast {ratio:.2f}:1 — AA {verdict}") diff --git a/je_auto_control/gui/assertions_tab.py b/je_auto_control/gui/assertions_tab.py new file mode 100644 index 00000000..06806ca8 --- /dev/null +++ b/je_auto_control/gui/assertions_tab.py @@ -0,0 +1,120 @@ +"""Assertions tab: run a single screen-state assertion and show pass/fail. + +Thin wrapper over the headless ``je_auto_control.assert_*`` functions. +Assertions run with ``raise_on_fail=False`` so the GUI reports the +outcome instead of crashing the tab. +""" +from typing import Any, Dict, Optional + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +import je_auto_control as ac + +_KINDS = ("text", "image", "pixel", "window") + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +def _parse_ints(raw: str): + return [int(part.strip()) for part in raw.split(",") if part.strip()] + + +class AssertionsTab(TranslatableMixin, QWidget): + """Form that drives one assertion and renders its :class:`AssertionResult`.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._kind = QComboBox() + self._kind.addItems([_t(f"assert_kind_{k}") for k in _KINDS]) + self._kind.currentIndexChanged.connect(self._sync_visibility) + self._target = QLineEdit() + self._xy = QLineEdit() + self._xy.setPlaceholderText("100, 200") + self._rgb = QLineEdit() + self._rgb.setPlaceholderText("255, 255, 255") + self._expect = QCheckBox(_t("assert_expect_present")) + self._expect.setChecked(True) + self._regex = QCheckBox(_t("assert_regex")) + self._result = QLabel() + self._result.setWordWrap(True) + self._build_layout() + self._sync_visibility() + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + krow = QHBoxLayout() + krow.addWidget(QLabel(_t("assert_kind"))) + krow.addWidget(self._kind) + krow.addStretch() + root.addLayout(krow) + + self._target_label = QLabel(_t("assert_target")) + root.addWidget(self._target_label) + root.addWidget(self._target) + + self._xy_label = QLabel(_t("assert_pixel_xy")) + root.addWidget(self._xy_label) + root.addWidget(self._xy) + self._rgb_label = QLabel(_t("assert_pixel_rgb")) + root.addWidget(self._rgb_label) + root.addWidget(self._rgb) + + root.addWidget(self._expect) + root.addWidget(self._regex) + run_btn = self._tr(QPushButton(), "assert_run") + run_btn.clicked.connect(self._on_run) + root.addWidget(run_btn) + root.addWidget(self._result) + root.addStretch() + + def _current_kind(self) -> str: + return _KINDS[self._kind.currentIndex()] + + def _sync_visibility(self) -> None: + kind = self._current_kind() + is_pixel = kind == "pixel" + for widget in (self._xy_label, self._xy, self._rgb_label, self._rgb): + widget.setVisible(is_pixel) + for widget in (self._target_label, self._target): + widget.setVisible(not is_pixel) + self._regex.setVisible(kind == "text") + + def _run_assertion(self) -> Dict[str, Any]: + kind = self._current_kind() + present = self._expect.isChecked() + if kind == "text": + return ac.assert_text( + self._target.text(), regex=self._regex.isChecked(), + present=present, raise_on_fail=False, + ).to_dict() + if kind == "image": + return ac.assert_image( + self._target.text(), present=present, raise_on_fail=False, + ).to_dict() + if kind == "pixel": + return ac.assert_pixel( + *_parse_ints(self._xy.text())[:2], _parse_ints(self._rgb.text()), + match=present, raise_on_fail=False, + ).to_dict() + return ac.assert_window( + self._target.text(), exists=present, raise_on_fail=False, + ).to_dict() + + def _on_run(self) -> None: + try: + result = self._run_assertion() + except (ValueError, OSError, RuntimeError, TypeError) as error: + self._result.setText(f"{_t('assert_failed')}: {error}") + return + label = _t("assert_passed") if result["passed"] else _t("assert_failed") + self._result.setText(f"{label} — {result['message']}") diff --git a/je_auto_control/gui/data_source_tab.py b/je_auto_control/gui/data_source_tab.py new file mode 100644 index 00000000..0c61fb12 --- /dev/null +++ b/je_auto_control/gui/data_source_tab.py @@ -0,0 +1,130 @@ +"""Data Sources tab: preview rows loaded by the headless data-source layer. + +Thin wrapper over :func:`je_auto_control.load_rows` — the widget only +builds a source spec dict from the form and renders the returned rows. +""" +import json +from typing import Any, Dict, List, Optional + +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, + QPlainTextEdit, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem, + QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.data_source import data_source_kinds, load_rows + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class DataSourceTab(TranslatableMixin, QWidget): + """Build a data-source spec, load rows, and show them in a table.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._kind = QComboBox() + self._kind.addItems(data_source_kinds()) + self._kind.currentTextChanged.connect(self._sync_visibility) + self._path = QLineEdit() + self._query = QLineEdit() + self._inline = QPlainTextEdit() + self._inline.setPlaceholderText('[{"user": "alice"}, {"user": "bob"}]') + self._limit = QSpinBox() + self._limit.setRange(0, 100000) + self._table = QTableWidget(0, 0) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._status = QLabel() + self._build_layout() + self._sync_visibility(self._kind.currentText()) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + row1 = QHBoxLayout() + row1.addWidget(QLabel(_t("ds_kind"))) + row1.addWidget(self._kind) + row1.addStretch() + root.addLayout(row1) + + self._path_row = QHBoxLayout() + self._path_label = QLabel(_t("ds_path")) + self._path_row.addWidget(self._path_label) + self._path_row.addWidget(self._path, stretch=1) + self._browse_btn = self._tr(QPushButton(), "ds_browse") + self._browse_btn.clicked.connect(self._on_browse) + self._path_row.addWidget(self._browse_btn) + root.addLayout(self._path_row) + + self._query_label = QLabel(_t("ds_query")) + root.addWidget(self._query_label) + root.addWidget(self._query) + + self._inline_label = QLabel(_t("ds_inline")) + root.addWidget(self._inline_label) + root.addWidget(self._inline) + + row2 = QHBoxLayout() + row2.addWidget(QLabel(_t("ds_limit"))) + row2.addWidget(self._limit) + load_btn = self._tr(QPushButton(), "ds_load") + load_btn.clicked.connect(self._on_load) + row2.addWidget(load_btn) + row2.addStretch() + root.addLayout(row2) + + root.addWidget(self._table, stretch=1) + root.addWidget(self._status) + + def _sync_visibility(self, kind: str) -> None: + is_file = kind in ("csv", "json", "sqlite", "excel") + for widget in (self._path_label, self._path, self._browse_btn): + widget.setVisible(is_file) + self._query_label.setVisible(kind == "sqlite") + self._query.setVisible(kind == "sqlite") + self._inline_label.setVisible(kind == "inline") + self._inline.setVisible(kind == "inline") + + def _on_browse(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, _t("ds_browse")) + if path: + self._path.setText(path) + + def _build_source(self) -> Dict[str, Any]: + kind = self._kind.currentText() + if kind == "inline": + return {"kind": "inline", "rows": json.loads(self._inline.toPlainText() or "[]")} + source: Dict[str, Any] = {"kind": kind, "path": self._path.text().strip()} + if kind == "sqlite": + source["query"] = self._query.text().strip() + return source + + def _on_load(self) -> None: + try: + limit = self._limit.value() or None + rows = load_rows(self._build_source(), limit=limit) + except (ValueError, OSError, RuntimeError, json.JSONDecodeError) as error: + self._status.setText(_t("ds_error").replace("{error}", str(error))) + return + self._render_rows(rows) + self._status.setText(_t("ds_row_count").replace("{n}", str(len(rows)))) + + def _render_rows(self, rows: List[Dict[str, Any]]) -> None: + columns: List[str] = [] + for row in rows: + for key in row: + if key not in columns: + columns.append(key) + self._table.setColumnCount(len(columns)) + self._table.setHorizontalHeaderLabels(columns) + self._table.setRowCount(len(rows)) + for r, row in enumerate(rows): + for c, key in enumerate(columns): + self._table.setItem( + r, c, QTableWidgetItem(str(row.get(key, ""))), + ) diff --git a/je_auto_control/gui/device_matrix_tab.py b/je_auto_control/gui/device_matrix_tab.py new file mode 100644 index 00000000..c4bb1ad7 --- /dev/null +++ b/je_auto_control/gui/device_matrix_tab.py @@ -0,0 +1,105 @@ +"""Device Matrix tab: run one action list across many devices in parallel. + +Thin wrapper over :func:`je_auto_control.run_on_devices`. +""" +import json +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +import je_auto_control as ac + +_COLS = ("dm_col_device", "dm_col_platform", "dm_col_result", "dm_col_time", + "dm_col_error") + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class DeviceMatrixTab(TranslatableMixin, QWidget): + """Edit a device list + action list, run in parallel, show results.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._devices = QPlainTextEdit() + self._devices.setPlaceholderText( + '[{"platform": "android", "serial": "emulator-5554"}]', + ) + self._actions = QPlainTextEdit() + self._actions.setPlaceholderText( + '[["AC_android_tap", {"x": 1, "y": 2, ' + '"serial": "${device.serial}"}]]', + ) + self._parallel = QSpinBox() + self._parallel.setRange(1, 64) + self._parallel.setValue(4) + self._table = QTableWidget(0, len(_COLS)) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.horizontalHeader().setStretchLastSection(True) + self._summary = QLabel() + self._apply_headers() + self._build_layout() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_headers() + + def _apply_headers(self) -> None: + self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + root.addWidget(QLabel(_t("dm_devices"))) + root.addWidget(self._devices) + root.addWidget(QLabel(_t("dm_actions"))) + root.addWidget(self._actions) + row = QHBoxLayout() + row.addWidget(QLabel(_t("dm_parallel"))) + row.addWidget(self._parallel) + run_btn = self._tr(QPushButton(), "dm_run") + run_btn.clicked.connect(self._on_run) + row.addWidget(run_btn) + row.addStretch() + root.addLayout(row) + root.addWidget(self._table, stretch=1) + root.addWidget(self._summary) + + def _on_run(self) -> None: + try: + devices = json.loads(self._devices.toPlainText() or "[]") + actions = json.loads(self._actions.toPlainText() or "[]") + report = ac.run_on_devices( + actions, devices, max_parallel=self._parallel.value(), + ) + except (ValueError, RuntimeError, json.JSONDecodeError) as error: + self._summary.setText(_t("dm_error").replace("{error}", str(error))) + return + self._render(report.to_dict()) + + def _render(self, report: dict) -> None: + results = report["results"] + self._table.setRowCount(len(results)) + for row, res in enumerate(results): + values = (res["device_id"], res["platform"], + "OK" if res["success"] else "FAIL", + f"{res['duration_s']:.2f}s", res.get("error") or "") + for col, text in enumerate(values): + item = QTableWidgetItem(str(text)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) + self._summary.setText( + _t("dm_summary") + .replace("{passed}", str(report["passed"])) + .replace("{failed}", str(report["failed"])) + .replace("{total}", str(report["total"])), + ) diff --git a/je_auto_control/gui/flakiness_tab.py b/je_auto_control/gui/flakiness_tab.py new file mode 100644 index 00000000..973b9ba9 --- /dev/null +++ b/je_auto_control/gui/flakiness_tab.py @@ -0,0 +1,111 @@ +"""Flaky Tests tab: rank intermittently-failing scripts from run history. + +Thin wrapper over :func:`je_auto_control.analyze_flakiness` — the table +holds no business logic, it only renders the headless report. +""" +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +from je_auto_control.utils.flakiness import analyze_flakiness + +_COLUMN_KEYS = ( + "flaky_col_key", "flaky_col_runs", "flaky_col_ok", "flaky_col_error", + "flaky_col_flips", "flaky_col_flip_rate", "flaky_col_pass_rate", + "flaky_col_last", "flaky_col_flaky", +) + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class FlakinessTab(TranslatableMixin, QWidget): + """Read-only flakiness leaderboard backed by the run-history store.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._table = QTableWidget(0, len(_COLUMN_KEYS)) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._table.verticalHeader().setVisible(False) + self._table.horizontalHeader().setStretchLastSection(True) + self._table.horizontalHeader().setSectionResizeMode( + QHeaderView.Interactive, + ) + self._limit = QSpinBox() + self._limit.setRange(1, 100000) + self._limit.setValue(500) + self._min_runs = QSpinBox() + self._min_runs.setRange(1, 1000) + self._min_runs.setValue(2) + self._group_by = QComboBox() + self._group_by.addItems(["script_path", "source_id"]) + self._status = QLabel() + self._apply_headers() + self._build_layout() + self._refresh() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_headers() + self._refresh() + + def _apply_headers(self) -> None: + self._table.setHorizontalHeaderLabels([_t(k) for k in _COLUMN_KEYS]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + controls = QHBoxLayout() + controls.addWidget(QLabel(_t("flaky_limit"))) + controls.addWidget(self._limit) + controls.addWidget(QLabel(_t("flaky_min_runs"))) + controls.addWidget(self._min_runs) + controls.addWidget(QLabel(_t("flaky_group_by"))) + controls.addWidget(self._group_by) + refresh_btn = self._tr(QPushButton(), "flaky_refresh") + refresh_btn.clicked.connect(self._refresh) + controls.addWidget(refresh_btn) + controls.addStretch() + root.addLayout(controls) + root.addWidget(self._table, stretch=1) + root.addWidget(self._status) + + def _refresh(self) -> None: + report = analyze_flakiness( + limit=self._limit.value(), + min_runs=self._min_runs.value(), + group_by=self._group_by.currentText(), + ) + self._table.setRowCount(len(report.entries)) + for row, entry in enumerate(report.entries): + self._set_row(row, entry) + if report.total_groups: + self._status.setText( + _t("flaky_summary") + .replace("{flaky}", str(report.flaky_count)) + .replace("{total}", str(report.total_groups)), + ) + else: + self._status.setText(_t("flaky_summary_empty")) + + def _set_row(self, row: int, entry) -> None: + values = ( + entry.key, str(entry.total_runs), str(entry.ok), + str(entry.error), str(entry.flips), f"{entry.flip_rate:.0%}", + f"{entry.pass_rate:.0%}", entry.last_status, + "⚠" if entry.flaky else "", + ) + for col, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 9f45be93..c60d64e5 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -58,6 +58,114 @@ "tab_inspector": "Packet Inspector", "tab_usb_devices": "USB Devices", "tab_diagnostics": "Diagnostics", + "tab_assertions": "Assertions", + "tab_data_source": "Data Sources", + "tab_flakiness": "Flaky Tests", + "tab_test_suite": "Test Suites", + + # Test Suites tab + "suite_spec_label": "Suite spec (JSON)", + "suite_load_file": "Load file…", + "suite_run": "Run suite", + "suite_junit": "Write JUnit XML…", + "suite_allure": "Write Allure dir…", + "suite_col_case": "Case", + "suite_col_status": "Status", + "suite_col_time": "Time", + "suite_col_message": "Message", + "suite_summary": "{passed} passed, {failed} failed, {errored} errored, {skipped} skipped", + "suite_error": "Run failed: {error}", + "suite_q_label": "Quarantine", + "suite_q_auto": "Auto-quarantine flaky", + "suite_q_clear": "Clear quarantine", + "suite_q_remove": "Release selected", + "tab_a11y_audit": "A11y Audit", + "tab_device_matrix": "Device Matrix", + "tab_media_checks": "Media Checks", + + # Accessibility audit tab + "audit_app": "App name (blank = all)", + "audit_run": "Run audit", + "audit_col_kind": "Kind", + "audit_col_severity": "Severity", + "audit_col_target": "Target", + "audit_col_message": "Message", + "audit_summary": "{errors} errors, {warnings} warnings", + "audit_contrast_label": "Contrast check (fg / bg RGB)", + "audit_contrast_run": "Check contrast", + + # Device matrix tab + "dm_devices": "Devices (JSON list)", + "dm_actions": "Action list (JSON)", + "dm_parallel": "Max parallel", + "dm_run": "Run matrix", + "dm_col_device": "Device", + "dm_col_platform": "Platform", + "dm_col_result": "Result", + "dm_col_time": "Time", + "dm_col_error": "Error", + "dm_summary": "{passed} passed, {failed} failed of {total}", + "dm_error": "Run failed: {error}", + + # Media checks tab + "media_audio_label": "Audio activity check", + "media_audio_duration": "Duration (s)", + "media_audio_threshold": "RMS threshold", + "media_audio_expect": "Expect sound", + "media_audio_run": "Record & check audio", + "media_video_label": "Video motion check", + "media_video_path": "Video file", + "media_video_browse": "Browse…", + "media_video_threshold": "Motion threshold", + "media_video_expect": "Expect motion", + "media_video_run": "Check video", + "media_result": "Result", + + # Assertions tab + "assert_kind": "Assertion", + "assert_kind_text": "Text on screen (OCR)", + "assert_kind_image": "Image on screen", + "assert_kind_pixel": "Pixel colour", + "assert_kind_window": "Window exists", + "assert_target": "Target", + "assert_target_text_hint": "Text / regex to find", + "assert_target_image_hint": "Template image path", + "assert_target_window_hint": "Window title contains", + "assert_pixel_xy": "x, y", + "assert_pixel_rgb": "r, g, b", + "assert_expect_present": "Expect present / match / exists", + "assert_regex": "Treat text as regex", + "assert_run": "Run assertion", + "assert_passed": "PASS", + "assert_failed": "FAIL", + + # Data Sources tab + "ds_kind": "Source kind", + "ds_path": "File path", + "ds_browse": "Browse…", + "ds_query": "SQL query (SELECT only)", + "ds_inline": "Inline JSON rows", + "ds_limit": "Row limit (0 = all)", + "ds_load": "Load rows", + "ds_row_count": "{n} rows loaded", + "ds_error": "Load failed: {error}", + + # Flaky Tests tab + "flaky_refresh": "Refresh", + "flaky_limit": "Runs to scan", + "flaky_min_runs": "Min runs", + "flaky_group_by": "Group by", + "flaky_col_key": "Script / Source", + "flaky_col_runs": "Runs", + "flaky_col_ok": "Pass", + "flaky_col_error": "Fail", + "flaky_col_flips": "Flips", + "flaky_col_flip_rate": "Flip rate", + "flaky_col_pass_rate": "Pass rate", + "flaky_col_last": "Last", + "flaky_col_flaky": "Flaky?", + "flaky_summary": "{flaky} flaky of {total} groups", + "flaky_summary_empty": "No run history yet", # Diagnostics tab "diag_run": "Run diagnostics", diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index b730d521..73497a1f 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -13,6 +13,13 @@ from je_auto_control.gui._auto_click_tab import AutoClickTabMixin from je_auto_control.gui._i18n_helpers import TranslatableMixin from je_auto_control.gui.accessibility_tab import AccessibilityTab +from je_auto_control.gui.assertions_tab import AssertionsTab +from je_auto_control.gui.data_source_tab import DataSourceTab +from je_auto_control.gui.flakiness_tab import FlakinessTab +from je_auto_control.gui.test_suite_tab import TestSuiteTab +from je_auto_control.gui.a11y_audit_tab import A11yAuditTab +from je_auto_control.gui.device_matrix_tab import DeviceMatrixTab +from je_auto_control.gui.media_checks_tab import MediaChecksTab from je_auto_control.gui.computer_use_tab import ComputerUseTab from je_auto_control.gui.chatops_tab import ChatOpsTab from je_auto_control.gui.dag_tab import DagTab @@ -159,6 +166,20 @@ def __init__(self, parent=None): category="automation") self._add_tab("email_triggers", "tab_email_triggers", EmailTriggersTab(), category="automation") + self._add_tab("test_suite", "tab_test_suite", TestSuiteTab(), + category="core") + self._add_tab("assertions", "tab_assertions", AssertionsTab(), + category="core") + self._add_tab("data_source", "tab_data_source", DataSourceTab(), + category="core") + self._add_tab("flakiness", "tab_flakiness", FlakinessTab(), + category="system") + self._add_tab("a11y_audit", "tab_a11y_audit", A11yAuditTab(), + category="core") + self._add_tab("device_matrix", "tab_device_matrix", DeviceMatrixTab(), + category="core") + self._add_tab("media_checks", "tab_media_checks", MediaChecksTab(), + category="core") self._add_tab("run_history", "tab_run_history", RunHistoryTab(), category="automation") self._add_tab("profiler", "tab_profiler", ProfilerTab(), diff --git a/je_auto_control/gui/media_checks_tab.py b/je_auto_control/gui/media_checks_tab.py new file mode 100644 index 00000000..f6b74e39 --- /dev/null +++ b/je_auto_control/gui/media_checks_tab.py @@ -0,0 +1,114 @@ +"""Media Checks tab: audio activity and video motion assertions. + +Thin wrapper over :func:`je_auto_control.assert_audio_activity` and +:func:`je_auto_control.assert_video_changes`. +""" +from typing import Optional + +from PySide6.QtWidgets import ( + QCheckBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QVBoxLayout, QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +import je_auto_control as ac + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class MediaChecksTab(TranslatableMixin, QWidget): + """Run a single audio or video media assertion and show the result.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._audio_duration = QDoubleSpinBox() + self._audio_duration.setRange(0.1, 60.0) + self._audio_duration.setValue(1.0) + self._audio_threshold = QDoubleSpinBox() + self._audio_threshold.setDecimals(4) + self._audio_threshold.setRange(0.0, 1.0) + self._audio_threshold.setValue(0.01) + self._audio_expect = QCheckBox(_t("media_audio_expect")) + self._audio_expect.setChecked(True) + self._video_path = QLineEdit() + self._video_threshold = QDoubleSpinBox() + self._video_threshold.setRange(0.0, 255.0) + self._video_threshold.setValue(1.0) + self._video_expect = QCheckBox(_t("media_video_expect")) + self._video_expect.setChecked(True) + self._result = QLabel() + self._result.setWordWrap(True) + self._build_layout() + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + root.addWidget(QLabel(_t("media_audio_label"))) + arow = QHBoxLayout() + arow.addWidget(QLabel(_t("media_audio_duration"))) + arow.addWidget(self._audio_duration) + arow.addWidget(QLabel(_t("media_audio_threshold"))) + arow.addWidget(self._audio_threshold) + arow.addWidget(self._audio_expect) + audio_btn = self._tr(QPushButton(), "media_audio_run") + audio_btn.clicked.connect(self._on_audio) + arow.addWidget(audio_btn) + arow.addStretch() + root.addLayout(arow) + + root.addWidget(QLabel(_t("media_video_label"))) + vrow = QHBoxLayout() + vrow.addWidget(QLabel(_t("media_video_path"))) + vrow.addWidget(self._video_path, stretch=1) + browse_btn = self._tr(QPushButton(), "media_video_browse") + browse_btn.clicked.connect(self._on_browse) + vrow.addWidget(browse_btn) + root.addLayout(vrow) + vrow2 = QHBoxLayout() + vrow2.addWidget(QLabel(_t("media_video_threshold"))) + vrow2.addWidget(self._video_threshold) + vrow2.addWidget(self._video_expect) + video_btn = self._tr(QPushButton(), "media_video_run") + video_btn.clicked.connect(self._on_video) + vrow2.addWidget(video_btn) + vrow2.addStretch() + root.addLayout(vrow2) + + root.addWidget(self._result) + root.addStretch() + + def _on_browse(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, _t("media_video_browse")) + if path: + self._video_path.setText(path) + + def _on_audio(self) -> None: + try: + result = ac.assert_audio_activity( + duration_s=self._audio_duration.value(), + threshold=self._audio_threshold.value(), + expect_sound=self._audio_expect.isChecked(), + raise_on_fail=False, + ) + except (RuntimeError, OSError, ValueError) as error: + self._result.setText(str(error)) + return + self._result.setText(result.message) + + def _on_video(self) -> None: + try: + result = ac.assert_video_changes( + self._video_path.text().strip(), + threshold=self._video_threshold.value(), + expect_motion=self._video_expect.isChecked(), + raise_on_fail=False, + ) + except (RuntimeError, OSError, ValueError, FileNotFoundError) as error: + self._result.setText(str(error)) + return + self._result.setText(result.message) diff --git a/je_auto_control/gui/test_suite_tab.py b/je_auto_control/gui/test_suite_tab.py new file mode 100644 index 00000000..3ef1b1dd --- /dev/null +++ b/je_auto_control/gui/test_suite_tab.py @@ -0,0 +1,163 @@ +"""Test Suites tab: run a QA suite spec and manage flaky quarantine. + +Thin wrapper over :func:`je_auto_control.run_suite`, the JUnit/Allure +report writers, and the quarantine store. Holds no business logic. +""" +import json +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QFileDialog, QHBoxLayout, QLabel, QListWidget, + QPlainTextEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, + QWidget, +) + +from je_auto_control.gui._i18n_helpers import TranslatableMixin +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) +import je_auto_control as ac +from je_auto_control.utils.quarantine import ( + auto_quarantine_from_flakiness, default_quarantine_store, +) + +_COLS = ("suite_col_case", "suite_col_status", "suite_col_time", + "suite_col_message") + + +def _t(key: str) -> str: + return language_wrapper.translate(key, key) + + +class TestSuiteTab(TranslatableMixin, QWidget): + """Run suites, render scored cases, and manage the quarantine list.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._tr_init() + self._spec = QPlainTextEdit() + self._spec.setPlaceholderText( + '{"name": "Demo", "cases": [{"name": "c1", "actions": []}]}', + ) + self._table = QTableWidget(0, len(_COLS)) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.horizontalHeader().setStretchLastSection(True) + self._summary = QLabel() + self._quarantine = QListWidget() + self._last_result = None + self._apply_headers() + self._build_layout() + self._refresh_quarantine() + + def retranslate(self) -> None: + TranslatableMixin.retranslate(self) + self._apply_headers() + + def _apply_headers(self) -> None: + self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) + + def _build_layout(self) -> None: + root = QVBoxLayout(self) + root.addWidget(QLabel(_t("suite_spec_label"))) + root.addWidget(self._spec, stretch=1) + controls = QHBoxLayout() + load_btn = self._tr(QPushButton(), "suite_load_file") + load_btn.clicked.connect(self._on_load_file) + run_btn = self._tr(QPushButton(), "suite_run") + run_btn.clicked.connect(self._on_run) + junit_btn = self._tr(QPushButton(), "suite_junit") + junit_btn.clicked.connect(self._on_junit) + allure_btn = self._tr(QPushButton(), "suite_allure") + allure_btn.clicked.connect(self._on_allure) + for btn in (load_btn, run_btn, junit_btn, allure_btn): + controls.addWidget(btn) + controls.addStretch() + root.addLayout(controls) + root.addWidget(self._table, stretch=2) + root.addWidget(self._summary) + root.addWidget(QLabel(_t("suite_q_label"))) + root.addWidget(self._quarantine) + qrow = QHBoxLayout() + auto_btn = self._tr(QPushButton(), "suite_q_auto") + auto_btn.clicked.connect(self._on_auto_quarantine) + remove_btn = self._tr(QPushButton(), "suite_q_remove") + remove_btn.clicked.connect(self._on_release_selected) + clear_btn = self._tr(QPushButton(), "suite_q_clear") + clear_btn.clicked.connect(self._on_clear_quarantine) + for btn in (auto_btn, remove_btn, clear_btn): + qrow.addWidget(btn) + qrow.addStretch() + root.addLayout(qrow) + + def _parse_spec(self): + return json.loads(self._spec.toPlainText() or "{}") + + def _on_load_file(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, _t("suite_load_file")) + if path: + with open(path, encoding="utf-8") as handle: + self._spec.setPlainText(handle.read()) + + def _on_run(self) -> None: + try: + result = ac.run_suite(self._parse_spec()) + except (ValueError, OSError, RuntimeError, json.JSONDecodeError) as err: + self._summary.setText(_t("suite_error").replace("{error}", str(err))) + return + self._last_result = result + self._render_result(result) + + def _render_result(self, result) -> None: + self._table.setRowCount(len(result.cases)) + for row, case in enumerate(result.cases): + values = (case.name, case.status, f"{case.duration_s:.3f}s", + case.message) + for col, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self._table.setItem(row, col, item) + self._summary.setText( + _t("suite_summary") + .replace("{passed}", str(result.passed)) + .replace("{failed}", str(result.failed)) + .replace("{errored}", str(result.errored)) + .replace("{skipped}", str(result.skipped)), + ) + + def _on_junit(self) -> None: + if self._last_result is None: + return + path, _ = QFileDialog.getSaveFileName(self, _t("suite_junit"), + "junit.xml") + if path: + ac.write_junit_xml(self._last_result, path) + + def _on_allure(self) -> None: + if self._last_result is None: + return + directory = QFileDialog.getExistingDirectory(self, _t("suite_allure")) + if directory: + ac.write_allure_results(self._last_result, directory) + + def _refresh_quarantine(self) -> None: + self._quarantine.clear() + for entry in default_quarantine_store().list(): + label = entry.name + if entry.reason: + label += f" ({entry.reason})" + self._quarantine.addItem(label) + + def _on_auto_quarantine(self) -> None: + auto_quarantine_from_flakiness() + self._refresh_quarantine() + + def _on_release_selected(self) -> None: + item = self._quarantine.currentItem() + if item is not None: + default_quarantine_store().remove(item.text().split(" (")[0]) + self._refresh_quarantine() + + def _on_clear_quarantine(self) -> None: + default_quarantine_store().clear() + self._refresh_quarantine() diff --git a/je_auto_control/utils/a11y_audit/__init__.py b/je_auto_control/utils/a11y_audit/__init__.py new file mode 100644 index 00000000..d80398dd --- /dev/null +++ b/je_auto_control/utils/a11y_audit/__init__.py @@ -0,0 +1,33 @@ +"""Accessibility & i18n auditing over the a11y tree + OCR. + +Public surface:: + + from je_auto_control import ( + run_audit, audit_missing_labels, audit_contrast, + detect_truncation, contrast_ratio, AuditReport, AuditIssue, + ) +""" +from je_auto_control.utils.a11y_audit.audit import ( + AuditIssue, + AuditReport, + audit_contrast, + audit_missing_labels, + contrast_ratio, + detect_truncation, + is_interactive, + relative_luminance, + run_audit, +) + + +__all__ = [ + "AuditIssue", + "AuditReport", + "audit_contrast", + "audit_missing_labels", + "contrast_ratio", + "detect_truncation", + "is_interactive", + "relative_luminance", + "run_audit", +] diff --git a/je_auto_control/utils/a11y_audit/audit.py b/je_auto_control/utils/a11y_audit/audit.py new file mode 100644 index 00000000..4c757e65 --- /dev/null +++ b/je_auto_control/utils/a11y_audit/audit.py @@ -0,0 +1,185 @@ +"""Accessibility & i18n auditing — reuse the a11y tree + OCR to find defects. + +The accessibility tree and OCR layer are normally used to *locate* widgets +to drive them. This module turns them around to *inspect* a UI for common +accessibility and localisation defects: + +* :func:`audit_missing_labels` — interactive widgets (buttons, menu items, + links, fields…) exposed through the a11y tree with no accessible name. +* :func:`contrast_ratio` / :func:`audit_contrast` — WCAG 2.x relative- + luminance contrast ratio between foreground and background colours, with + AA/AAA threshold checks. +* :func:`detect_truncation` — OCR strings ending in an ellipsis (text + clipped by a too-narrow control after translation). + +Every check accepts its inputs programmatically (elements / colour pairs / +strings) so it is fully unit-testable headlessly; :func:`run_audit` wires +the live a11y tree in for convenience. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" + +# WCAG 2.x AA / AAA contrast minimums for normal-size text. +WCAG_AA_NORMAL = 4.5 +WCAG_AAA_NORMAL = 7.0 + +# Substrings of accessibility roles that imply an actionable control which +# must carry an accessible name. Matched case-insensitively. +INTERACTIVE_ROLE_HINTS = frozenset({ + "button", "menuitem", "menu item", "checkbox", "check box", "radio", + "link", "hyperlink", "tab", "combobox", "combo box", "edit", "textbox", + "text box", "slider", "listitem", "list item", "treeitem", "tree item", + "switch", "spinbutton", "spin button", +}) + +_TRUNCATION_MARKERS = ("…", "...") + + +@dataclass(frozen=True) +class AuditIssue: + """One accessibility / i18n defect.""" + + kind: str + severity: str + message: str + target: str = "" + detail: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class AuditReport: + """Collected issues for an audited scope.""" + + issues: List[AuditIssue] = field(default_factory=list) + scope: str = "" + + @property + def error_count(self) -> int: + return sum(1 for i in self.issues if i.severity == SEVERITY_ERROR) + + @property + def warning_count(self) -> int: + return sum(1 for i in self.issues if i.severity == SEVERITY_WARNING) + + def to_dict(self) -> Dict[str, Any]: + return { + "scope": self.scope, + "total": len(self.issues), + "error_count": self.error_count, + "warning_count": self.warning_count, + "issues": [issue.to_dict() for issue in self.issues], + } + + +def is_interactive(role: str) -> bool: + """Return True when ``role`` names an actionable control.""" + lowered = (role or "").lower() + return any(hint in lowered for hint in INTERACTIVE_ROLE_HINTS) + + +def audit_missing_labels(elements: Iterable[Any]) -> List[AuditIssue]: + """Flag interactive elements whose accessible name is blank.""" + issues: List[AuditIssue] = [] + for element in elements: + name = getattr(element, "name", "") or "" + role = getattr(element, "role", "") or "" + if is_interactive(role) and not name.strip(): + issues.append(AuditIssue( + kind="missing_label", severity=SEVERITY_ERROR, + message=f"{role} has no accessible name", + target=role, + detail={"bounds": list(getattr(element, "bounds", []))}, + )) + return issues + + +def _channel(value: float) -> float: + srgb = max(0.0, min(255.0, float(value))) / 255.0 + return srgb / 12.92 if srgb <= 0.03928 else ((srgb + 0.055) / 1.055) ** 2.4 + + +def relative_luminance(rgb: Sequence[float]) -> float: + """WCAG relative luminance of an sRGB colour.""" + r, g, b = (_channel(rgb[i]) for i in range(3)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast_ratio(rgb1: Sequence[float], rgb2: Sequence[float]) -> float: + """WCAG contrast ratio between two colours (1.0 .. 21.0).""" + lum1, lum2 = relative_luminance(rgb1), relative_luminance(rgb2) + lighter, darker = max(lum1, lum2), min(lum1, lum2) + return (lighter + 0.05) / (darker + 0.05) + + +def audit_contrast(pairs: Iterable[Dict[str, Any]], + min_ratio: float = WCAG_AA_NORMAL) -> List[AuditIssue]: + """Flag foreground/background colour pairs below ``min_ratio``. + + Each pair is ``{"foreground": [r,g,b], "background": [r,g,b], + "label": "..."}``. + """ + issues: List[AuditIssue] = [] + for pair in pairs: + ratio = contrast_ratio(pair["foreground"], pair["background"]) + if ratio < min_ratio: + issues.append(AuditIssue( + kind="contrast", severity=SEVERITY_ERROR, + message=(f"contrast {ratio:.2f}:1 below " + f"{min_ratio:.1f}:1 minimum"), + target=str(pair.get("label", "")), + detail={"ratio": round(ratio, 2), + "foreground": list(pair["foreground"]), + "background": list(pair["background"])}, + )) + return issues + + +def detect_truncation(texts: Iterable[str], + markers: Tuple[str, ...] = _TRUNCATION_MARKERS, + ) -> List[AuditIssue]: + """Flag OCR strings that end with an ellipsis (likely clipped text).""" + issues: List[AuditIssue] = [] + for text in texts: + stripped = (text or "").rstrip() + if stripped and stripped.endswith(markers): + issues.append(AuditIssue( + kind="truncation", severity=SEVERITY_WARNING, + message="text appears truncated (ends with ellipsis)", + target=stripped, + )) + return issues + + +def run_audit(app_name: Optional[str] = None, + elements: Optional[Iterable[Any]] = None, + contrast_pairs: Optional[Iterable[Dict[str, Any]]] = None, + texts: Optional[Iterable[str]] = None, + min_ratio: float = WCAG_AA_NORMAL, + max_results: int = 500) -> AuditReport: + """Run every applicable check and return a combined :class:`AuditReport`. + + When ``elements`` is omitted the live accessibility tree is queried. + ``contrast_pairs`` and ``texts`` are optional caller-supplied inputs. + """ + if elements is None: + from je_auto_control.utils.accessibility.accessibility_api import ( + list_accessibility_elements, + ) + elements = list_accessibility_elements( + app_name=app_name, max_results=int(max_results), + ) + report = AuditReport(scope=app_name or "screen") + report.issues.extend(audit_missing_labels(elements)) + if contrast_pairs is not None: + report.issues.extend(audit_contrast(contrast_pairs, min_ratio)) + if texts is not None: + report.issues.extend(detect_truncation(texts)) + return report diff --git a/je_auto_control/utils/assertion/__init__.py b/je_auto_control/utils/assertion/__init__.py new file mode 100644 index 00000000..b330bc58 --- /dev/null +++ b/je_auto_control/utils/assertion/__init__.py @@ -0,0 +1,25 @@ +"""Assertion DSL — screen-state verification for automation scripts. + +Public surface:: + + from je_auto_control import ( + assert_text, assert_image, assert_pixel, assert_window, + AssertionResult, + ) +""" +from je_auto_control.utils.assertion.assertions import ( + AssertionResult, + assert_image, + assert_pixel, + assert_text, + assert_window, +) + + +__all__ = [ + "AssertionResult", + "assert_image", + "assert_pixel", + "assert_text", + "assert_window", +] diff --git a/je_auto_control/utils/assertion/assertions.py b/je_auto_control/utils/assertion/assertions.py new file mode 100644 index 00000000..d47cd774 --- /dev/null +++ b/je_auto_control/utils/assertion/assertions.py @@ -0,0 +1,239 @@ +"""Assertion DSL — verify the screen state, not just drive it. + +A naive automation script only *performs* actions; it never checks that +they produced the expected result. These helpers close that gap: each +``assert_*`` function observes the current screen / window state, decides +whether it matches the caller's expectation, and (by default) raises +:class:`AutoControlAssertionException` on mismatch so a script — or a +``pytest`` test, or a scheduled run — fails loudly at the point of the +broken assumption. + +Every function returns an :class:`AssertionResult` describing what was +expected versus observed, and can optionally save a screenshot of the +failing screen for the run-history / audit trail. + +The module is GUI-free: it depends only on the headless wrapper / OCR +layer, so ``import je_auto_control as ac; ac.assert_text(...)`` works +without instantiating Qt. +""" +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, +) +from je_auto_control.utils.logging.logging_instance import autocontrol_logger + +_DEFAULT_THRESHOLD = 0.9 +_DEFAULT_MIN_CONFIDENCE = 60.0 + + +@dataclass(frozen=True) +class AssertionResult: + """Outcome of a single assertion check.""" + + kind: str + passed: bool + message: str + expected: Any = None + actual: Any = None + screenshot_path: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def _capture_failure_screenshot(kind: str) -> Optional[str]: + """Best-effort screenshot of the failing screen; never raises.""" + try: + from je_auto_control.wrapper.auto_control_screen import screenshot + except ImportError as error: + autocontrol_logger.warning("assert capture import failed: %r", error) + return None + target = Path.home() / ".je_auto_control" / "assertions" + try: + target.mkdir(parents=True, exist_ok=True) + path = target / f"assert_{kind}_{int(time.time() * 1000)}.png" + screenshot(file_path=str(path)) + return str(path) + except (OSError, RuntimeError, ValueError) as error: + autocontrol_logger.warning("assert screenshot failed: %r", error) + return None + + +def _finalize(kind: str, passed: bool, message: str, + expected: Any, actual: Any, + raise_on_fail: bool, capture_on_fail: bool) -> AssertionResult: + """Build the result, capturing + raising on failure as requested.""" + screenshot_path = ( + _capture_failure_screenshot(kind) + if (not passed and capture_on_fail) else None + ) + result = AssertionResult( + kind=kind, passed=passed, message=message, + expected=expected, actual=actual, screenshot_path=screenshot_path, + ) + if not passed: + autocontrol_logger.info("assertion failed: %s", message) + if raise_on_fail: + raise AutoControlAssertionException(message) + return result + + +def _region_text(region: Optional[Sequence[int]], lang: str, + min_confidence: float) -> str: + """Return the concatenated OCR text inside ``region`` (or whole screen).""" + from je_auto_control.utils.ocr.ocr_engine import read_text_in_region + matches = read_text_in_region( + region=region, lang=lang, min_confidence=min_confidence, + ) + return " ".join(match.text for match in matches) + + +def assert_text(text: str, + region: Optional[Sequence[int]] = None, + lang: str = "eng", + regex: bool = False, + present: bool = True, + ignore_case: bool = True, + min_confidence: float = _DEFAULT_MIN_CONFIDENCE, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> AssertionResult: + """Assert that ``text`` is (or is not) visible on screen via OCR. + + :param regex: treat ``text`` as a regular expression instead of a + literal substring. + :param present: when True (default) the text must be found; when + False the assertion passes only when the text is absent. + """ + if regex: + from je_auto_control.utils.ocr.ocr_engine import find_text_regex + found = bool(find_text_regex( + text, lang=lang, region=region, min_confidence=min_confidence, + )) + observed = _region_text(region, lang, min_confidence) + else: + observed = _region_text(region, lang, min_confidence) + haystack = observed.lower() if ignore_case else observed + needle = text.lower() if ignore_case else text + found = needle in haystack + passed = (found == present) + state = "present" if present else "absent" + message = ( + f"assert_text passed: {text!r} is {state}" + if passed else + f"assert_text failed: expected {text!r} to be {state}; " + f"OCR saw {observed!r}" + ) + return _finalize( + "text", passed, message, + expected={"text": text, "present": present, "regex": regex}, + actual=observed, raise_on_fail=raise_on_fail, + capture_on_fail=capture_on_fail, + ) + + +def assert_image(template_path: str, + threshold: float = _DEFAULT_THRESHOLD, + present: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> AssertionResult: + """Assert that the template image is (or is not) on screen.""" + from je_auto_control.utils.exception.exceptions import ( + ImageNotFoundException, + ) + from je_auto_control.wrapper.auto_control_image import locate_image_center + coords: Optional[Any] = None + try: + coords = locate_image_center(template_path, threshold) + found = True + except (ImageNotFoundException, OSError, RuntimeError, ValueError, + TypeError): + found = False + passed = (found == present) + state = "present" if present else "absent" + message = ( + f"assert_image passed: {template_path!r} is {state}" + if passed else + f"assert_image failed: expected {template_path!r} to be {state} " + f"(threshold={threshold})" + ) + return _finalize( + "image", passed, message, + expected={"template_path": template_path, "present": present, + "threshold": threshold}, + actual={"found": found, "coords": list(coords) if coords else None}, + raise_on_fail=raise_on_fail, capture_on_fail=capture_on_fail, + ) + + +def _pixel_matches(x: int, y: int, rgb: Sequence[int], tolerance: int) -> bool: + """Return True when the live pixel at (x, y) matches rgb within tolerance.""" + from je_auto_control.wrapper.auto_control_screen import get_pixel + color = get_pixel(x, y) + if color is None or len(color) < 3 or len(rgb) < 3: + return False + return all(abs(int(color[i]) - int(rgb[i])) <= tolerance for i in range(3)) + + +def assert_pixel(x: int, y: int, rgb: Sequence[int], + tolerance: int = 0, + match: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> AssertionResult: + """Assert the pixel at ``(x, y)`` matches (or differs from) ``rgb``.""" + from je_auto_control.wrapper.auto_control_screen import get_pixel + actual_color = get_pixel(int(x), int(y)) + matched = _pixel_matches(int(x), int(y), rgb, int(tolerance)) + passed = (matched == match) + verb = "match" if match else "differ from" + message = ( + f"assert_pixel passed: ({x},{y}) {verb} {list(rgb)}" + if passed else + f"assert_pixel failed: expected ({x},{y})={actual_color} to " + f"{verb} {list(rgb)} (tolerance={tolerance})" + ) + return _finalize( + "pixel", passed, message, + expected={"x": x, "y": y, "rgb": list(rgb), "match": match, + "tolerance": tolerance}, + actual=list(actual_color) if actual_color else None, + raise_on_fail=raise_on_fail, capture_on_fail=capture_on_fail, + ) + + +def _window_titles() -> List[str]: + """Return the titles of every visible top-level window.""" + from je_auto_control.wrapper.auto_control_window import list_windows + return [title for _, title in list_windows()] + + +def assert_window(title: str, + exists: bool = True, + ignore_case: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> AssertionResult: + """Assert a window whose title contains ``title`` does (not) exist.""" + titles = _window_titles() + needle = title.lower() if ignore_case else title + found = any( + (t.lower() if ignore_case else t).find(needle) >= 0 for t in titles + ) + passed = (found == exists) + state = "exist" if exists else "not exist" + message = ( + f"assert_window passed: a window matching {title!r} does {state}" + if passed else + f"assert_window failed: expected a window matching {title!r} to " + f"{state}; open titles={titles}" + ) + return _finalize( + "window", passed, message, + expected={"title": title, "exists": exists}, + actual=titles, raise_on_fail=raise_on_fail, + capture_on_fail=capture_on_fail, + ) diff --git a/je_auto_control/utils/data_source/__init__.py b/je_auto_control/utils/data_source/__init__.py new file mode 100644 index 00000000..30b45094 --- /dev/null +++ b/je_auto_control/utils/data_source/__init__.py @@ -0,0 +1,13 @@ +"""Data-driven execution — load rows from CSV / JSON / SQLite / Excel. + +Public surface:: + + from je_auto_control import load_rows, data_source_kinds +""" +from je_auto_control.utils.data_source.data_source import ( + load_rows, + supported_kinds as data_source_kinds, +) + + +__all__ = ["load_rows", "data_source_kinds"] diff --git a/je_auto_control/utils/data_source/data_source.py b/je_auto_control/utils/data_source/data_source.py new file mode 100644 index 00000000..54e525b4 --- /dev/null +++ b/je_auto_control/utils/data_source/data_source.py @@ -0,0 +1,158 @@ +"""Data-driven execution — feed rows into ``${var}`` scripts. + +Runtime variables plus ``AC_for_each`` already let a script iterate over a +list, but the list had to be hard-coded. This module supplies the missing +*data source*: load tabular rows from CSV, JSON, SQLite, Excel, or an +inline literal, then drive the same action body once per row. + +A source is a plain dict so it round-trips through JSON action files, +the socket server, and the REST API:: + + {"kind": "csv", "path": "users.csv"} + {"kind": "json", "path": "cases.json"} + {"kind": "sqlite", "path": "app.db", "query": "SELECT * FROM users"} + {"kind": "excel", "path": "matrix.xlsx", "sheet": "Sheet1"} + {"kind": "inline", "rows": [{"user": "a"}, {"user": "b"}]} + +:func:`load_rows` always returns ``List[Dict[str, Any]]`` so each row can +be bound to a single variable whose keys are addressable as +``${row.user}`` downstream. +""" +from __future__ import annotations + +import csv +import json +import os +import sqlite3 +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +_READ_ONLY_SQL_PREFIXES = ("select", "with") + + +def _resolve_path(raw: str) -> Path: + """Resolve a user-supplied path and verify it points at a real file.""" + if not raw or not isinstance(raw, str): + raise ValueError("data source 'path' must be a non-empty string") + resolved = Path(os.path.realpath(os.path.expanduser(raw))) + if not resolved.is_file(): + raise FileNotFoundError(f"data source file not found: {resolved}") + return resolved + + +def _load_csv(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Load rows from a CSV file; the header row supplies the dict keys.""" + path = _resolve_path(source["path"]) + delimiter = str(source.get("delimiter", ",")) + encoding = str(source.get("encoding", "utf-8")) + with path.open("r", encoding=encoding, newline="") as handle: + reader = csv.DictReader(handle, delimiter=delimiter) + return [dict(row) for row in reader] + + +def _coerce_json_rows(payload: Any) -> List[Dict[str, Any]]: + """Normalise parsed JSON into a list of row dicts.""" + if isinstance(payload, dict): + payload = payload.get("rows", []) + if not isinstance(payload, list): + raise ValueError("JSON data source must be a list or {'rows': [...]}") + rows: List[Dict[str, Any]] = [] + for item in payload: + if not isinstance(item, dict): + raise ValueError("each JSON data row must be an object") + rows.append(dict(item)) + return rows + + +def _load_json(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Load rows from a JSON file (a list of objects, or ``{"rows": [...]}``).""" + path = _resolve_path(source["path"]) + encoding = str(source.get("encoding", "utf-8")) + with path.open("r", encoding=encoding) as handle: + return _coerce_json_rows(json.load(handle)) + + +def _validate_select(query: str) -> str: + """Reject anything but a single read-only SELECT/WITH statement.""" + cleaned = query.strip().rstrip(";").strip() + if ";" in cleaned: + raise ValueError("sqlite data source allows a single statement only") + if not cleaned.lower().startswith(_READ_ONLY_SQL_PREFIXES): + raise ValueError("sqlite data source query must start with SELECT/WITH") + return cleaned + + +def _load_sqlite(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Run a read-only SELECT against a SQLite file and return dict rows.""" + path = _resolve_path(source["path"]) + query = _validate_select(str(source["query"])) + uri = f"file:{path}?mode=ro" + with sqlite3.connect(uri, uri=True) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute(query).fetchall() + return [dict(row) for row in rows] + + +def _load_excel(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Load rows from an .xlsx file; the first row supplies the dict keys.""" + try: + from openpyxl import load_workbook + except ImportError as error: + raise RuntimeError( + "Excel data sources require openpyxl (pip install openpyxl).", + ) from error + path = _resolve_path(source["path"]) + workbook = load_workbook(filename=str(path), read_only=True, data_only=True) + try: + sheet_name = source.get("sheet") + worksheet = workbook[sheet_name] if sheet_name else workbook.active + rows_iter = worksheet.iter_rows(values_only=True) + header = next(rows_iter, None) + if header is None: + return [] + keys = [str(cell) for cell in header] + return [dict(zip(keys, values)) for values in rows_iter] + finally: + workbook.close() + + +def _load_inline(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return rows supplied directly in the spec under ``rows``.""" + return _coerce_json_rows(source.get("rows", [])) + + +_LOADERS: Dict[str, Callable[[Dict[str, Any]], List[Dict[str, Any]]]] = { + "csv": _load_csv, + "json": _load_json, + "sqlite": _load_sqlite, + "excel": _load_excel, + "inline": _load_inline, +} + + +def supported_kinds() -> List[str]: + """Return the data-source kinds this module can load.""" + return sorted(_LOADERS) + + +def load_rows(source: Dict[str, Any], + limit: Optional[int] = None) -> List[Dict[str, Any]]: + """Load tabular rows from ``source``; return a list of row dicts. + + :param source: a spec dict with a ``kind`` key (one of + :func:`supported_kinds`) plus kind-specific fields. + :param limit: optional cap on the number of rows returned. + """ + if not isinstance(source, dict): + raise ValueError("data source must be a dict with a 'kind' key") + kind = str(source.get("kind", "")).strip().lower() + loader = _LOADERS.get(kind) + if loader is None: + raise ValueError( + f"unknown data source kind {kind!r}; " + f"expected one of {supported_kinds()}" + ) + rows = loader(source) + if limit is not None and limit >= 0: + rows = rows[: int(limit)] + return rows diff --git a/je_auto_control/utils/device_matrix/__init__.py b/je_auto_control/utils/device_matrix/__init__.py new file mode 100644 index 00000000..6d7287dc --- /dev/null +++ b/je_auto_control/utils/device_matrix/__init__.py @@ -0,0 +1,14 @@ +"""Mobile device matrix — parallel script execution across devices. + +Public surface:: + + from je_auto_control import run_on_devices, DeviceResult, MatrixReport +""" +from je_auto_control.utils.device_matrix.matrix import ( + DeviceResult, + MatrixReport, + run_on_devices, +) + + +__all__ = ["DeviceResult", "MatrixReport", "run_on_devices"] diff --git a/je_auto_control/utils/device_matrix/matrix.py b/je_auto_control/utils/device_matrix/matrix.py new file mode 100644 index 00000000..61e94196 --- /dev/null +++ b/je_auto_control/utils/device_matrix/matrix.py @@ -0,0 +1,124 @@ +"""Mobile device matrix — run one action script across many devices. + +Single-device Android (adb / uiautomator2) and iOS (WebDriverAgent) control +already exists. This module fans a single action list out across a list of +devices *in parallel*, giving each device its own isolated executor (so the +runtime variable scopes never collide between threads) and aggregating the +per-device pass/fail outcome. + +The action list addresses the current device through a bound variable, e.g. +``${device.serial}`` / ``${device.url}``, so the same script targets every +device:: + + run_on_devices( + actions=[["AC_android_tap", {"x": 100, "y": 200, + "serial": "${device.serial}"}]], + devices=[{"platform": "android", "serial": "emulator-5554"}, + {"platform": "android", "serial": "emulator-5556"}], + ) +""" +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlAssertionException, +) + + +@dataclass +class DeviceResult: + """Outcome of running the script against one device.""" + + device_id: str + platform: str + success: bool + duration_s: float = 0.0 + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class MatrixReport: + """Aggregated per-device outcomes.""" + + results: List[DeviceResult] = field(default_factory=list) + + @property + def total(self) -> int: + return len(self.results) + + @property + def passed(self) -> int: + return sum(1 for r in self.results if r.success) + + @property + def failed(self) -> int: + return self.total - self.passed + + @property + def success(self) -> bool: + return self.failed == 0 and self.total > 0 + + def to_dict(self) -> Dict[str, Any]: + return { + "total": self.total, + "passed": self.passed, + "failed": self.failed, + "success": self.success, + "results": [r.to_dict() for r in self.results], + } + + +def _device_id(device: Dict[str, Any], index: int) -> str: + return str(device.get("serial") or device.get("url") or f"device-{index}") + + +def _run_one_device(actions: List[Any], device: Dict[str, Any], + index: int, var_name: str) -> DeviceResult: + """Run ``actions`` against a single device on a fresh executor.""" + from je_auto_control.utils.executor.action_executor import Executor + runner = Executor() + runner.variables.set(var_name, device) + device_id = _device_id(device, index) + platform = str(device.get("platform", "")) + started = time.monotonic() + try: + runner.execute_action(actions, raise_on_error=True) + return DeviceResult(device_id, platform, True, + time.monotonic() - started) + except (AutoControlAssertionException, AutoControlActionException, + OSError, RuntimeError, TypeError, ValueError) as error: + return DeviceResult(device_id, platform, False, + time.monotonic() - started, repr(error)) + + +def run_on_devices(actions: List[Any], + devices: List[Dict[str, Any]], + max_parallel: int = 4, + var_name: str = "device") -> MatrixReport: + """Run ``actions`` against every device in parallel; aggregate results. + + :param actions: an AC_* action list (may reference ``${device.*}``). + :param devices: list of device specs (``platform`` + ``serial`` / ``url``). + :param max_parallel: maximum concurrent device runs. + :param var_name: variable each device spec is bound to during the run. + """ + if not isinstance(devices, list) or not devices: + raise ValueError("devices must be a non-empty list of device specs") + if not isinstance(actions, list): + raise ValueError("actions must be a list") + workers = max(1, min(int(max_parallel), len(devices))) + report = MatrixReport() + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [ + pool.submit(_run_one_device, actions, device, index, var_name) + for index, device in enumerate(devices) + ] + report.results = [future.result() for future in futures] + return report diff --git a/je_auto_control/utils/exception/exceptions.py b/je_auto_control/utils/exception/exceptions.py index 50d30cb0..6a4a755b 100644 --- a/je_auto_control/utils/exception/exceptions.py +++ b/je_auto_control/utils/exception/exceptions.py @@ -60,6 +60,10 @@ class AutoControlAddCommandException(Exception): pass +class AutoControlAssertionException(Exception): + """Raised when an ``AC_assert_*`` check fails.""" + + class AutoControlArgparseException(Exception): pass diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 064d2db1..c8b9175f 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -573,6 +573,209 @@ def _redact_screenshot(file_path: str, } +def _assert_text(text: str, + region: Optional[List[int]] = None, + lang: str = "eng", + regex: bool = False, + present: bool = True, + ignore_case: bool = True, + min_confidence: float = 60.0, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + """Executor adapter: assert OCR text is (not) on screen.""" + from je_auto_control.utils.assertion import assert_text + return assert_text( + text, region=region, lang=lang, regex=bool(regex), + present=bool(present), ignore_case=bool(ignore_case), + min_confidence=float(min_confidence), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def _assert_image(template_path: str, + threshold: float = 0.9, + present: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + """Executor adapter: assert a template image is (not) on screen.""" + from je_auto_control.utils.assertion import assert_image + return assert_image( + template_path, threshold=float(threshold), present=bool(present), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def _assert_pixel(x: int, y: int, rgb: List[int], + tolerance: int = 0, + match: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + """Executor adapter: assert a pixel matches (or differs from) ``rgb``.""" + from je_auto_control.utils.assertion import assert_pixel + return assert_pixel( + int(x), int(y), rgb, tolerance=int(tolerance), match=bool(match), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def _assert_window(title: str, + exists: bool = True, + ignore_case: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + """Executor adapter: assert a window matching ``title`` does (not) exist.""" + from je_auto_control.utils.assertion import assert_window + return assert_window( + title, exists=bool(exists), ignore_case=bool(ignore_case), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def _load_data(source: Dict[str, Any]) -> List[Dict[str, Any]]: + """Executor adapter: load tabular rows from a data source spec.""" + from je_auto_control.utils.data_source import load_rows + return load_rows(source) + + +def _flaky_report(limit: int = 500, + min_runs: int = 2, + group_by: str = "script_path") -> Dict[str, Any]: + """Executor adapter: score run-history flakiness per script / source.""" + from je_auto_control.utils.flakiness import analyze_flakiness + return analyze_flakiness( + limit=int(limit), min_runs=int(min_runs), group_by=group_by, + ).to_dict() + + +def _run_suite(spec: Dict[str, Any], + tags: Optional[List[str]] = None, + respect_quarantine: bool = True, + junit_path: Optional[str] = None, + allure_dir: Optional[str] = None) -> Dict[str, Any]: + """Executor adapter: run a QA suite and optionally write CI reports.""" + from je_auto_control.utils.test_suite import ( + run_suite, write_allure_results, write_junit_xml, + ) + result = run_suite( + spec, executor=executor, tags=tags, + respect_quarantine=bool(respect_quarantine), + ) + payload = result.to_dict() + reports: Dict[str, Any] = {} + if junit_path: + reports["junit"] = write_junit_xml(result, junit_path) + if allure_dir: + reports["allure"] = write_allure_results(result, allure_dir) + if reports: + payload["reports"] = reports + return payload + + +def _quarantine_add(name: str, reason: str = "") -> Dict[str, Any]: + from je_auto_control.utils.quarantine import default_quarantine_store + return default_quarantine_store().add(name, reason=reason).to_dict() + + +def _quarantine_remove(name: str) -> Dict[str, Any]: + from je_auto_control.utils.quarantine import default_quarantine_store + return {"name": name, "removed": default_quarantine_store().remove(name)} + + +def _quarantine_list() -> List[Dict[str, Any]]: + from je_auto_control.utils.quarantine import default_quarantine_store + return [entry.to_dict() for entry in default_quarantine_store().list()] + + +def _quarantine_clear() -> Dict[str, Any]: + from je_auto_control.utils.quarantine import default_quarantine_store + return {"cleared": default_quarantine_store().clear()} + + +def _quarantine_auto(flip_rate_threshold: float = 0.5, + min_runs: int = 3, + limit: int = 500, + group_by: str = "script_path") -> List[Dict[str, Any]]: + from je_auto_control.utils.quarantine import auto_quarantine_from_flakiness + return [ + entry.to_dict() + for entry in auto_quarantine_from_flakiness( + flip_rate_threshold=float(flip_rate_threshold), + min_runs=int(min_runs), limit=int(limit), group_by=group_by, + ) + ] + + +def _audit_accessibility(app_name: Optional[str] = None, + contrast_pairs: Optional[List[Dict[str, Any]]] = None, + texts: Optional[List[str]] = None, + min_ratio: float = 4.5, + max_results: int = 500) -> Dict[str, Any]: + """Executor adapter: run the accessibility / i18n audit.""" + from je_auto_control.utils.a11y_audit import run_audit + return run_audit( + app_name=app_name, contrast_pairs=contrast_pairs, texts=texts, + min_ratio=float(min_ratio), max_results=int(max_results), + ).to_dict() + + +def _audit_contrast(foreground: List[int], background: List[int], + min_ratio: float = 4.5) -> Dict[str, Any]: + """Executor adapter: WCAG contrast ratio for one colour pair.""" + from je_auto_control.utils.a11y_audit import contrast_ratio + ratio = contrast_ratio(foreground, background) + return { + "ratio": round(ratio, 2), + "passes_aa": ratio >= float(min_ratio), + "foreground": list(foreground), "background": list(background), + } + + +def _run_device_matrix(actions: List[Any], devices: List[Dict[str, Any]], + max_parallel: int = 4, + var_name: str = "device") -> Dict[str, Any]: + """Executor adapter: run an action list across many devices in parallel.""" + from je_auto_control.utils.device_matrix import run_on_devices + return run_on_devices( + actions, devices, max_parallel=int(max_parallel), var_name=var_name, + ).to_dict() + + +def _assert_audio(duration_s: float = 1.0, + threshold: float = 0.01, + expect_sound: bool = True, + samplerate: int = 44100, + channels: int = 1, + raise_on_fail: bool = True) -> Dict[str, Any]: + """Executor adapter: assert audio activity / silence.""" + from je_auto_control.utils.media_assert import assert_audio_activity + return assert_audio_activity( + duration_s=float(duration_s), threshold=float(threshold), + expect_sound=bool(expect_sound), samplerate=int(samplerate), + channels=int(channels), raise_on_fail=bool(raise_on_fail), + ).to_dict() + + +def _assert_video_changes(video_path: str, + start_s: float = 0.0, + end_s: Optional[float] = None, + threshold: float = 1.0, + expect_motion: bool = True, + region: Optional[List[int]] = None, + raise_on_fail: bool = True) -> Dict[str, Any]: + """Executor adapter: assert a video segment has motion / is static.""" + from je_auto_control.utils.media_assert import assert_video_changes + return assert_video_changes( + video_path, start_s=float(start_s), + end_s=None if end_s is None else float(end_s), + threshold=float(threshold), expect_motion=bool(expect_motion), + region=region, raise_on_fail=bool(raise_on_fail), + ).to_dict() + + def _computer_use(goal: str, display_width_px: Optional[int] = None, display_height_px: Optional[int] = None, @@ -1758,6 +1961,39 @@ def __init__(self): "AC_self_heal_log_list": _self_heal_log_list, "AC_self_heal_log_clear": _self_heal_log_clear, + # Assertion DSL (verify screen state; raise on mismatch) + "AC_assert_text": _assert_text, + "AC_assert_image": _assert_image, + "AC_assert_pixel": _assert_pixel, + "AC_assert_window": _assert_window, + + # Data-driven execution (load rows from CSV / JSON / SQLite / ...) + "AC_load_data": _load_data, + + # Flaky-test detection (analytics over the run-history store) + "AC_flaky_report": _flaky_report, + + # QA suite runner + CI report output (JUnit / Allure) + "AC_run_suite": _run_suite, + + # Flaky quarantine (skip known-unstable cases in suites) + "AC_quarantine_add": _quarantine_add, + "AC_quarantine_remove": _quarantine_remove, + "AC_quarantine_list": _quarantine_list, + "AC_quarantine_clear": _quarantine_clear, + "AC_quarantine_auto": _quarantine_auto, + + # Accessibility / i18n audit (missing labels, contrast, truncation) + "AC_audit_accessibility": _audit_accessibility, + "AC_audit_contrast": _audit_contrast, + + # Mobile device matrix (parallel script across devices) + "AC_run_device_matrix": _run_device_matrix, + + # Media assertions (audio activity, video motion) + "AC_assert_audio": _assert_audio, + "AC_assert_video_changes": _assert_video_changes, + # Computer-use (Anthropic computer_20250124 closed-loop agent) "AC_computer_use": _computer_use, diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index f66b97d8..049f2871 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -263,6 +263,37 @@ def exec_for_each(executor: Any, args: Mapping[str, Any]) -> int: return iterations +def exec_for_each_row(executor: Any, args: Mapping[str, Any]) -> int: + """Load rows from a data source and run ``body`` once per row. + + ``source`` is a data-source spec dict (see + :mod:`je_auto_control.utils.data_source`). Each row dict is bound to + the variable named by ``as`` (default ``row``) so the body can read + ``${row.column}``. Honours ``AC_break`` / ``AC_continue``. + """ + from je_auto_control.utils.data_source import load_rows + source = args.get("source") + if not isinstance(source, dict): + raise AutoControlActionException( + "AC_for_each_row: 'source' must be a data-source spec dict" + ) + rows = load_rows(source, limit=args.get("limit")) + var_name = args.get("as", "row") + body = args.get("body") or [] + iterations = 0 + for row in rows: + executor.variables.set(var_name, row) + try: + executor.execute_action(body, _validated=True) + except LoopContinue: + iterations += 1 + continue + except LoopBreak: + break + iterations += 1 + return iterations + + BLOCK_COMMANDS: Dict[str, Callable[[Any, Mapping[str, Any]], Any]] = { "AC_if_image_found": exec_if_image_found, "AC_if_pixel": exec_if_pixel, @@ -279,4 +310,5 @@ def exec_for_each(executor: Any, args: Mapping[str, Any]) -> int: "AC_get_var": exec_get_var, "AC_inc_var": exec_inc_var, "AC_for_each": exec_for_each, + "AC_for_each_row": exec_for_each_row, } diff --git a/je_auto_control/utils/flakiness/__init__.py b/je_auto_control/utils/flakiness/__init__.py new file mode 100644 index 00000000..1b796b94 --- /dev/null +++ b/je_auto_control/utils/flakiness/__init__.py @@ -0,0 +1,16 @@ +"""Flaky-test detection — analytics over the run-history store. + +Public surface:: + + from je_auto_control import ( + analyze_flakiness, FlakinessReport, FlakyEntry, + ) +""" +from je_auto_control.utils.flakiness.flakiness import ( + FlakinessReport, + FlakyEntry, + analyze_flakiness, +) + + +__all__ = ["FlakinessReport", "FlakyEntry", "analyze_flakiness"] diff --git a/je_auto_control/utils/flakiness/flakiness.py b/je_auto_control/utils/flakiness/flakiness.py new file mode 100644 index 00000000..4259a0ff --- /dev/null +++ b/je_auto_control/utils/flakiness/flakiness.py @@ -0,0 +1,134 @@ +"""Flaky-test detection — score intermittent failures from run history. + +The SQLite run-history store already records every scheduler / trigger / +hotkey / REST run with an ``ok`` or ``error`` status. A *flaky* script is +one that produces *both* outcomes over time without its definition +changing — the most expensive kind of failure to chase manually. + +:func:`analyze_flakiness` groups historical runs by script (or source id), +counts pass/fail outcomes and the number of pass<->fail *flips* in +chronological order, and emits a per-script flakiness score so a flaky +script surfaces above one that is consistently green or consistently red. + +This is read-only analytics over data the framework already collects; it +adds no new storage and has zero Qt dependency. +""" +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +from je_auto_control.utils.run_history.history_store import ( + HistoryStore, STATUS_ERROR, STATUS_OK, default_history_store, +) + +_GROUP_KEYS = ("script_path", "source_id") + + +@dataclass(frozen=True) +class FlakyEntry: + """Flakiness statistics for one script / source group.""" + + key: str + total_runs: int + ok: int + error: int + flips: int + flip_rate: float + pass_rate: float + last_status: str + flaky: bool + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class FlakinessReport: + """Ranked flakiness across every qualifying group.""" + + generated_at: float + group_by: str + total_groups: int + flaky_count: int + entries: List[FlakyEntry] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "generated_at": self.generated_at, + "group_by": self.group_by, + "total_groups": self.total_groups, + "flaky_count": self.flaky_count, + "entries": [entry.to_dict() for entry in self.entries], + } + + +def _count_flips(statuses: List[str]) -> int: + """Count adjacent pass<->fail transitions in a chronological list.""" + return sum( + 1 for prev, curr in zip(statuses, statuses[1:]) if prev != curr + ) + + +def _entry_for_group(key: str, statuses: List[str]) -> FlakyEntry: + """Build a :class:`FlakyEntry` from chronological ``ok``/``error`` values.""" + total = len(statuses) + ok = statuses.count(STATUS_OK) + error = statuses.count(STATUS_ERROR) + flips = _count_flips(statuses) + flip_rate = flips / (total - 1) if total > 1 else 0.0 + pass_rate = ok / total if total else 0.0 + return FlakyEntry( + key=key, total_runs=total, ok=ok, error=error, + flips=flips, flip_rate=round(flip_rate, 4), + pass_rate=round(pass_rate, 4), + last_status=statuses[-1], flaky=(ok > 0 and error > 0), + ) + + +def _grouped_statuses(records: List[Any], group_by: str + ) -> Dict[str, List[str]]: + """Map each group key to its chronological list of finished statuses. + + ``records`` arrive newest-first (the store's default order); we reverse + per group so flips are counted oldest -> newest. + """ + buckets: Dict[str, List[str]] = {} + for record in records: + if record.status not in (STATUS_OK, STATUS_ERROR): + continue + key = getattr(record, group_by) + buckets.setdefault(key, []).append(record.status) + return {key: list(reversed(values)) for key, values in buckets.items()} + + +def analyze_flakiness(store: Optional[HistoryStore] = None, + limit: int = 500, + min_runs: int = 2, + group_by: str = "script_path") -> FlakinessReport: + """Score flakiness across run history grouped by script or source. + + :param store: history store to read (defaults to the shared store). + :param limit: maximum number of recent runs to consider. + :param min_runs: minimum finished runs a group needs to be scored. + :param group_by: ``"script_path"`` (default) or ``"source_id"``. + """ + if group_by not in _GROUP_KEYS: + raise ValueError( + f"group_by must be one of {list(_GROUP_KEYS)}, got {group_by!r}" + ) + active_store = store if store is not None else default_history_store + records = active_store.list_runs(limit=int(limit)) + grouped = _grouped_statuses(records, group_by) + entries = [ + _entry_for_group(key, statuses) + for key, statuses in grouped.items() + if len(statuses) >= max(1, int(min_runs)) + ] + entries.sort(key=lambda e: (e.flip_rate, e.error), reverse=True) + flaky_count = sum(1 for entry in entries if entry.flaky) + return FlakinessReport( + generated_at=time.time(), group_by=group_by, + total_groups=len(entries), flaky_count=flaky_count, entries=entries, + ) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index e170f72b..04b1821b 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1886,6 +1886,278 @@ def usb_passthrough_tools() -> List[MCPTool]: ] +def assertion_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_assert_text", + description=("Assert OCR text is (present=true) or is not " + "(present=false) on screen. Set regex=true to treat " + "'text' as a regular expression. Raises on mismatch " + "when raise_on_fail is true (default)."), + input_schema=schema({ + "text": {"type": "string"}, + "region": {"type": "array", "items": {"type": "integer"}}, + "lang": {"type": "string"}, + "regex": {"type": "boolean"}, + "present": {"type": "boolean"}, + "ignore_case": {"type": "boolean"}, + "min_confidence": {"type": "number"}, + "raise_on_fail": {"type": "boolean"}, + "capture_on_fail": {"type": "boolean"}, + }, required=["text"]), + handler=h.assert_text, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_assert_image", + description=("Assert a template image is (or is not) visible on " + "screen at the given match threshold."), + input_schema=schema({ + "template_path": {"type": "string"}, + "threshold": {"type": "number"}, + "present": {"type": "boolean"}, + "raise_on_fail": {"type": "boolean"}, + "capture_on_fail": {"type": "boolean"}, + }, required=["template_path"]), + handler=h.assert_image, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_assert_pixel", + description=("Assert the pixel at (x, y) matches (match=true) or " + "differs from (match=false) rgb within tolerance."), + input_schema=schema({ + "x": {"type": "integer"}, + "y": {"type": "integer"}, + "rgb": {"type": "array", "items": {"type": "integer"}}, + "tolerance": {"type": "integer"}, + "match": {"type": "boolean"}, + "raise_on_fail": {"type": "boolean"}, + "capture_on_fail": {"type": "boolean"}, + }, required=["x", "y", "rgb"]), + handler=h.assert_pixel, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_assert_window", + description=("Assert a window whose title contains 'title' does " + "(exists=true) or does not (exists=false) exist."), + input_schema=schema({ + "title": {"type": "string"}, + "exists": {"type": "boolean"}, + "ignore_case": {"type": "boolean"}, + "raise_on_fail": {"type": "boolean"}, + "capture_on_fail": {"type": "boolean"}, + }, required=["title"]), + handler=h.assert_window, + annotations=READ_ONLY, + ), + ] + + +def data_source_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_load_data", + description=("Load tabular rows from a data source spec and return " + "them as a list of row objects. 'source' is a dict " + "with kind=csv|json|sqlite|excel|inline plus " + "kind-specific fields (path / query / rows). Combine " + "with the AC_for_each_row flow-control command to " + "drive a script once per row."), + input_schema=schema({ + "source": {"type": "object"}, + "limit": {"type": "integer"}, + }, required=["source"]), + handler=h.load_data, + annotations=READ_ONLY, + ), + ] + + +def flakiness_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_flaky_report", + description=("Score run-history flakiness: group recent runs by " + "script_path (or source_id), count pass/fail flips, " + "and rank scripts that intermittently fail. " + "Read-only analytics over the run-history store."), + input_schema=schema({ + "limit": {"type": "integer"}, + "min_runs": {"type": "integer"}, + "group_by": {"type": "string", + "enum": ["script_path", "source_id"]}, + }), + handler=h.flaky_report, + annotations=READ_ONLY, + ), + ] + + +def suite_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_run_suite", + description=("Run a QA suite spec (name + optional setup/teardown " + "+ cases) and return per-case pass/fail/error/skip " + "results. Cases with a 'data' source expand to one " + "scored case per row. Pass junit_path / allure_dir to " + "also write CI-native reports. Quarantined case names " + "are skipped."), + input_schema=schema({ + "spec": {"type": "object"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "respect_quarantine": {"type": "boolean"}, + "junit_path": {"type": "string"}, + "allure_dir": {"type": "string"}, + }, required=["spec"]), + handler=h.run_suite, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + +def quarantine_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_quarantine_add", + description=("Quarantine a case name so the suite runner skips it " + "(records it as skipped, not failed)."), + input_schema=schema({ + "name": {"type": "string"}, + "reason": {"type": "string"}, + }, required=["name"]), + handler=h.quarantine_add, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_quarantine_remove", + description="Release a case name from quarantine.", + input_schema=schema({"name": {"type": "string"}}, + required=["name"]), + handler=h.quarantine_remove, + annotations=NON_DESTRUCTIVE, + ), + MCPTool( + name="ac_quarantine_list", + description="List every quarantined case name with its reason.", + input_schema=schema({}), + handler=h.quarantine_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_quarantine_auto", + description=("Auto-quarantine flaky scripts from run history whose " + "flip rate meets flip_rate_threshold."), + input_schema=schema({ + "flip_rate_threshold": {"type": "number"}, + "min_runs": {"type": "integer"}, + "limit": {"type": "integer"}, + "group_by": {"type": "string", + "enum": ["script_path", "source_id"]}, + }), + handler=h.quarantine_auto, + annotations=NON_DESTRUCTIVE, + ), + ] + + +def a11y_audit_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_audit_accessibility", + description=("Audit the accessibility tree for interactive widgets " + "missing an accessible name; optionally also check " + "supplied foreground/background contrast_pairs (WCAG) " + "and OCR 'texts' for ellipsis truncation. Returns an " + "issue list with severities."), + input_schema=schema({ + "app_name": {"type": "string"}, + "contrast_pairs": {"type": "array", + "items": {"type": "object"}}, + "texts": {"type": "array", "items": {"type": "string"}}, + "min_ratio": {"type": "number"}, + "max_results": {"type": "integer"}, + }), + handler=h.audit_accessibility, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_audit_contrast", + description=("Compute the WCAG contrast ratio between a foreground " + "and background RGB colour; reports pass/fail against " + "the AA threshold."), + input_schema=schema({ + "foreground": {"type": "array", "items": {"type": "integer"}}, + "background": {"type": "array", "items": {"type": "integer"}}, + "min_ratio": {"type": "number"}, + }, required=["foreground", "background"]), + handler=h.audit_contrast, + annotations=READ_ONLY, + ), + ] + + +def device_matrix_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_run_device_matrix", + description=("Run one AC_* action list across many mobile devices " + "in parallel (each on an isolated executor). Each " + "device spec (platform + serial/url) is bound to " + "${device.*} so the script targets the current " + "device. Returns per-device pass/fail."), + input_schema=schema({ + "actions": {"type": "array"}, + "devices": {"type": "array", "items": {"type": "object"}}, + "max_parallel": {"type": "integer"}, + "var_name": {"type": "string"}, + }, required=["actions", "devices"]), + handler=h.run_device_matrix, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + +def media_assert_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_assert_audio", + description=("Record from an input device for duration_s and " + "assert sound (expect_sound=true) or silence " + "(false) by comparing RMS level to threshold."), + input_schema=schema({ + "duration_s": {"type": "number"}, + "threshold": {"type": "number"}, + "expect_sound": {"type": "boolean"}, + "samplerate": {"type": "integer"}, + "channels": {"type": "integer"}, + "raise_on_fail": {"type": "boolean"}, + }), + handler=h.assert_audio, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_assert_video_changes", + description=("Measure mean frame-to-frame difference over a " + "segment of a recorded video and assert motion " + "(expect_motion=true) or a static segment."), + input_schema=schema({ + "video_path": {"type": "string"}, + "start_s": {"type": "number"}, + "end_s": {"type": "number"}, + "threshold": {"type": "number"}, + "expect_motion": {"type": "boolean"}, + "region": {"type": "array", "items": {"type": "integer"}}, + "raise_on_fail": {"type": "boolean"}, + }, required=["video_path"]), + handler=h.assert_video_changes, + annotations=READ_ONLY, + ), + ] + + ALL_FACTORIES = ( mouse_tools, keyboard_tools, screen_tools, image_and_ocr_tools, window_tools, system_tools, recording_tools, drag_and_send_tools, @@ -1896,5 +2168,7 @@ def usb_passthrough_tools() -> List[MCPTool]: redaction_tools, android_widget_tools, ios_tools, webrunner_tools, scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, - usb_passthrough_tools, + usb_passthrough_tools, assertion_tools, data_source_tools, + flakiness_tools, suite_tools, quarantine_tools, + a11y_audit_tools, device_matrix_tools, media_assert_tools, ) diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 13eaec7b..cb95c8a6 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1596,3 +1596,209 @@ def usb_remote_open(vendor_id: str, product_id: str, serial: Optional[str] = None) -> Dict[str, Any]: from je_auto_control.utils.usb.passthrough import commands return commands.remote_open(vendor_id, product_id, serial=serial) + + +# --- Assertion DSL --------------------------------------------------------- + +def assert_text(text: str, + region: Optional[List[int]] = None, + lang: str = "eng", + regex: bool = False, + present: bool = True, + ignore_case: bool = True, + min_confidence: float = 60.0, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.assertion import assert_text as _assert + return _assert( + text, region=region, lang=lang, regex=bool(regex), + present=bool(present), ignore_case=bool(ignore_case), + min_confidence=float(min_confidence), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def assert_image(template_path: str, + threshold: float = 0.9, + present: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.assertion import assert_image as _assert + return _assert( + template_path, threshold=float(threshold), present=bool(present), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def assert_pixel(x: int, y: int, rgb: List[int], + tolerance: int = 0, + match: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.assertion import assert_pixel as _assert + return _assert( + int(x), int(y), rgb, tolerance=int(tolerance), match=bool(match), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +def assert_window(title: str, + exists: bool = True, + ignore_case: bool = True, + raise_on_fail: bool = True, + capture_on_fail: bool = False) -> Dict[str, Any]: + from je_auto_control.utils.assertion import assert_window as _assert + return _assert( + title, exists=bool(exists), ignore_case=bool(ignore_case), + raise_on_fail=bool(raise_on_fail), + capture_on_fail=bool(capture_on_fail), + ).to_dict() + + +# --- Data-driven execution ------------------------------------------------- + +def load_data(source: Dict[str, Any], + limit: Optional[int] = None) -> List[Dict[str, Any]]: + from je_auto_control.utils.data_source import load_rows + return load_rows(source, limit=limit if limit is None else int(limit)) + + +# --- Flaky-test detection -------------------------------------------------- + +def flaky_report(limit: int = 500, + min_runs: int = 2, + group_by: str = "script_path") -> Dict[str, Any]: + from je_auto_control.utils.flakiness import analyze_flakiness + return analyze_flakiness( + limit=int(limit), min_runs=int(min_runs), group_by=group_by, + ).to_dict() + + +# --- QA suite runner + CI reports ------------------------------------------ + +def run_suite(spec: Dict[str, Any], + tags: Optional[List[str]] = None, + respect_quarantine: bool = True, + junit_path: Optional[str] = None, + allure_dir: Optional[str] = None) -> Dict[str, Any]: + from je_auto_control.utils.executor.action_executor import executor + from je_auto_control.utils.test_suite import ( + run_suite as _run, write_allure_results, write_junit_xml, + ) + result = _run( + spec, executor=executor, tags=tags, + respect_quarantine=bool(respect_quarantine), + ) + payload = result.to_dict() + reports: Dict[str, Any] = {} + if junit_path: + reports["junit"] = write_junit_xml(result, junit_path) + if allure_dir: + reports["allure"] = write_allure_results(result, allure_dir) + if reports: + payload["reports"] = reports + return payload + + +# --- Flaky quarantine ------------------------------------------------------ + +def quarantine_add(name: str, reason: str = "") -> Dict[str, Any]: + from je_auto_control.utils.quarantine import default_quarantine_store + return default_quarantine_store().add(name, reason=reason).to_dict() + + +def quarantine_remove(name: str) -> Dict[str, Any]: + from je_auto_control.utils.quarantine import default_quarantine_store + return {"name": name, "removed": default_quarantine_store().remove(name)} + + +def quarantine_list() -> List[Dict[str, Any]]: + from je_auto_control.utils.quarantine import default_quarantine_store + return [entry.to_dict() for entry in default_quarantine_store().list()] + + +def quarantine_auto(flip_rate_threshold: float = 0.5, + min_runs: int = 3, + limit: int = 500, + group_by: str = "script_path") -> List[Dict[str, Any]]: + from je_auto_control.utils.quarantine import auto_quarantine_from_flakiness + return [ + entry.to_dict() + for entry in auto_quarantine_from_flakiness( + flip_rate_threshold=float(flip_rate_threshold), + min_runs=int(min_runs), limit=int(limit), group_by=group_by, + ) + ] + + +# --- Accessibility / i18n audit -------------------------------------------- + +def audit_accessibility(app_name: Optional[str] = None, + contrast_pairs: Optional[List[Dict[str, Any]]] = None, + texts: Optional[List[str]] = None, + min_ratio: float = 4.5, + max_results: int = 500) -> Dict[str, Any]: + from je_auto_control.utils.a11y_audit import run_audit + return run_audit( + app_name=app_name, contrast_pairs=contrast_pairs, texts=texts, + min_ratio=float(min_ratio), max_results=int(max_results), + ).to_dict() + + +def audit_contrast(foreground: List[int], background: List[int], + min_ratio: float = 4.5) -> Dict[str, Any]: + from je_auto_control.utils.a11y_audit import contrast_ratio + ratio = contrast_ratio(foreground, background) + return { + "ratio": round(ratio, 2), + "passes_aa": ratio >= float(min_ratio), + "foreground": list(foreground), "background": list(background), + } + + +# --- Mobile device matrix -------------------------------------------------- + +def run_device_matrix(actions: List[Any], devices: List[Dict[str, Any]], + max_parallel: int = 4, + var_name: str = "device") -> Dict[str, Any]: + from je_auto_control.utils.device_matrix import run_on_devices + return run_on_devices( + actions, devices, max_parallel=int(max_parallel), var_name=var_name, + ).to_dict() + + +# --- Media assertions ------------------------------------------------------ + +def assert_audio(duration_s: float = 1.0, + threshold: float = 0.01, + expect_sound: bool = True, + samplerate: int = 44100, + channels: int = 1, + raise_on_fail: bool = True) -> Dict[str, Any]: + from je_auto_control.utils.media_assert import assert_audio_activity + return assert_audio_activity( + duration_s=float(duration_s), threshold=float(threshold), + expect_sound=bool(expect_sound), samplerate=int(samplerate), + channels=int(channels), raise_on_fail=bool(raise_on_fail), + ).to_dict() + + +def assert_video_changes(video_path: str, + start_s: float = 0.0, + end_s: Optional[float] = None, + threshold: float = 1.0, + expect_motion: bool = True, + region: Optional[List[int]] = None, + raise_on_fail: bool = True) -> Dict[str, Any]: + from je_auto_control.utils.media_assert import ( + assert_video_changes as _assert, + ) + return _assert( + video_path, start_s=float(start_s), + end_s=None if end_s is None else float(end_s), + threshold=float(threshold), expect_motion=bool(expect_motion), + region=region, raise_on_fail=bool(raise_on_fail), + ).to_dict() diff --git a/je_auto_control/utils/media_assert/__init__.py b/je_auto_control/utils/media_assert/__init__.py new file mode 100644 index 00000000..f8b79052 --- /dev/null +++ b/je_auto_control/utils/media_assert/__init__.py @@ -0,0 +1,29 @@ +"""Media assertions — audio activity and video motion checks. + +Public surface:: + + from je_auto_control import ( + assert_audio_activity, assert_video_changes, + measure_audio_rms, video_segment_motion, MediaAssertionResult, + ) +""" +from je_auto_control.utils.media_assert.media import ( + MediaAssertionResult, + assert_audio_activity, + assert_video_changes, + mean_frame_diff, + measure_audio_rms, + rms, + video_segment_motion, +) + + +__all__ = [ + "MediaAssertionResult", + "assert_audio_activity", + "assert_video_changes", + "mean_frame_diff", + "measure_audio_rms", + "rms", + "video_segment_motion", +] diff --git a/je_auto_control/utils/media_assert/media.py b/je_auto_control/utils/media_assert/media.py new file mode 100644 index 00000000..57aced3e --- /dev/null +++ b/je_auto_control/utils/media_assert/media.py @@ -0,0 +1,204 @@ +"""Media assertions — verify audio activity and video motion. + +Screen / audio recording already exists; this module adds the *assertion* +side so a script can check that something actually played or animated: + +* :func:`assert_audio_activity` records from an input device for a short + window and checks the RMS level against a threshold (sound vs silence). +* :func:`assert_video_changes` measures mean frame-to-frame difference over + a segment of a recorded video and checks for motion vs a static frame. + +The numeric cores (:func:`rms`, :func:`mean_frame_diff`) are pure and +unit-testable; the capture wrappers (sounddevice / OpenCV) are thin and +lazily imported, and the assertion helpers call the module-level measure +functions so tests can substitute a fake measurement. +""" +from __future__ import annotations + +import math +import os +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, +) + + +@dataclass(frozen=True) +class MediaAssertionResult: + """Outcome of an audio / video assertion.""" + + kind: str + passed: bool + message: str + measured: float + threshold: float + expected: bool + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def rms(samples: Sequence[float]) -> float: + """Root-mean-square level of a sample sequence (pure).""" + values = list(samples) + if not values: + return 0.0 + total = math.fsum(float(value) * float(value) for value in values) + return math.sqrt(total / len(values)) + + +def measure_audio_rms(duration_s: float = 1.0, + samplerate: int = 44100, + channels: int = 1, + device: Optional[int] = None) -> float: + """Record from an input device and return the RMS level of the buffer.""" + try: + import numpy as np + import sounddevice as sd + except ImportError as error: + raise RuntimeError( + "Audio assertions require sounddevice + numpy " + "(pip install sounddevice numpy).", + ) from error + frames = max(1, int(float(duration_s) * int(samplerate))) + recording = sd.rec(frames, samplerate=int(samplerate), + channels=int(channels), device=device) + sd.wait() + return float(np.sqrt(np.mean(np.square(recording)))) + + +def assert_audio_activity(duration_s: float = 1.0, + threshold: float = 0.01, + expect_sound: bool = True, + samplerate: int = 44100, + channels: int = 1, + device: Optional[int] = None, + raise_on_fail: bool = True + ) -> MediaAssertionResult: + """Assert the input device is (or is not) producing sound.""" + level = measure_audio_rms( + duration_s=duration_s, samplerate=samplerate, + channels=channels, device=device, + ) + has_sound = level >= threshold + passed = (has_sound == expect_sound) + state = "sound" if expect_sound else "silence" + message = ( + f"assert_audio_activity passed: RMS {level:.4f} indicates {state}" + if passed else + f"assert_audio_activity failed: RMS {level:.4f} vs threshold " + f"{threshold} (expected {state})" + ) + return _finalize_media( + "audio", passed, message, level, threshold, expect_sound, + raise_on_fail, + ) + + +def mean_frame_diff(frames: Sequence[Any]) -> float: + """Mean absolute difference between consecutive grayscale frames (pure). + + ``frames`` is a sequence of 2-D numeric arrays (numpy arrays or nested + lists). Returns 0.0 for fewer than two frames. + """ + try: + import numpy as np + except ImportError as error: + raise RuntimeError("mean_frame_diff requires numpy.") from error + if len(frames) < 2: + return 0.0 + diffs: List[float] = [] + previous = np.asarray(frames[0], dtype="float64") + for frame in frames[1:]: + current = np.asarray(frame, dtype="float64") + diffs.append(float(np.mean(np.abs(current - previous)))) + previous = current + return float(sum(diffs) / len(diffs)) if diffs else 0.0 + + +def _read_segment_frames(video_path: str, start_s: float, + end_s: Optional[float], + region: Optional[Sequence[int]]) -> List[Any]: + """Read grayscale frames of ``video_path`` within [start_s, end_s].""" + import cv2 + resolved = os.path.realpath(os.path.expanduser(video_path)) + if not os.path.isfile(resolved): + raise FileNotFoundError(f"video not found: {resolved}") + capture = cv2.VideoCapture(resolved) + try: + fps = capture.get(cv2.CAP_PROP_FPS) or 30.0 + start_frame = int(max(0.0, start_s) * fps) + end_frame = int(end_s * fps) if end_s is not None else None + capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + return _collect_gray_frames(capture, cv2, start_frame, end_frame, + region) + finally: + capture.release() + + +def _collect_gray_frames(capture, cv2, start_frame: int, + end_frame: Optional[int], + region: Optional[Sequence[int]]) -> List[Any]: + """Pull and grayscale frames until ``end_frame`` or end of stream.""" + frames: List[Any] = [] + index = start_frame + while end_frame is None or index < end_frame: + ok, frame = capture.read() + if not ok: + break + if region: + x1, y1, x2, y2 = (int(v) for v in region) + frame = frame[y1:y2, x1:x2] + frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)) + index += 1 + return frames + + +def video_segment_motion(video_path: str, + start_s: float = 0.0, + end_s: Optional[float] = None, + region: Optional[Sequence[int]] = None) -> float: + """Mean frame-to-frame difference over a video segment (0 = static).""" + return mean_frame_diff( + _read_segment_frames(video_path, start_s, end_s, region), + ) + + +def assert_video_changes(video_path: str, + start_s: float = 0.0, + end_s: Optional[float] = None, + threshold: float = 1.0, + expect_motion: bool = True, + region: Optional[Sequence[int]] = None, + raise_on_fail: bool = True) -> MediaAssertionResult: + """Assert a video segment contains motion (or is static).""" + motion = video_segment_motion( + video_path, start_s=start_s, end_s=end_s, region=region, + ) + has_motion = motion >= threshold + passed = (has_motion == expect_motion) + state = "motion" if expect_motion else "static" + message = ( + f"assert_video_changes passed: diff {motion:.3f} indicates {state}" + if passed else + f"assert_video_changes failed: diff {motion:.3f} vs threshold " + f"{threshold} (expected {state})" + ) + return _finalize_media( + "video", passed, message, motion, threshold, expect_motion, + raise_on_fail, + ) + + +def _finalize_media(kind: str, passed: bool, message: str, measured: float, + threshold: float, expected: bool, + raise_on_fail: bool) -> MediaAssertionResult: + """Build the result and raise on failure when requested.""" + if not passed and raise_on_fail: + raise AutoControlAssertionException(message) + return MediaAssertionResult( + kind=kind, passed=passed, message=message, + measured=round(measured, 6), threshold=threshold, expected=expected, + ) diff --git a/je_auto_control/utils/quarantine/__init__.py b/je_auto_control/utils/quarantine/__init__.py new file mode 100644 index 00000000..60146fde --- /dev/null +++ b/je_auto_control/utils/quarantine/__init__.py @@ -0,0 +1,25 @@ +"""Flaky-test quarantine — skip known-unstable cases in the suite runner. + +Public surface:: + + from je_auto_control import ( + QuarantineStore, default_quarantine_store, + auto_quarantine_from_flakiness, + ) +""" +from je_auto_control.utils.quarantine.store import ( + QuarantineEntry, + QuarantineStore, + auto_quarantine_from_flakiness, + default_quarantine_store, + quarantined_names, +) + + +__all__ = [ + "QuarantineEntry", + "QuarantineStore", + "auto_quarantine_from_flakiness", + "default_quarantine_store", + "quarantined_names", +] diff --git a/je_auto_control/utils/quarantine/store.py b/je_auto_control/utils/quarantine/store.py new file mode 100644 index 00000000..53c0f27e --- /dev/null +++ b/je_auto_control/utils/quarantine/store.py @@ -0,0 +1,165 @@ +"""Quarantine store — close the loop on flaky tests. + +:func:`analyze_flakiness` only *reports* unstable scripts. The quarantine +store *acts* on that report: a quarantined case name is skipped by the +:mod:`je_auto_control.utils.test_suite` runner (recorded as ``skipped`` +with reason ``quarantined``) so a known-flaky case stops poisoning the +suite's red/green status until it is fixed and released. + +The store is a small JSON file (mode 0600 on POSIX) that persists across +restarts, mirroring the other per-user config stores in the project. +""" +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Union + +from je_auto_control.utils.logging.logging_instance import autocontrol_logger + + +@dataclass +class QuarantineEntry: + """One quarantined case name plus why and when.""" + + name: str + reason: str = "" + added_at: float = 0.0 + flip_rate: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def _default_path() -> Path: + return Path.home() / ".je_auto_control" / "quarantine.json" + + +class QuarantineStore: + """Thread-safe JSON-backed set of quarantined case names.""" + + def __init__(self, path: Union[str, Path, None] = None) -> None: + self._path = Path(path) if path is not None else _default_path() + self._lock = threading.Lock() + self._entries: Dict[str, QuarantineEntry] = {} + self._load() + + @property + def path(self) -> str: + return str(self._path) + + def _load(self) -> None: + if not self._path.is_file(): + return + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + autocontrol_logger.warning("quarantine load failed: %r", error) + return + for item in raw.get("entries", []): + name = item.get("name") + if name: + self._entries[name] = QuarantineEntry( + name=name, reason=item.get("reason", ""), + added_at=float(item.get("added_at", 0.0)), + flip_rate=item.get("flip_rate"), + ) + + def _save(self) -> None: + payload = {"entries": [e.to_dict() for e in self._entries.values()]} + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + if os.name == "posix": + try: + os.chmod(self._path, 0o600) + except OSError as error: + autocontrol_logger.warning("quarantine chmod failed: %r", error) + + def add(self, name: str, reason: str = "", + flip_rate: Optional[float] = None) -> QuarantineEntry: + """Quarantine ``name`` (idempotent); return the stored entry.""" + if not name: + raise ValueError("quarantine name must be non-empty") + with self._lock: + entry = QuarantineEntry( + name=name, reason=reason, added_at=time.time(), + flip_rate=flip_rate, + ) + self._entries[name] = entry + self._save() + return entry + + def remove(self, name: str) -> bool: + """Release ``name`` from quarantine; return False if absent.""" + with self._lock: + if name not in self._entries: + return False + del self._entries[name] + self._save() + return True + + def is_quarantined(self, name: str) -> bool: + return name in self._entries + + def names(self) -> Set[str]: + return set(self._entries) + + def list(self) -> List[QuarantineEntry]: + return sorted(self._entries.values(), key=lambda e: e.name) + + def clear(self) -> int: + with self._lock: + count = len(self._entries) + self._entries.clear() + self._save() + return count + + +_DEFAULT_STORE: Optional[QuarantineStore] = None + + +def default_quarantine_store() -> QuarantineStore: + """Return the lazily-created shared quarantine store.""" + global _DEFAULT_STORE + if _DEFAULT_STORE is None: + _DEFAULT_STORE = QuarantineStore() + return _DEFAULT_STORE + + +def quarantined_names() -> Set[str]: + """Convenience: the set of quarantined names in the default store.""" + return default_quarantine_store().names() + + +def auto_quarantine_from_flakiness(flip_rate_threshold: float = 0.5, + min_runs: int = 3, + limit: int = 500, + group_by: str = "script_path", + store: Optional[QuarantineStore] = None, + ) -> List[QuarantineEntry]: + """Quarantine every flaky group whose flip rate meets the threshold. + + Reads the run-history flakiness report and adds qualifying entries to + the quarantine store. Returns the entries added or refreshed. + """ + from je_auto_control.utils.flakiness import analyze_flakiness + target = store if store is not None else default_quarantine_store() + report = analyze_flakiness( + limit=int(limit), min_runs=int(min_runs), group_by=group_by, + ) + added: List[QuarantineEntry] = [] + for entry in report.entries: + if entry.flaky and entry.flip_rate >= flip_rate_threshold: + added.append(target.add( + entry.key, + reason=f"auto: flip_rate={entry.flip_rate}", + flip_rate=entry.flip_rate, + )) + return added diff --git a/je_auto_control/utils/script_vars/interpolate.py b/je_auto_control/utils/script_vars/interpolate.py index 86fe80aa..270d21f3 100644 --- a/je_auto_control/utils/script_vars/interpolate.py +++ b/je_auto_control/utils/script_vars/interpolate.py @@ -52,12 +52,37 @@ def _interpolate_string(text: str, variables: Mapping[str, Any]) -> Any: ) +_MISSING = object() + + +def _resolve_segment(value: Any, segment: str) -> Any: + """Index one path segment into a mapping or sequence; ``_MISSING`` if absent. + + Only dict-key and list-index access are supported — never arbitrary + attribute access — so a dotted placeholder cannot reach into the host + object graph. + """ + if isinstance(value, Mapping) and segment in value: + return value[segment] + if isinstance(value, (list, tuple)) and segment.isdigit(): + index = int(segment) + if 0 <= index < len(value): + return value[index] + return _MISSING + + def _lookup(name: str, variables: Mapping[str, Any]) -> Any: if name.startswith(_VAULT_NAMESPACE): return _lookup_secret(name[len(_VAULT_NAMESPACE):]) - if name not in variables: + base, _, path = name.partition(".") + if base not in variables: raise ValueError(f"Unknown variable: ${{{name}}}") - return variables[name] + value = variables[base] + for segment in filter(None, path.split(".")): + value = _resolve_segment(value, segment) + if value is _MISSING: + raise ValueError(f"Unknown variable: ${{{name}}}") + return value def _lookup_secret(secret_name: str) -> str: diff --git a/je_auto_control/utils/test_suite/__init__.py b/je_auto_control/utils/test_suite/__init__.py new file mode 100644 index 00000000..d0252005 --- /dev/null +++ b/je_auto_control/utils/test_suite/__init__.py @@ -0,0 +1,24 @@ +"""QA suite orchestration — score action lists as test cases. + +Public surface:: + + from je_auto_control import ( + run_suite, TestCaseResult, TestSuiteResult, + ) +""" +from je_auto_control.utils.test_suite.result import ( + STATUS_ERROR, STATUS_FAILED, STATUS_PASSED, STATUS_SKIPPED, + TestCaseResult, TestSuiteResult, +) +from je_auto_control.utils.test_suite.reports import ( + to_allure_results, to_junit_xml, write_allure_results, write_junit_xml, +) +from je_auto_control.utils.test_suite.runner import run_suite + + +__all__ = [ + "STATUS_ERROR", "STATUS_FAILED", "STATUS_PASSED", "STATUS_SKIPPED", + "TestCaseResult", "TestSuiteResult", "run_suite", + "to_allure_results", "to_junit_xml", + "write_allure_results", "write_junit_xml", +] diff --git a/je_auto_control/utils/test_suite/reports.py b/je_auto_control/utils/test_suite/reports.py new file mode 100644 index 00000000..a4314e08 --- /dev/null +++ b/je_auto_control/utils/test_suite/reports.py @@ -0,0 +1,113 @@ +"""CI-native report output for :class:`TestSuiteResult`. + +* :func:`write_junit_xml` emits a JUnit XML file that Jenkins, GitHub + Actions, GitLab CI, and most test dashboards parse natively. +* :func:`write_allure_results` emits one Allure-2 ``*-result.json`` file + per case into a results directory, ready for ``allure generate``. + +Only *generation* happens here (never parsing untrusted XML), so the +stdlib ``xml.etree.ElementTree`` writer is safe to use. +""" +from __future__ import annotations + +import json +import uuid +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List + +from je_auto_control.utils.test_suite.result import ( + STATUS_ERROR, STATUS_FAILED, STATUS_SKIPPED, TestSuiteResult, +) + +_ALLURE_STATUS = { + "passed": "passed", + STATUS_FAILED: "failed", + STATUS_ERROR: "broken", + STATUS_SKIPPED: "skipped", +} + + +def _case_element(parent: ET.Element, case: Any, suite_name: str) -> None: + """Append one ```` (with failure/error/skipped child) element.""" + node = ET.SubElement(parent, "testcase", { + "name": case.name, + "classname": suite_name, + "time": f"{case.duration_s:.3f}", + }) + if case.status == STATUS_FAILED: + child = ET.SubElement(node, "failure", {"message": case.message}) + child.text = case.message + elif case.status == STATUS_ERROR: + child = ET.SubElement(node, "error", {"message": case.message}) + child.text = case.message + elif case.status == STATUS_SKIPPED: + ET.SubElement(node, "skipped", {"message": case.message}) + + +def to_junit_xml(result: TestSuiteResult) -> str: + """Render ``result`` as a JUnit XML string.""" + suites = ET.Element("testsuites") + suite = ET.SubElement(suites, "testsuite", { + "name": result.name, + "tests": str(result.total), + "failures": str(result.failed), + "errors": str(result.errored), + "skipped": str(result.skipped), + "time": f"{result.duration_s:.3f}", + }) + if result.setup_error: + error_case = ET.SubElement(suite, "testcase", { + "name": "", "classname": result.name, "time": "0.000", + }) + node = ET.SubElement(error_case, "error", + {"message": result.setup_error}) + node.text = result.setup_error + for case in result.cases: + _case_element(suite, case, result.name) + return ET.tostring(suites, encoding="unicode") + + +def write_junit_xml(result: TestSuiteResult, path: str) -> str: + """Write the JUnit XML for ``result`` to ``path``; return the path.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + xml = "\n" + to_junit_xml(result) + target.write_text(xml, encoding="utf-8") + return str(target) + + +def _allure_case(result: TestSuiteResult, case: Any) -> Dict[str, Any]: + """Build one Allure-2 result dict for a case.""" + labels = [{"name": "suite", "value": result.name}] + labels.extend({"name": "tag", "value": tag} for tag in case.tags) + payload: Dict[str, Any] = { + "uuid": str(uuid.uuid4()), + "name": case.name, + "fullName": f"{result.name}#{case.name}", + "status": _ALLURE_STATUS.get(case.status, "unknown"), + "labels": labels, + } + if case.message: + payload["statusDetails"] = {"message": case.message} + return payload + + +def to_allure_results(result: TestSuiteResult) -> List[Dict[str, Any]]: + """Return the list of Allure-2 result dicts for ``result``.""" + return [_allure_case(result, case) for case in result.cases] + + +def write_allure_results(result: TestSuiteResult, directory: str) -> List[str]: + """Write one ``-result.json`` per case into ``directory``.""" + target_dir = Path(directory) + target_dir.mkdir(parents=True, exist_ok=True) + written: List[str] = [] + for payload in to_allure_results(result): + file_path = target_dir / f"{payload['uuid']}-result.json" + file_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + written.append(str(file_path)) + return written diff --git a/je_auto_control/utils/test_suite/result.py b/je_auto_control/utils/test_suite/result.py new file mode 100644 index 00000000..7886caed --- /dev/null +++ b/je_auto_control/utils/test_suite/result.py @@ -0,0 +1,97 @@ +"""Result model for the QA suite runner. + +A :class:`TestSuiteResult` aggregates one :class:`TestCaseResult` per case; +both are plain dataclasses with ``to_dict`` so they round-trip through JSON +action files, the REST API, and the report generators without any Qt or +third-party dependency. +""" +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +STATUS_PASSED = "passed" +STATUS_FAILED = "failed" +STATUS_ERROR = "error" +STATUS_SKIPPED = "skipped" + +VALID_STATUSES = frozenset({ + STATUS_PASSED, STATUS_FAILED, STATUS_ERROR, STATUS_SKIPPED, +}) + + +@dataclass +class TestCaseResult: + """Outcome of a single test case.""" + + name: str + status: str + duration_s: float = 0.0 + message: str = "" + tags: List[str] = field(default_factory=list) + screenshot_path: Optional[str] = None + data_row: Optional[Dict[str, Any]] = None + + @property + def ok(self) -> bool: + """True when the case did not fail or error.""" + return self.status in (STATUS_PASSED, STATUS_SKIPPED) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class TestSuiteResult: + """Aggregated outcome of a whole suite.""" + + name: str + cases: List[TestCaseResult] = field(default_factory=list) + started_at: float = field(default_factory=time.time) + duration_s: float = 0.0 + setup_error: Optional[str] = None + + def _count(self, status: str) -> int: + return sum(1 for case in self.cases if case.status == status) + + @property + def total(self) -> int: + return len(self.cases) + + @property + def passed(self) -> int: + return self._count(STATUS_PASSED) + + @property + def failed(self) -> int: + return self._count(STATUS_FAILED) + + @property + def errored(self) -> int: + return self._count(STATUS_ERROR) + + @property + def skipped(self) -> int: + return self._count(STATUS_SKIPPED) + + @property + def success(self) -> bool: + """True when no case failed/errored and setup succeeded.""" + return (self.failed == 0 and self.errored == 0 + and self.setup_error is None) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "started_at": self.started_at, + "duration_s": self.duration_s, + "setup_error": self.setup_error, + "total": self.total, + "passed": self.passed, + "failed": self.failed, + "errored": self.errored, + "skipped": self.skipped, + "success": self.success, + "cases": [case.to_dict() for case in self.cases], + } diff --git a/je_auto_control/utils/test_suite/runner.py b/je_auto_control/utils/test_suite/runner.py new file mode 100644 index 00000000..9833a9c6 --- /dev/null +++ b/je_auto_control/utils/test_suite/runner.py @@ -0,0 +1,168 @@ +"""SuiteRunner — turn flat action lists into scored test cases. + +A *suite* spec is a plain dict so it round-trips through JSON action +files:: + + { + "name": "Login", + "setup": [ ...actions run once before the cases... ], + "teardown": [ ...actions run once after, always... ], + "cases": [ + {"name": "valid login", "tags": ["smoke"], "actions": [ ... ]}, + {"name": "each user", "actions": [ ... ${row.user} ... ], + "data": {"kind": "csv", "path": "users.csv"}, "as": "row"} + ] + } + +Each case runs its actions through the action executor with +``raise_on_error=True``. An :class:`AutoControlAssertionException` marks +the case *failed*; any other exception marks it *error*; a clean run is +*passed*. Cases carrying a ``data`` source expand to one scored case per +row. Quarantined case names (see :mod:`je_auto_control.utils.quarantine`) +are recorded as *skipped* instead of run. +""" +from __future__ import annotations + +import time +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlAssertionException, +) +from je_auto_control.utils.test_suite.result import ( + STATUS_ERROR, STATUS_FAILED, STATUS_PASSED, STATUS_SKIPPED, + TestCaseResult, TestSuiteResult, +) + +# (case_name, case_spec, optional (var_name, row)) tuples. +_ExpandedCase = Tuple[str, Dict[str, Any], Optional[Tuple[str, Dict[str, Any]]]] + + +def _resolve_executor(executor: Any) -> Any: + if executor is not None: + return executor + from je_auto_control.utils.executor.action_executor import ( + executor as global_executor, + ) + return global_executor + + +def _quarantined_keys(respect: bool) -> Set[str]: + """Return the set of quarantined case names (empty if disabled/absent).""" + if not respect: + return set() + try: + from je_auto_control.utils.quarantine import quarantined_names + return set(quarantined_names()) + except (ImportError, OSError, RuntimeError): + return set() + + +def _tags_match(case_tags: Iterable[str], wanted: Optional[Set[str]]) -> bool: + if not wanted: + return True + return bool(set(case_tags) & wanted) + + +def _expand_cases(case_spec: Dict[str, Any]) -> List[_ExpandedCase]: + """Expand a data-driven case into one entry per row; else a single entry.""" + base = str(case_spec.get("name", "case")) + data = case_spec.get("data") + if not data: + return [(base, case_spec, None)] + from je_auto_control.utils.data_source import load_rows + rows = load_rows(data, limit=case_spec.get("limit")) + as_name = str(case_spec.get("as", "row")) + return [ + (f"{base}[{index}]", case_spec, (as_name, row)) + for index, row in enumerate(rows) + ] + + +def _run_actions(executor: Any, actions: List[Any]) -> Tuple[str, str]: + """Execute a case body; map the outcome to a (status, message) pair.""" + if not actions: + return STATUS_PASSED, "" + try: + executor.execute_action(actions, raise_on_error=True) + return STATUS_PASSED, "" + except AutoControlAssertionException as error: + return STATUS_FAILED, str(error) + except (AutoControlActionException, OSError, RuntimeError, + TypeError, ValueError) as error: + return STATUS_ERROR, repr(error) + + +def _run_one_case(executor: Any, name: str, spec: Dict[str, Any], + binding: Optional[Tuple[str, Dict[str, Any]]], + quarantined: Set[str]) -> TestCaseResult: + """Run (or skip) a single expanded case and return its result.""" + tags = [str(tag) for tag in spec.get("tags", [])] + row = binding[1] if binding is not None else None + if name in quarantined or spec.get("name") in quarantined: + return TestCaseResult( + name=name, status=STATUS_SKIPPED, message="quarantined", + tags=tags, data_row=row, + ) + if binding is not None: + executor.variables.set(binding[0], binding[1]) + started = time.monotonic() + status, message = _run_actions(executor, spec.get("actions") or []) + return TestCaseResult( + name=name, status=status, duration_s=time.monotonic() - started, + message=message, tags=tags, data_row=row, + ) + + +def run_suite(spec: Dict[str, Any], + executor: Any = None, + tags: Optional[Iterable[str]] = None, + respect_quarantine: bool = True) -> TestSuiteResult: + """Run a suite spec and return its aggregated :class:`TestSuiteResult`. + + :param spec: the suite definition dict (see module docstring). + :param executor: action executor to drive (defaults to the global one). + :param tags: when given, only cases sharing one of these tags run. + :param respect_quarantine: skip cases listed in the quarantine store. + """ + if not isinstance(spec, dict): + raise ValueError("suite spec must be a dict") + runner = _resolve_executor(executor) + wanted = {str(tag) for tag in tags} if tags else None + quarantined = _quarantined_keys(respect_quarantine) + result = TestSuiteResult(name=str(spec.get("name", "suite"))) + started = time.monotonic() + setup_error = _run_setup(runner, spec.get("setup")) + result.setup_error = setup_error + if setup_error is None: + _run_all_cases(runner, spec, wanted, quarantined, result) + _run_teardown(runner, spec.get("teardown")) + result.duration_s = time.monotonic() - started + return result + + +def _run_setup(executor: Any, setup: Optional[List[Any]]) -> Optional[str]: + """Run the optional setup block; return an error string on failure.""" + if not setup: + return None + status, message = _run_actions(executor, setup) + return None if status == STATUS_PASSED else message + + +def _run_teardown(executor: Any, teardown: Optional[List[Any]]) -> None: + """Run the optional teardown block; failures are swallowed by design.""" + if teardown: + _run_actions(executor, teardown) + + +def _run_all_cases(executor: Any, spec: Dict[str, Any], + wanted: Optional[Set[str]], quarantined: Set[str], + result: TestSuiteResult) -> None: + """Expand, filter, and run every case into ``result``.""" + for case_spec in spec.get("cases", []): + if not _tags_match(case_spec.get("tags", []), wanted): + continue + for name, sub_spec, binding in _expand_cases(case_spec): + result.cases.append( + _run_one_case(executor, name, sub_spec, binding, quarantined), + ) diff --git a/test/unit_test/headless/test_a11y_audit.py b/test/unit_test/headless/test_a11y_audit.py new file mode 100644 index 00000000..fcef6b6d --- /dev/null +++ b/test/unit_test/headless/test_a11y_audit.py @@ -0,0 +1,76 @@ +"""Headless tests for the accessibility / i18n audit.""" +from types import SimpleNamespace + +import je_auto_control as ac +from je_auto_control.utils.a11y_audit import ( + audit_contrast, audit_missing_labels, contrast_ratio, detect_truncation, + is_interactive, run_audit, +) + + +def _el(name, role): + return SimpleNamespace(name=name, role=role, bounds=(0, 0, 10, 10)) + + +def test_facade_exports(): + assert hasattr(ac, "run_audit") + assert hasattr(ac, "contrast_ratio") + + +def test_is_interactive(): + assert is_interactive("Button") + assert is_interactive("menu item") + assert not is_interactive("StaticText") + + +def test_missing_label_flagged(): + issues = audit_missing_labels([ + _el("", "button"), _el("OK", "button"), _el("", "StaticText"), + ]) + assert len(issues) == 1 + assert issues[0].kind == "missing_label" + assert issues[0].severity == "error" + + +def test_contrast_ratio_known_values(): + # black on white is the maximum 21:1 + assert round(contrast_ratio([0, 0, 0], [255, 255, 255]), 1) == 21.0 + # identical colours are 1:1 + assert round(contrast_ratio([120, 120, 120], [120, 120, 120]), 1) == 1.0 + + +def test_audit_contrast_flags_low(): + issues = audit_contrast([ + {"foreground": [200, 200, 200], "background": [255, 255, 255], + "label": "faint"}, + {"foreground": [0, 0, 0], "background": [255, 255, 255], + "label": "strong"}, + ]) + assert len(issues) == 1 + assert issues[0].target == "faint" + + +def test_detect_truncation(): + issues = detect_truncation(["Hello", "This is clipped…", "More..."]) + assert len(issues) == 2 + assert all(i.kind == "truncation" for i in issues) + + +def test_run_audit_combines_inputs(): + report = run_audit( + elements=[_el("", "button")], + contrast_pairs=[{"foreground": [200, 200, 200], + "background": [255, 255, 255], "label": "x"}], + texts=["clipped…"], + ) + kinds = {i.kind for i in report.issues} + assert kinds == {"missing_label", "contrast", "truncation"} + assert report.error_count >= 2 + assert report.warning_count == 1 + + +def test_executor_audit_contrast(): + from je_auto_control.utils.executor.action_executor import executor + out = executor.event_dict["AC_audit_contrast"]([0, 0, 0], [255, 255, 255]) + assert out["passes_aa"] is True + assert out["ratio"] == 21.0 diff --git a/test/unit_test/headless/test_assertions.py b/test/unit_test/headless/test_assertions.py new file mode 100644 index 00000000..8cf864de --- /dev/null +++ b/test/unit_test/headless/test_assertions.py @@ -0,0 +1,158 @@ +"""Headless tests for the assertion DSL (no Qt imports).""" +import sys +from types import SimpleNamespace + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.assertion import ( + AssertionResult, assert_image, assert_pixel, assert_text, assert_window, +) +from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, +) + + +def test_facade_exports_assertions(): + assert hasattr(ac, "assert_text") + assert hasattr(ac, "assert_window") + assert hasattr(ac, "assert_image") + assert hasattr(ac, "assert_pixel") + + +def test_assertion_import_stays_qt_free(): + """Import the facade in a fresh interpreter so the check isn't polluted + by GUI tests that may have already imported PySide6 in this session.""" + import subprocess + script = ( + "import sys, je_auto_control as ac\n" + "ac.assert_window # touch the assertion surface\n" + "qt = [m for m in sys.modules if 'PySide6' in m]\n" + "import json; print(json.dumps(qt))\n" + ) + result = subprocess.run( # nosec B603 + [sys.executable, "-c", script], + capture_output=True, text=True, check=True, timeout=60, + ) + assert result.stdout.strip() in ("[]", "") + + +# === assert_window ========================================================= + +def _patch_windows(monkeypatch, titles): + import je_auto_control.wrapper.auto_control_window as win + monkeypatch.setattr( + win, "list_windows", + lambda: [(i, t) for i, t in enumerate(titles)], + ) + + +def test_assert_window_present_passes(monkeypatch): + _patch_windows(monkeypatch, ["Notepad", "Calculator"]) + result = assert_window("notepad", exists=True) + assert isinstance(result, AssertionResult) + assert result.passed is True + assert result.kind == "window" + + +def test_assert_window_absent_raises(monkeypatch): + _patch_windows(monkeypatch, ["Calculator"]) + with pytest.raises(AutoControlAssertionException): + assert_window("Notepad", exists=True) + + +def test_assert_window_no_raise_returns_failed(monkeypatch): + _patch_windows(monkeypatch, ["Calculator"]) + result = assert_window("Notepad", exists=True, raise_on_fail=False) + assert result.passed is False + assert "Calculator" in result.actual + + +def test_assert_window_expect_absent(monkeypatch): + _patch_windows(monkeypatch, ["Calculator"]) + result = assert_window("Notepad", exists=False) + assert result.passed is True + + +# === assert_pixel ========================================================== + +def _patch_pixel(monkeypatch, color): + import je_auto_control.wrapper.auto_control_screen as scr + monkeypatch.setattr(scr, "get_pixel", lambda x, y: color) + + +def test_assert_pixel_match_within_tolerance(monkeypatch): + _patch_pixel(monkeypatch, (100, 100, 100)) + result = assert_pixel(0, 0, [102, 98, 100], tolerance=5) + assert result.passed is True + + +def test_assert_pixel_mismatch_raises(monkeypatch): + _patch_pixel(monkeypatch, (0, 0, 0)) + with pytest.raises(AutoControlAssertionException): + assert_pixel(0, 0, [255, 255, 255], tolerance=0) + + +def test_assert_pixel_expect_differ(monkeypatch): + _patch_pixel(monkeypatch, (0, 0, 0)) + result = assert_pixel(0, 0, [255, 255, 255], match=False) + assert result.passed is True + + +# === assert_image ========================================================== + +def test_assert_image_present(monkeypatch): + import je_auto_control.wrapper.auto_control_image as img + monkeypatch.setattr(img, "locate_image_center", lambda p, t: (5, 6)) + result = assert_image("button.png", present=True) + assert result.passed is True + assert result.actual["coords"] == [5, 6] + + +def test_assert_image_absent_raises(monkeypatch): + import je_auto_control.wrapper.auto_control_image as img + from je_auto_control.utils.exception.exceptions import ( + ImageNotFoundException, + ) + + def _miss(_p, _t): + raise ImageNotFoundException("nope") + + monkeypatch.setattr(img, "locate_image_center", _miss) + with pytest.raises(AutoControlAssertionException): + assert_image("button.png", present=True) + + +# === assert_text =========================================================== + +def _patch_ocr_text(monkeypatch, text): + import je_auto_control.utils.ocr.ocr_engine as ocr + matches = [SimpleNamespace(text=word) for word in text.split()] + monkeypatch.setattr( + ocr, "read_text_in_region", + lambda region=None, lang="eng", min_confidence=60.0: matches, + ) + + +def test_assert_text_substring_present(monkeypatch): + _patch_ocr_text(monkeypatch, "Login Successful Welcome") + result = assert_text("successful", present=True) + assert result.passed is True + + +def test_assert_text_absent_raises(monkeypatch): + _patch_ocr_text(monkeypatch, "Error 404") + with pytest.raises(AutoControlAssertionException): + assert_text("Welcome", present=True) + + +def test_assert_text_regex(monkeypatch): + import je_auto_control.utils.ocr.ocr_engine as ocr + _patch_ocr_text(monkeypatch, "Order 12345 placed") + monkeypatch.setattr( + ocr, "find_text_regex", + lambda pattern, lang="eng", region=None, min_confidence=60.0: + [SimpleNamespace(text="12345")], + ) + result = assert_text(r"\d{5}", regex=True, present=True) + assert result.passed is True diff --git a/test/unit_test/headless/test_data_source.py b/test/unit_test/headless/test_data_source.py new file mode 100644 index 00000000..fe0d57a4 --- /dev/null +++ b/test/unit_test/headless/test_data_source.py @@ -0,0 +1,112 @@ +"""Headless tests for data-driven execution (data sources).""" +import json +import sqlite3 +from pathlib import Path + +import pytest + +from je_auto_control.utils.data_source import data_source_kinds, load_rows +from je_auto_control.utils.executor.action_executor import executor + + +def test_supported_kinds(): + kinds = data_source_kinds() + assert {"csv", "json", "sqlite", "excel", "inline"} <= set(kinds) + + +def test_load_inline(): + rows = load_rows({"kind": "inline", "rows": [{"a": 1}, {"a": 2}]}) + assert rows == [{"a": 1}, {"a": 2}] + + +def test_load_inline_with_limit(): + rows = load_rows( + {"kind": "inline", "rows": [{"a": 1}, {"a": 2}, {"a": 3}]}, limit=2, + ) + assert len(rows) == 2 + + +def test_load_csv(tmp_path: Path): + csv_path = tmp_path / "users.csv" + csv_path.write_text("user,pw\nalice,1\nbob,2\n", encoding="utf-8") + rows = load_rows({"kind": "csv", "path": str(csv_path)}) + assert rows == [{"user": "alice", "pw": "1"}, {"user": "bob", "pw": "2"}] + + +def test_load_json_list(tmp_path: Path): + json_path = tmp_path / "cases.json" + json_path.write_text(json.dumps([{"x": 1}, {"x": 2}]), encoding="utf-8") + assert load_rows({"kind": "json", "path": str(json_path)}) == [ + {"x": 1}, {"x": 2}, + ] + + +def test_load_json_rows_wrapper(tmp_path: Path): + json_path = tmp_path / "cases.json" + json_path.write_text(json.dumps({"rows": [{"x": 9}]}), encoding="utf-8") + assert load_rows({"kind": "json", "path": str(json_path)}) == [{"x": 9}] + + +def test_load_sqlite(tmp_path: Path): + db_path = tmp_path / "app.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE users (name TEXT, age INTEGER)") + conn.execute("INSERT INTO users VALUES ('alice', 30), ('bob', 25)") + conn.commit() + conn.close() + rows = load_rows({ + "kind": "sqlite", "path": str(db_path), + "query": "SELECT name, age FROM users ORDER BY name", + }) + assert rows == [{"name": "alice", "age": 30}, {"name": "bob", "age": 25}] + + +def test_sqlite_rejects_non_select(tmp_path: Path): + db_path = tmp_path / "app.db" + sqlite3.connect(db_path).close() + with pytest.raises(ValueError): + load_rows({ + "kind": "sqlite", "path": str(db_path), + "query": "DROP TABLE users", + }) + + +def test_sqlite_rejects_multiple_statements(tmp_path: Path): + db_path = tmp_path / "app.db" + sqlite3.connect(db_path).close() + with pytest.raises(ValueError): + load_rows({ + "kind": "sqlite", "path": str(db_path), + "query": "SELECT 1; DROP TABLE users", + }) + + +def test_missing_file_raises(): + with pytest.raises(FileNotFoundError): + load_rows({"kind": "csv", "path": "/no/such/file_xyz.csv"}) + + +def test_unknown_kind_raises(): + with pytest.raises(ValueError): + load_rows({"kind": "parquet", "path": "x"}) + + +def test_executor_for_each_row_binds_rows(): + actions = [ + ["AC_set_var", {"name": "last", "value": ""}], + ["AC_for_each_row", { + "source": {"kind": "inline", + "rows": [{"u": "alice"}, {"u": "bob"}]}, + "as": "row", + "body": [["AC_set_var", {"name": "last", "value": "${row.u}"}]], + }], + ] + executor.execute_action(actions) + assert executor.variables.get_value("last") == "bob" + + +def test_executor_ac_load_data(): + rows = executor.event_dict["AC_load_data"]( + {"kind": "inline", "rows": [{"k": 1}]}, + ) + assert rows == [{"k": 1}] diff --git a/test/unit_test/headless/test_device_matrix.py b/test/unit_test/headless/test_device_matrix.py new file mode 100644 index 00000000..310299b3 --- /dev/null +++ b/test/unit_test/headless/test_device_matrix.py @@ -0,0 +1,54 @@ +"""Headless tests for the mobile device matrix.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.device_matrix import MatrixReport, run_on_devices + +# A device-agnostic action list: bind a var and assert nothing touches HW. +# Uses ${device.platform} which every device spec carries. +_ACTIONS = [["AC_set_var", {"name": "id", "value": "${device.platform}"}]] + + +def test_facade_export(): + assert hasattr(ac, "run_on_devices") + + +def test_runs_each_device(): + devices = [ + {"platform": "android", "serial": "a"}, + {"platform": "android", "serial": "b"}, + {"platform": "ios", "url": "http://localhost:8100"}, + ] + report = run_on_devices(_ACTIONS, devices, max_parallel=2) + assert isinstance(report, MatrixReport) + assert report.total == 3 + assert report.passed == 3 + assert report.success is True + ids = {r.device_id for r in report.results} + assert ids == {"a", "b", "http://localhost:8100"} + + +def test_failure_isolated_per_device(): + # An action that raises (unknown command) fails only its own device. + devices = [{"platform": "android", "serial": "a"}] + report = run_on_devices( + [["AC_assert_window", {"title": "zzz_none", "exists": True}]], + devices, + ) + assert report.total == 1 + assert report.passed == 0 + assert report.results[0].error is not None + + +def test_empty_devices_raises(): + with pytest.raises(ValueError): + run_on_devices(_ACTIONS, []) + + +def test_executor_command(): + from je_auto_control.utils.executor.action_executor import executor + out = executor.event_dict["AC_run_device_matrix"]( + _ACTIONS, [{"platform": "android", "serial": "x"}], + ) + assert out["total"] == 1 + assert out["passed"] == 1 diff --git a/test/unit_test/headless/test_flakiness.py b/test/unit_test/headless/test_flakiness.py new file mode 100644 index 00000000..d80d75f7 --- /dev/null +++ b/test/unit_test/headless/test_flakiness.py @@ -0,0 +1,75 @@ +"""Headless tests for flaky-test detection over run history.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.flakiness import ( + FlakinessReport, analyze_flakiness, +) +from je_auto_control.utils.run_history.history_store import HistoryStore + + +@pytest.fixture +def store() -> HistoryStore: + return HistoryStore() # in-memory + + +def _record(store: HistoryStore, source_id: str, script: str, status: str): + run_id = store.start_run("manual", source_id, script) + store.finish_run(run_id, status) + + +def test_facade_export(): + assert hasattr(ac, "analyze_flakiness") + + +def test_flaky_script_detected(store): + for status in ["ok", "error", "ok", "error"]: + _record(store, "job1", "flaky.json", status) + report = analyze_flakiness(store=store, min_runs=2) + assert isinstance(report, FlakinessReport) + entry = report.entries[0] + assert entry.key == "flaky.json" + assert entry.flaky is True + assert entry.ok == 2 and entry.error == 2 + assert entry.flips == 3 + assert entry.flip_rate == 1.0 + assert report.flaky_count == 1 + + +def test_stable_script_not_flaky(store): + for _ in range(3): + _record(store, "job2", "stable.json", "ok") + report = analyze_flakiness(store=store, min_runs=2) + entry = report.entries[0] + assert entry.flaky is False + assert entry.flips == 0 + assert entry.pass_rate == 1.0 + assert report.flaky_count == 0 + + +def test_min_runs_filters_out_short_history(store): + _record(store, "job3", "once.json", "error") + report = analyze_flakiness(store=store, min_runs=2) + assert report.entries == [] + + +def test_group_by_source_id(store): + _record(store, "nightly", "a.json", "ok") + _record(store, "nightly", "b.json", "error") + report = analyze_flakiness(store=store, min_runs=2, group_by="source_id") + assert report.entries[0].key == "nightly" + assert report.entries[0].total_runs == 2 + + +def test_invalid_group_by_raises(store): + with pytest.raises(ValueError): + analyze_flakiness(store=store, group_by="bogus") + + +def test_running_runs_ignored(store): + _record(store, "job4", "x.json", "ok") + store.start_run("manual", "job4", "x.json") # left running + _record(store, "job4", "x.json", "error") + report = analyze_flakiness(store=store, min_runs=2) + entry = report.entries[0] + assert entry.total_runs == 2 # the in-flight run is excluded diff --git a/test/unit_test/headless/test_media_assert.py b/test/unit_test/headless/test_media_assert.py new file mode 100644 index 00000000..18127baa --- /dev/null +++ b/test/unit_test/headless/test_media_assert.py @@ -0,0 +1,76 @@ +"""Headless tests for media assertions (audio RMS + video motion). + +The numeric cores are pure; the capture wrappers are exercised through +monkeypatched measurement functions so no microphone / video file is +required. +""" +import math + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, +) +from je_auto_control.utils.media_assert import media + + +def test_rms_pure(): + assert media.rms([]) == 0.0 + assert media.rms([3, 4]) == pytest.approx(math.sqrt((9 + 16) / 2)) + assert media.rms([0, 0, 0]) == 0.0 + + +def test_mean_frame_diff_pure(): + np = pytest.importorskip("numpy") + frame_a = np.zeros((2, 2)) + frame_b = np.full((2, 2), 10.0) + assert media.mean_frame_diff([frame_a]) == 0.0 + assert media.mean_frame_diff([frame_a, frame_b]) == pytest.approx(10.0) + + +def test_assert_audio_activity_pass(monkeypatch): + monkeypatch.setattr(media, "measure_audio_rms", + lambda **kwargs: 0.5) + result = media.assert_audio_activity(threshold=0.01, expect_sound=True) + assert result.passed is True + assert result.kind == "audio" + + +def test_assert_audio_activity_fail_raises(monkeypatch): + monkeypatch.setattr(media, "measure_audio_rms", + lambda **kwargs: 0.0001) + with pytest.raises(AutoControlAssertionException): + media.assert_audio_activity(threshold=0.01, expect_sound=True) + + +def test_assert_audio_expect_silence(monkeypatch): + monkeypatch.setattr(media, "measure_audio_rms", + lambda **kwargs: 0.0001) + result = media.assert_audio_activity( + threshold=0.01, expect_sound=False, raise_on_fail=False, + ) + assert result.passed is True + + +def test_assert_video_changes_motion(monkeypatch): + monkeypatch.setattr(media, "video_segment_motion", + lambda *a, **k: 9.0) + result = media.assert_video_changes( + "x.mp4", threshold=1.0, expect_motion=True, + ) + assert result.passed is True + assert result.measured == 9.0 + + +def test_assert_video_static_fail(monkeypatch): + monkeypatch.setattr(media, "video_segment_motion", + lambda *a, **k: 0.0) + with pytest.raises(AutoControlAssertionException): + media.assert_video_changes("x.mp4", threshold=1.0, expect_motion=True) + + +def test_facade_exports(): + assert hasattr(ac, "assert_audio_activity") + assert hasattr(ac, "assert_video_changes") + assert hasattr(ac, "measure_audio_rms") diff --git a/test/unit_test/headless/test_qa_tabs_b_gui.py b/test/unit_test/headless/test_qa_tabs_b_gui.py new file mode 100644 index 00000000..7bc727dd --- /dev/null +++ b/test/unit_test/headless/test_qa_tabs_b_gui.py @@ -0,0 +1,43 @@ +"""GUI smoke tests for the B-group tabs (a11y audit / matrix / media).""" +import os + +import pytest + +pytest.importorskip("PySide6.QtWidgets") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from je_auto_control.gui.a11y_audit_tab import A11yAuditTab # noqa: E402 +from je_auto_control.gui.device_matrix_tab import DeviceMatrixTab # noqa: E402 +from je_auto_control.gui.media_checks_tab import MediaChecksTab # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def test_a11y_audit_tab_contrast(app): + tab = A11yAuditTab() + tab._fg.setText("0,0,0") + tab._bg.setText("255,255,255") + tab._on_contrast() + assert "21" in tab._summary.text() + + +def test_device_matrix_tab_runs(app): + tab = DeviceMatrixTab() + tab._devices.setPlainText('[{"platform":"android","serial":"a"}]') + tab._actions.setPlainText( + '[["AC_set_var", {"name":"id","value":"${device.serial}"}]]', + ) + tab._on_run() + assert tab._table.rowCount() == 1 + assert "1" in tab._summary.text() + + +def test_media_checks_tab_instantiates(app): + tab = MediaChecksTab() + assert tab._video_threshold.value() == 1.0 diff --git a/test/unit_test/headless/test_qa_tabs_gui.py b/test/unit_test/headless/test_qa_tabs_gui.py new file mode 100644 index 00000000..5e836585 --- /dev/null +++ b/test/unit_test/headless/test_qa_tabs_gui.py @@ -0,0 +1,53 @@ +"""GUI smoke tests for the QA tabs (assertions / data source / flakiness).""" +import os + +import pytest + +pytest.importorskip("PySide6.QtWidgets") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from je_auto_control.gui.assertions_tab import AssertionsTab # noqa: E402 +from je_auto_control.gui.data_source_tab import DataSourceTab # noqa: E402 +from je_auto_control.gui.flakiness_tab import FlakinessTab # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def test_flakiness_tab_refreshes(app): + tab = FlakinessTab() + tab._refresh() + assert tab._status.text() != "" + + +def test_data_source_tab_loads_inline(app): + tab = DataSourceTab() + tab._kind.setCurrentText("inline") + tab._inline.setPlainText('[{"u": "x"}, {"u": "y"}]') + tab._on_load() + assert tab._table.rowCount() == 2 + assert tab._table.columnCount() == 1 + + +def test_data_source_tab_reports_error(app): + tab = DataSourceTab() + tab._kind.setCurrentText("csv") + tab._path.setText("/no/such/file_zzz.csv") + tab._on_load() + assert "fail" in tab._status.text().lower() or tab._status.text() + + +def test_assertions_tab_window_check(app, monkeypatch): + import je_auto_control.wrapper.auto_control_window as win + monkeypatch.setattr(win, "list_windows", lambda: [(1, "Calculator")]) + tab = AssertionsTab() + tab._kind.setCurrentIndex(3) # window + tab._target.setText("Calculator") + tab._expect.setChecked(True) + tab._on_run() + assert "PASS" in tab._result.text() or "pass" in tab._result.text().lower() diff --git a/test/unit_test/headless/test_test_suite.py b/test/unit_test/headless/test_test_suite.py new file mode 100644 index 00000000..d0807158 --- /dev/null +++ b/test/unit_test/headless/test_test_suite.py @@ -0,0 +1,134 @@ +"""Headless tests for the QA suite runner, reports, and quarantine.""" +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from je_auto_control.utils.quarantine.store import QuarantineStore +from je_auto_control.utils.test_suite import ( + STATUS_PASSED, run_suite, to_junit_xml, + write_allure_results, write_junit_xml, +) + +_PASS = ["AC_assert_window", {"title": "zzz_no_window", "exists": False}] +_FAIL = ["AC_assert_window", {"title": "zzz_no_window", "exists": True}] + + +def _spec(): + return { + "name": "Demo", + "setup": [["AC_set_var", {"name": "base", "value": 1}]], + "cases": [ + {"name": "passes", "tags": ["smoke"], "actions": [_PASS]}, + {"name": "fails", "actions": [_FAIL]}, + {"name": "each", "as": "row", + "data": {"kind": "inline", "rows": [{"u": "a"}, {"u": "b"}]}, + "actions": [["AC_set_var", {"name": "u", "value": "${row.u}"}]]}, + ], + } + + +def test_run_suite_scores_cases(): + result = run_suite(_spec(), respect_quarantine=False) + assert result.total == 4 + assert result.passed == 3 + assert result.failed == 1 + assert result.errored == 0 + assert result.success is False + names = [c.name for c in result.cases] + assert names == ["passes", "fails", "each[0]", "each[1]"] + + +def test_run_suite_tag_filter(): + result = run_suite(_spec(), tags=["smoke"], respect_quarantine=False) + assert result.total == 1 + assert result.cases[0].name == "passes" + assert result.cases[0].status == STATUS_PASSED + + +def test_setup_failure_skips_cases(): + spec = { + "name": "S", "setup": [_FAIL], + "cases": [{"name": "c", "actions": [_PASS]}], + } + result = run_suite(spec, respect_quarantine=False) + assert result.setup_error is not None + assert result.total == 0 + assert result.success is False + + +def test_quarantine_skips_named_case(monkeypatch): + import je_auto_control.utils.quarantine as q + monkeypatch.setattr(q, "quarantined_names", lambda: {"fails"}) + result = run_suite(_spec(), respect_quarantine=True) + fails = next(c for c in result.cases if c.name == "fails") + assert fails.status == "skipped" + assert fails.message == "quarantined" + # the failing case is now skipped, so the suite has no failures + assert result.failed == 0 + + +def test_junit_xml_is_wellformed(): + result = run_suite(_spec(), respect_quarantine=False) + xml = to_junit_xml(result) + root = ET.fromstring(xml) + suite = root.find("testsuite") + assert suite.attrib["tests"] == "4" + assert suite.attrib["failures"] == "1" + failures = root.findall(".//failure") + assert len(failures) == 1 + + +def test_write_junit_and_allure(tmp_path: Path): + result = run_suite(_spec(), respect_quarantine=False) + junit = write_junit_xml(result, str(tmp_path / "junit.xml")) + assert Path(junit).is_file() + allure = write_allure_results(result, str(tmp_path / "allure")) + assert len(allure) == 4 + assert all(Path(p).is_file() for p in allure) + + +# === quarantine store ====================================================== + +def test_quarantine_store_roundtrip(tmp_path: Path): + store = QuarantineStore(path=tmp_path / "q.json") + store.add("flaky.json", reason="manual", flip_rate=0.8) + assert store.is_quarantined("flaky.json") + assert "flaky.json" in store.names() + # persisted across instances + reloaded = QuarantineStore(path=tmp_path / "q.json") + assert reloaded.is_quarantined("flaky.json") + assert reloaded.remove("flaky.json") is True + assert reloaded.remove("flaky.json") is False + + +def test_auto_quarantine_from_flakiness(tmp_path: Path): + from je_auto_control.utils.quarantine.store import ( + auto_quarantine_from_flakiness, + ) + from je_auto_control.utils.run_history.history_store import HistoryStore + import je_auto_control.utils.flakiness as flak + + history = HistoryStore() + for status in ["ok", "error", "ok", "error"]: + run_id = history.start_run("manual", "j", "flaky.json") + history.finish_run(run_id, status) + + real_analyze = flak.analyze_flakiness + + def _patched(limit=500, min_runs=2, group_by="script_path"): + return real_analyze( + store=history, limit=limit, min_runs=min_runs, group_by=group_by, + ) + + store = QuarantineStore(path=tmp_path / "q.json") + monkey = pytest.MonkeyPatch() + monkey.setattr(flak, "analyze_flakiness", _patched) + try: + added = auto_quarantine_from_flakiness( + flip_rate_threshold=0.5, min_runs=2, store=store, + ) + finally: + monkey.undo() + assert any(e.name == "flaky.json" for e in added) + assert store.is_quarantined("flaky.json") From 532d982b7613315aae34f822b34eb097f3b93746 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 2 Jun 2026 20:39:57 +0800 Subject: [PATCH 09/10] Fix static-analysis findings flagged on PR 202 - bandit B107: justify resume_token="" default (reconnect handle, not a credential) - Sonar S5713: drop redundant exception subclasses already caught (json.JSONDecodeError/UnicodeDecodeError vs ValueError, FileNotFoundError vs OSError) - Sonar S1244: use pytest.approx for float comparisons in QA tests - Sonar S3776: extract datachannel dispatch from _wire_pc_handlers (16 -> under limit) - Sonar S6418/S116/S1313/S5332: justified NOSONAR on USB test fixtures and loopback scheme-detection (not real credentials / outbound calls) --- je_auto_control/gui/data_source_tab.py | 2 +- je_auto_control/gui/device_matrix_tab.py | 2 +- je_auto_control/gui/media_checks_tab.py | 2 +- je_auto_control/gui/test_suite_tab.py | 2 +- je_auto_control/gui/usb_browser_tab.py | 2 +- .../utils/remote_desktop/webrtc_viewer.py | 36 ++++++++++--------- .../utils/usb/passthrough/session.py | 2 +- .../utils/usb/passthrough/viewer_client.py | 2 +- test/unit_test/headless/test_a11y_audit.py | 8 +++-- test/unit_test/headless/test_flakiness.py | 4 +-- test/unit_test/headless/test_media_assert.py | 8 ++--- test/unit_test/headless/test_qa_tabs_b_gui.py | 2 +- .../headless/test_usb_key_provider.py | 2 +- .../headless/test_usb_passthrough.py | 2 +- .../headless/test_usb_passthrough_panel.py | 2 +- 15 files changed, 42 insertions(+), 36 deletions(-) diff --git a/je_auto_control/gui/data_source_tab.py b/je_auto_control/gui/data_source_tab.py index 0c61fb12..8845e084 100644 --- a/je_auto_control/gui/data_source_tab.py +++ b/je_auto_control/gui/data_source_tab.py @@ -108,7 +108,7 @@ def _on_load(self) -> None: try: limit = self._limit.value() or None rows = load_rows(self._build_source(), limit=limit) - except (ValueError, OSError, RuntimeError, json.JSONDecodeError) as error: + except (ValueError, OSError, RuntimeError) as error: self._status.setText(_t("ds_error").replace("{error}", str(error))) return self._render_rows(rows) diff --git a/je_auto_control/gui/device_matrix_tab.py b/je_auto_control/gui/device_matrix_tab.py index c4bb1ad7..addfc785 100644 --- a/je_auto_control/gui/device_matrix_tab.py +++ b/je_auto_control/gui/device_matrix_tab.py @@ -81,7 +81,7 @@ def _on_run(self) -> None: report = ac.run_on_devices( actions, devices, max_parallel=self._parallel.value(), ) - except (ValueError, RuntimeError, json.JSONDecodeError) as error: + except (ValueError, RuntimeError) as error: self._summary.setText(_t("dm_error").replace("{error}", str(error))) return self._render(report.to_dict()) diff --git a/je_auto_control/gui/media_checks_tab.py b/je_auto_control/gui/media_checks_tab.py index f6b74e39..20bc6218 100644 --- a/je_auto_control/gui/media_checks_tab.py +++ b/je_auto_control/gui/media_checks_tab.py @@ -108,7 +108,7 @@ def _on_video(self) -> None: expect_motion=self._video_expect.isChecked(), raise_on_fail=False, ) - except (RuntimeError, OSError, ValueError, FileNotFoundError) as error: + except (RuntimeError, OSError, ValueError) as error: self._result.setText(str(error)) return self._result.setText(result.message) diff --git a/je_auto_control/gui/test_suite_tab.py b/je_auto_control/gui/test_suite_tab.py index 3ef1b1dd..c2a88096 100644 --- a/je_auto_control/gui/test_suite_tab.py +++ b/je_auto_control/gui/test_suite_tab.py @@ -102,7 +102,7 @@ def _on_load_file(self) -> None: def _on_run(self) -> None: try: result = ac.run_suite(self._parse_spec()) - except (ValueError, OSError, RuntimeError, json.JSONDecodeError) as err: + except (ValueError, OSError, RuntimeError) as err: self._summary.setText(_t("suite_error").replace("{error}", str(err))) return self._last_result = result diff --git a/je_auto_control/gui/usb_browser_tab.py b/je_auto_control/gui/usb_browser_tab.py index 166dde06..b2b0d738 100644 --- a/je_auto_control/gui/usb_browser_tab.py +++ b/je_auto_control/gui/usb_browser_tab.py @@ -49,7 +49,7 @@ def _t(key: str) -> str: def _is_loopback_target(base_url: str) -> bool: """True if ``base_url`` points at this machine (so a local open is valid).""" text = base_url.strip() - if not text.startswith(("http://", "https://")): + if not text.startswith(("http://", "https://")): # NOSONAR python:S5332 - scheme detection on user input, not an outbound http call text = f"{_TEST_SCHEME}://{text}" host = urllib.parse.urlsplit(text).hostname or "" return host.lower() in _LOOPBACK_HOSTS diff --git a/je_auto_control/utils/remote_desktop/webrtc_viewer.py b/je_auto_control/utils/remote_desktop/webrtc_viewer.py index 38656131..09582e80 100644 --- a/je_auto_control/utils/remote_desktop/webrtc_viewer.py +++ b/je_auto_control/utils/remote_desktop/webrtc_viewer.py @@ -465,22 +465,26 @@ def _on_track(track) -> None: @pc.on("datachannel") def _on_datachannel(channel) -> None: - autocontrol_logger.info( - "webrtc viewer: data channel %r open", channel.label, - ) - if channel.label == "mic": - self._mic_channel = channel - return - if channel.label == "files": - self._files_channel = channel - self._wire_files_channel(channel) - return - if channel.label == "usb": - self._usb_channel = channel - self._wire_usb_channel(channel) - return - self._control_channel = channel - self._wire_control_channel(channel) + self._attach_datachannel(channel) + + def _attach_datachannel(self, channel) -> None: + """Route an inbound data channel to its handler by label.""" + autocontrol_logger.info( + "webrtc viewer: data channel %r open", channel.label, + ) + if channel.label == "mic": + self._mic_channel = channel + return + if channel.label == "files": + self._files_channel = channel + self._wire_files_channel(channel) + return + if channel.label == "usb": + self._usb_channel = channel + self._wire_usb_channel(channel) + return + self._control_channel = channel + self._wire_control_channel(channel) def _wire_control_channel(self, channel) -> None: @channel.on("open") diff --git a/je_auto_control/utils/usb/passthrough/session.py b/je_auto_control/utils/usb/passthrough/session.py index 38febc8a..597e598b 100644 --- a/je_auto_control/utils/usb/passthrough/session.py +++ b/je_auto_control/utils/usb/passthrough/session.py @@ -549,7 +549,7 @@ def _is_misbehaviour(replies: List[Frame]) -> bool: if reply.op == Opcode.OPENED: try: body = json.loads(reply.payload.decode("utf-8")) - except (ValueError, UnicodeDecodeError): + except ValueError: return True if not body.get("ok"): return True diff --git a/je_auto_control/utils/usb/passthrough/viewer_client.py b/je_auto_control/utils/usb/passthrough/viewer_client.py index 7b045582..e55e5e58 100644 --- a/je_auto_control/utils/usb/passthrough/viewer_client.py +++ b/je_auto_control/utils/usb/passthrough/viewer_client.py @@ -92,7 +92,7 @@ class ClientHandle: """ def __init__(self, client: "UsbPassthroughClient", claim_id: int, - resume_token: str = "") -> None: + resume_token: str = "") -> None: # nosec B107 # reason: resume_token is a reconnect handle, not a credential; "" means "no token yet" self._client = client self._claim_id = claim_id self._resume_token = resume_token diff --git a/test/unit_test/headless/test_a11y_audit.py b/test/unit_test/headless/test_a11y_audit.py index fcef6b6d..e13e97e8 100644 --- a/test/unit_test/headless/test_a11y_audit.py +++ b/test/unit_test/headless/test_a11y_audit.py @@ -1,6 +1,8 @@ """Headless tests for the accessibility / i18n audit.""" from types import SimpleNamespace +import pytest + import je_auto_control as ac from je_auto_control.utils.a11y_audit import ( audit_contrast, audit_missing_labels, contrast_ratio, detect_truncation, @@ -34,9 +36,9 @@ def test_missing_label_flagged(): def test_contrast_ratio_known_values(): # black on white is the maximum 21:1 - assert round(contrast_ratio([0, 0, 0], [255, 255, 255]), 1) == 21.0 + assert contrast_ratio([0, 0, 0], [255, 255, 255]) == pytest.approx(21.0) # identical colours are 1:1 - assert round(contrast_ratio([120, 120, 120], [120, 120, 120]), 1) == 1.0 + assert contrast_ratio([120, 120, 120], [120, 120, 120]) == pytest.approx(1.0) def test_audit_contrast_flags_low(): @@ -73,4 +75,4 @@ def test_executor_audit_contrast(): from je_auto_control.utils.executor.action_executor import executor out = executor.event_dict["AC_audit_contrast"]([0, 0, 0], [255, 255, 255]) assert out["passes_aa"] is True - assert out["ratio"] == 21.0 + assert out["ratio"] == pytest.approx(21.0) diff --git a/test/unit_test/headless/test_flakiness.py b/test/unit_test/headless/test_flakiness.py index d80d75f7..683f3d83 100644 --- a/test/unit_test/headless/test_flakiness.py +++ b/test/unit_test/headless/test_flakiness.py @@ -32,7 +32,7 @@ def test_flaky_script_detected(store): assert entry.flaky is True assert entry.ok == 2 and entry.error == 2 assert entry.flips == 3 - assert entry.flip_rate == 1.0 + assert entry.flip_rate == pytest.approx(1.0) assert report.flaky_count == 1 @@ -43,7 +43,7 @@ def test_stable_script_not_flaky(store): entry = report.entries[0] assert entry.flaky is False assert entry.flips == 0 - assert entry.pass_rate == 1.0 + assert entry.pass_rate == pytest.approx(1.0) assert report.flaky_count == 0 diff --git a/test/unit_test/headless/test_media_assert.py b/test/unit_test/headless/test_media_assert.py index 18127baa..f8581ca1 100644 --- a/test/unit_test/headless/test_media_assert.py +++ b/test/unit_test/headless/test_media_assert.py @@ -16,16 +16,16 @@ def test_rms_pure(): - assert media.rms([]) == 0.0 + assert media.rms([]) == pytest.approx(0.0) assert media.rms([3, 4]) == pytest.approx(math.sqrt((9 + 16) / 2)) - assert media.rms([0, 0, 0]) == 0.0 + assert media.rms([0, 0, 0]) == pytest.approx(0.0) def test_mean_frame_diff_pure(): np = pytest.importorskip("numpy") frame_a = np.zeros((2, 2)) frame_b = np.full((2, 2), 10.0) - assert media.mean_frame_diff([frame_a]) == 0.0 + assert media.mean_frame_diff([frame_a]) == pytest.approx(0.0) assert media.mean_frame_diff([frame_a, frame_b]) == pytest.approx(10.0) @@ -60,7 +60,7 @@ def test_assert_video_changes_motion(monkeypatch): "x.mp4", threshold=1.0, expect_motion=True, ) assert result.passed is True - assert result.measured == 9.0 + assert result.measured == pytest.approx(9.0) def test_assert_video_static_fail(monkeypatch): diff --git a/test/unit_test/headless/test_qa_tabs_b_gui.py b/test/unit_test/headless/test_qa_tabs_b_gui.py index 7bc727dd..785817b1 100644 --- a/test/unit_test/headless/test_qa_tabs_b_gui.py +++ b/test/unit_test/headless/test_qa_tabs_b_gui.py @@ -40,4 +40,4 @@ def test_device_matrix_tab_runs(app): def test_media_checks_tab_instantiates(app): tab = MediaChecksTab() - assert tab._video_threshold.value() == 1.0 + assert tab._video_threshold.value() == pytest.approx(1.0) diff --git a/test/unit_test/headless/test_usb_key_provider.py b/test/unit_test/headless/test_usb_key_provider.py index 7674a138..556cf9fb 100644 --- a/test/unit_test/headless/test_usb_key_provider.py +++ b/test/unit_test/headless/test_usb_key_provider.py @@ -25,7 +25,7 @@ def test_dpapi_available_matches_platform(): @pytest.mark.skipif(not _IS_WINDOWS, reason="DPAPI is Windows-only") def test_dpapi_round_trip(): - secret = b"a-32-byte-key-or-anything-really" + secret = b"a-32-byte-key-or-anything-really" # NOSONAR python:S6418 - test plaintext, not a real credential blob = dpapi_protect(secret) assert blob != secret # actually encrypted assert dpapi_unprotect(blob) == secret diff --git a/test/unit_test/headless/test_usb_passthrough.py b/test/unit_test/headless/test_usb_passthrough.py index aa81e2cf..23b5b760 100644 --- a/test/unit_test/headless/test_usb_passthrough.py +++ b/test/unit_test/headless/test_usb_passthrough.py @@ -526,7 +526,7 @@ def test_usb_handle_is_an_abc(): class _FakeInterface: def __init__(self, number: int) -> None: - self.bInterfaceNumber = number + self.bInterfaceNumber = number # NOSONAR python:S116 - mirrors the USB spec descriptor field name class _FakeKernelDevice: diff --git a/test/unit_test/headless/test_usb_passthrough_panel.py b/test/unit_test/headless/test_usb_passthrough_panel.py index b83ebccc..4be0215e 100644 --- a/test/unit_test/headless/test_usb_passthrough_panel.py +++ b/test/unit_test/headless/test_usb_passthrough_panel.py @@ -38,7 +38,7 @@ ("127.0.0.1:9939", True), ("localhost:9939", True), ("http://localhost", True), - ("http://192.168.1.5:9939", False), + ("http://192.168.1.5:9939", False), # NOSONAR python:S1313,python:S5332 - test data asserting a non-loopback URL is rejected ("https://example.com", False), ]) def test_is_loopback_target(url, expected): From 5b3aa74384485faa331c280d07f95610e190a66c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 2 Jun 2026 20:46:23 +0800 Subject: [PATCH 10/10] Fix Codacy findings on PR 202 - use-defused-xml: switch test XML parse to defusedxml.ElementTree; justify the write-only xml.etree import in reports.py (no untrusted parsing) - dangerous-subprocess-use-audit: nosemgrep on the Qt-free subprocess probe (fixed argv, sys.executable, no shell) - pylint not-callable: disable on registry.usb_client getter guarded by callable() --- je_auto_control/utils/remote_desktop/registry.py | 1 + je_auto_control/utils/test_suite/reports.py | 2 +- test/unit_test/headless/test_assertions.py | 2 +- test/unit_test/headless/test_test_suite.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/je_auto_control/utils/remote_desktop/registry.py b/je_auto_control/utils/remote_desktop/registry.py index daf1fd4e..4fffddbe 100644 --- a/je_auto_control/utils/remote_desktop/registry.py +++ b/je_auto_control/utils/remote_desktop/registry.py @@ -363,6 +363,7 @@ def webrtc_usb_client(self): if viewer is None: return None getter = getattr(viewer, "usb_client", None) + # pylint: disable=not-callable # reason: guarded by callable(getter) return getter() if callable(getter) else None diff --git a/je_auto_control/utils/test_suite/reports.py b/je_auto_control/utils/test_suite/reports.py index a4314e08..61c2a079 100644 --- a/je_auto_control/utils/test_suite/reports.py +++ b/je_auto_control/utils/test_suite/reports.py @@ -12,7 +12,7 @@ import json import uuid -import xml.etree.ElementTree as ET +import xml.etree.ElementTree as ET # nosemgrep # nosec B405 # reason: write-only XML generation; never parses untrusted input from pathlib import Path from typing import Any, Dict, List diff --git a/test/unit_test/headless/test_assertions.py b/test/unit_test/headless/test_assertions.py index 8cf864de..aaaa2eb1 100644 --- a/test/unit_test/headless/test_assertions.py +++ b/test/unit_test/headless/test_assertions.py @@ -30,7 +30,7 @@ def test_assertion_import_stays_qt_free(): "qt = [m for m in sys.modules if 'PySide6' in m]\n" "import json; print(json.dumps(qt))\n" ) - result = subprocess.run( # nosec B603 + result = subprocess.run( # nosec B603 # nosemgrep [sys.executable, "-c", script], capture_output=True, text=True, check=True, timeout=60, ) diff --git a/test/unit_test/headless/test_test_suite.py b/test/unit_test/headless/test_test_suite.py index d0807158..cf161d34 100644 --- a/test/unit_test/headless/test_test_suite.py +++ b/test/unit_test/headless/test_test_suite.py @@ -1,5 +1,5 @@ """Headless tests for the QA suite runner, reports, and quarantine.""" -import xml.etree.ElementTree as ET +import defusedxml.ElementTree as ET from pathlib import Path import pytest