Skip to content

Commit 216c021

Browse files
committed
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.
1 parent a714789 commit 216c021

10 files changed

Lines changed: 203 additions & 28 deletions

File tree

docs/source/Eng/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,13 @@ What is *not* shipped yet
279279
one (a descriptor read proves the full stack). The *USB Browser* tab's
280280
*Open* button now also works against a **localhost** target via the
281281
same loopback path.
282-
- Cross-machine transport is now wired: the WebRTC host creates a
283-
``usb`` DataChannel and the viewer exposes ``viewer.usb_client()``
284-
(a ``UsbChannelClient`` with ``list_devices`` / ``open`` / ``resume``).
285-
Drive it from Python today over a live WebRTC session. The simple
286-
*USB Browser* / *USB Sharing* panels still use the local loopback +
287-
REST paths; auto-wiring those panels to a live WebRTC viewer session
288-
is the remaining GUI integration step.
282+
- Cross-machine is fully wired: the WebRTC host creates a ``usb``
283+
DataChannel and the viewer exposes ``viewer.usb_client()`` (a
284+
``UsbChannelClient`` with ``list_devices`` / ``open`` / ``resume``).
285+
The *USB Sharing* panel has a **Source** selector — pick *Remote
286+
(WebRTC)* and the List / Open buttons run against the live WebRTC
287+
viewer's host (via ``registry.webrtc_usb_client()``); pick *Local
288+
(loopback)* for same-machine use. You can also drive it from Python.
289289
- Windows WinUSB and macOS IOKit transfer paths are written but not yet
290290
validated against real hardware. Do not use in production until the
291291
Phase 2e hardware test matrix passes.

docs/source/Zh/doc/operations_layer/usb_passthrough_operator_guide.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,12 @@ OPEN 後 host 鍵盤停止運作 Linux:HID 裝置被 claim
258258
做 ACL 允許/封鎖;右側經 in-process channel 列出分享裝置並 *開啟*
259259
其中一個(讀描述元即證明整條堆疊運作)。*USB Browser* 分頁的 *Open*
260260
按鈕現在對 **localhost** 目標也會走同一條 loopback 路徑。
261-
- 跨機器 transport 已串接:WebRTC host 會建立 ``usb`` DataChannel,
262-
viewer 以 ``viewer.usb_client()`` 暴露 ``UsbChannelClient``\ (含
263-
``list_devices`` / ``open`` / ``resume``\ )。今天即可在 WebRTC session
264-
上從 Python 驅動。簡易的 *USB Browser* / *USB 分享* 面板目前仍走本機
265-
loopback + REST;把面板自動接到 live WebRTC viewer session 是剩餘的
266-
GUI 整合步驟
261+
- 跨機器已完整串接:WebRTC host 建立 ``usb`` DataChannel,viewer 以
262+
``viewer.usb_client()`` 暴露 ``UsbChannelClient``\ (含 ``list_devices``
263+
/ ``open`` / ``resume``\ )。*USB 分享* 面板有 **來源** 下拉:選
264+
*遠端(WebRTC)* 時 List / Open 會對 live WebRTC viewer 的主機操作
265+
(經 ``registry.webrtc_usb_client()``\ );選 *本機(loopback)* 則走同機。
266+
亦可從 Python 驅動
267267
- Windows WinUSB 與 macOS IOKit 的 transfer 路徑已寫但尚未對實體硬體
268268
驗證。在 Phase 2e 硬體測試矩陣通過前請勿用於 production。
269269
- Phase 2e 外部安全審查尚未簽核;feature flag 必須維持顯式 opt-in。

je_auto_control/gui/language_wrapper/english.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@
156156
"usb_share_acl_imported": "Imported {count} rule(s).",
157157
"usb_share_acl_import_failed": "ACL I/O failed: {error}",
158158
"usb_share_auto_refresh": "Auto-refresh (hotplug)",
159+
"usb_share_source_label": "Source:",
160+
"usb_share_source_local": "Local (loopback)",
161+
"usb_share_source_remote": "Remote (WebRTC)",
162+
"usb_share_no_webrtc": "No live WebRTC session — connect a WebRTC viewer first.",
159163

160164
# Inspector tab
161165
"inspector_metrics_group": "Rolling metrics",

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@
154154
"usb_share_acl_imported": "{count} 件のルールをインポートしました。",
155155
"usb_share_acl_import_failed": "ACL 入出力に失敗:{error}",
156156
"usb_share_auto_refresh": "自動更新(ホットプラグ)",
157+
"usb_share_source_label": "ソース:",
158+
"usb_share_source_local": "ローカル(loopback)",
159+
"usb_share_source_remote": "リモート(WebRTC)",
160+
"usb_share_no_webrtc": "ライブ WebRTC セッションがありません — 先に WebRTC viewer に接続してください。",
157161

158162
# 監視タブ
159163
"inspector_metrics_group": "集約メトリクス",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@
145145
"usb_share_acl_imported": "已导入 {count} 条规则。",
146146
"usb_share_acl_import_failed": "ACL 读写失败:{error}",
147147
"usb_share_auto_refresh": "自动刷新(热插拔)",
148+
"usb_share_source_label": "来源:",
149+
"usb_share_source_local": "本机(loopback)",
150+
"usb_share_source_remote": "远程(WebRTC)",
151+
"usb_share_no_webrtc": "没有 live WebRTC session — 请先连接 WebRTC viewer。",
148152

149153
# 包监测分页
150154
"inspector_metrics_group": "汇总指标",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@
146146
"usb_share_acl_imported": "已匯入 {count} 條規則。",
147147
"usb_share_acl_import_failed": "ACL 讀寫失敗:{error}",
148148
"usb_share_auto_refresh": "自動刷新(熱插拔)",
149+
"usb_share_source_label": "來源:",
150+
"usb_share_source_local": "本機(loopback)",
151+
"usb_share_source_remote": "遠端(WebRTC)",
152+
"usb_share_no_webrtc": "沒有 live WebRTC session — 請先連線 WebRTC viewer。",
149153

150154
# 封包監測分頁
151155
"inspector_metrics_group": "彙整指標",

je_auto_control/gui/usb_passthrough_panel.py

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121

2222
from PySide6.QtCore import QObject, QThread, QTimer, Signal
2323
from PySide6.QtWidgets import (
24-
QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, QLabel,
25-
QLineEdit, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem,
26-
QVBoxLayout, QWidget,
24+
QCheckBox, QComboBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView,
25+
QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget,
26+
QTableWidgetItem, QVBoxLayout, QWidget,
2727
)
2828

2929
from je_auto_control.gui._i18n_helpers import TranslatableMixin
@@ -76,15 +76,22 @@ class UsbPassthroughPanel(TranslatableMixin, QWidget):
7676
def __init__(self, parent: Optional[QWidget] = None, *,
7777
acl: Optional[UsbAcl] = None,
7878
loopback_factory: Optional[Callable[[], UsbLoopback]] = None,
79+
remote_client_provider: Optional[
80+
Callable[[], Optional[Any]]
81+
] = None,
7982
) -> None:
8083
super().__init__(parent)
8184
self._tr_init()
8285
self._acl = acl if acl is not None else UsbAcl()
8386
self._loopback_factory = loopback_factory or self._default_loopback
87+
self._remote_client_provider = (
88+
remote_client_provider or _default_remote_client
89+
)
8490
self._loopback: Optional[UsbLoopback] = None
8591
self._thread: Optional[QThread] = None
8692
self._host_badge = _StatusBadge()
8793
self._viewer_status = QLabel("")
94+
self._source_combo = QComboBox()
8895
self._auto_check = QCheckBox()
8996
self._auto_check.toggled.connect(self._on_auto_toggled)
9097
self._hotplug_timer = QTimer(self)
@@ -97,6 +104,7 @@ def __init__(self, parent: Optional[QWidget] = None, *,
97104
self._remote_token = QLineEdit()
98105
self._remote_token.setEchoMode(QLineEdit.EchoMode.Password)
99106
self._build_layout()
107+
self._populate_source_combo()
100108
self._apply_local_headers()
101109
self._apply_shared_headers()
102110
self._refresh_local_devices()
@@ -161,6 +169,10 @@ def _build_viewer_section(self) -> QWidget:
161169
intro = self._tr(QLabel(), "usb_share_intro")
162170
intro.setWordWrap(True)
163171
layout.addWidget(intro)
172+
source_row = QHBoxLayout()
173+
source_row.addWidget(self._tr(QLabel(), "usb_share_source_label"))
174+
source_row.addWidget(self._source_combo, stretch=1)
175+
layout.addLayout(source_row)
164176
layout.addWidget(self._shared_table, stretch=1)
165177
use_row = QHBoxLayout()
166178
list_btn = self._tr(QPushButton(), "usb_share_fetch_shared")
@@ -204,8 +216,18 @@ def _apply_shared_headers(self) -> None:
204216
_t("usb_share_col_product"), _t("usb_share_col_serial"),
205217
])
206218

219+
def _populate_source_combo(self) -> None:
220+
index = max(0, self._source_combo.currentIndex())
221+
self._source_combo.blockSignals(True)
222+
self._source_combo.clear()
223+
self._source_combo.addItem(_t("usb_share_source_local"))
224+
self._source_combo.addItem(_t("usb_share_source_remote"))
225+
self._source_combo.setCurrentIndex(index)
226+
self._source_combo.blockSignals(False)
227+
207228
def retranslate(self) -> None:
208229
TranslatableMixin.retranslate(self)
230+
self._populate_source_combo()
209231
self._apply_local_headers()
210232
self._apply_shared_headers()
211233
self._refresh_host_badge()
@@ -330,15 +352,40 @@ def _import_acl(self) -> None:
330352
)
331353
self._refresh_local_devices()
332354

333-
# --- use (loopback) ----------------------------------------------------
355+
# --- use (loopback or live WebRTC) -------------------------------------
356+
357+
def _source_is_remote(self) -> bool:
358+
return self._source_combo.currentIndex() == 1
359+
360+
def _active_use_client(self) -> Any:
361+
"""Return the client for the selected source, or raise a friendly error.
362+
363+
Both the loopback bundle and the WebRTC ``UsbChannelClient``
364+
expose ``list_devices`` and ``open`` with the same signatures, so
365+
the use actions are source-agnostic.
366+
"""
367+
if self._source_is_remote():
368+
client = self._remote_client_provider()
369+
if client is None:
370+
raise RuntimeError(_t("usb_share_no_webrtc"))
371+
return client
372+
if self._loopback is None:
373+
raise RuntimeError(_t("usb_share_enable_first"))
374+
return self._loopback
375+
376+
def _use_client_or_warn(self) -> Any:
377+
try:
378+
return self._active_use_client()
379+
except RuntimeError as error:
380+
self._info(str(error))
381+
return None
334382

335383
def _list_shared(self) -> None:
336-
loop = self._loopback
337-
if loop is None:
338-
self._info(_t("usb_share_enable_first"))
384+
client = self._use_client_or_warn()
385+
if client is None:
339386
return
340387
self._viewer_status.setText(_t("usb_share_listing"))
341-
self._run_async(loop.list_devices, self._apply_shared, self._fail)
388+
self._run_async(client.list_devices, self._apply_shared, self._fail)
342389

343390
def _apply_shared(self, devices: List[dict]) -> None:
344391
self._viewer_status.setText(
@@ -354,9 +401,8 @@ def _apply_shared(self, devices: List[dict]) -> None:
354401
self._shared_table.setItem(row, col, QTableWidgetItem(str(text)))
355402

356403
def _open_selected(self) -> None:
357-
loop = self._loopback
358-
if loop is None:
359-
self._info(_t("usb_share_enable_first"))
404+
client = self._use_client_or_warn()
405+
if client is None:
360406
return
361407
row = _selected_row(self._shared_table)
362408
if row is None:
@@ -369,7 +415,7 @@ def _open_selected(self) -> None:
369415
_t("usb_share_opening").format(vid=vid, pid=pid),
370416
)
371417
self._run_async(
372-
lambda: _probe_device(loop, vid, pid, serial),
418+
lambda: _probe_device(client, vid, pid, serial),
373419
lambda descriptor: self._opened(vid, pid, descriptor),
374420
self._fail,
375421
)
@@ -458,10 +504,23 @@ def _selected_row(table: QTableWidget) -> Optional[int]:
458504
return rows[0] if rows else None
459505

460506

461-
def _probe_device(loop: UsbLoopback, vid: str, pid: str,
507+
def _default_remote_client() -> Optional[Any]:
508+
"""Return the live WebRTC viewer's USB client, or None if unavailable."""
509+
try:
510+
from je_auto_control.utils.remote_desktop.registry import registry
511+
except ImportError:
512+
return None
513+
return registry.webrtc_usb_client()
514+
515+
516+
def _probe_device(client: Any, vid: str, pid: str,
462517
serial: Optional[str]) -> bytes:
463-
"""Open the device, read its descriptor as a liveness proof, close."""
464-
handle = loop.open(vendor_id=vid, product_id=pid, serial=serial)
518+
"""Open the device, read its descriptor as a liveness proof, close.
519+
520+
``client`` is either a :class:`UsbLoopback` or a WebRTC
521+
``UsbChannelClient`` — both expose the same ``open`` signature.
522+
"""
523+
handle = client.open(vendor_id=vid, product_id=pid, serial=serial)
465524
try:
466525
return handle.control_transfer(
467526
bm_request_type=_DESC_REQUEST_TYPE, b_request=_DESC_REQUEST,

je_auto_control/utils/remote_desktop/registry.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,5 +352,18 @@ def webrtc_viewer_status(self) -> Dict[str, Any]:
352352
"authenticated": getattr(viewer, "authenticated", False),
353353
}
354354

355+
def webrtc_usb_client(self):
356+
"""Return the live WebRTC viewer's USB passthrough client, or None.
357+
358+
The viewer exposes ``usb_client()`` once the host has opened the
359+
``usb`` DataChannel. Returns None when no WebRTC viewer is active
360+
or the channel hasn't been negotiated yet.
361+
"""
362+
viewer = self._webrtc_viewer
363+
if viewer is None:
364+
return None
365+
getter = getattr(viewer, "usb_client", None)
366+
return getter() if callable(getter) else None
367+
355368

356369
registry = _RemoteDesktopRegistry()

test/unit_test/headless/test_usb_passthrough_panel.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,60 @@ def test_panel_hotplug_toggle_starts_and_stops(qapp, tmp_path):
133133
panel.deleteLater()
134134

135135

136+
def test_panel_remote_source_uses_provider(qapp, tmp_path):
137+
"""When 'Remote (WebRTC)' is selected, the panel routes to the
138+
injected provider's client instead of the local loopback."""
139+
backend = FakeUsbBackend(devices=[_SAMPLE])
140+
acl = UsbAcl(path=tmp_path / "acl.json")
141+
acl.add_rule(AclRule(vendor_id="1050", product_id="0407", allow=True))
142+
remote = UsbLoopback(backend=backend, acl=acl, viewer_id="remote")
143+
panel = _panel_mod.UsbPassthroughPanel(
144+
acl=UsbAcl(path=tmp_path / "host_acl.json"),
145+
loopback_factory=lambda: UsbLoopback(
146+
backend=FakeUsbBackend(devices=[]), acl=acl,
147+
),
148+
remote_client_provider=lambda: remote,
149+
)
150+
try:
151+
# Index 1 == "Remote (WebRTC)".
152+
panel._source_combo.setCurrentIndex(1)
153+
client = panel._active_use_client()
154+
assert client is remote
155+
assert [d["vendor_id"] for d in client.list_devices()] == ["1050"]
156+
finally:
157+
remote.close()
158+
panel.deleteLater()
159+
160+
161+
def test_panel_remote_source_without_session_warns(qapp, tmp_path):
162+
panel = _panel_mod.UsbPassthroughPanel(
163+
acl=UsbAcl(path=tmp_path / "acl.json"),
164+
loopback_factory=lambda: UsbLoopback(
165+
backend=FakeUsbBackend(devices=[]),
166+
acl=UsbAcl(path=tmp_path / "acl.json"),
167+
),
168+
remote_client_provider=lambda: None, # no live WebRTC session
169+
)
170+
try:
171+
panel._source_combo.setCurrentIndex(1)
172+
import pytest as _pytest
173+
with _pytest.raises(RuntimeError):
174+
panel._active_use_client()
175+
finally:
176+
panel.deleteLater()
177+
178+
179+
def test_panel_local_source_is_default(qapp, tmp_path):
180+
panel, _acl = _make_panel(qapp, tmp_path)
181+
try:
182+
assert panel._source_combo.currentIndex() == 0
183+
panel._enable_sharing()
184+
assert panel._active_use_client() is panel._loopback
185+
finally:
186+
panel._disable_sharing()
187+
panel.deleteLater()
188+
189+
136190
def test_panel_export_import_acl_round_trip(qapp, tmp_path):
137191
panel, acl = _make_panel(qapp, tmp_path)
138192
try:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""registry.webrtc_usb_client() exposes the live WebRTC viewer's USB client."""
2+
from je_auto_control.utils.remote_desktop.registry import registry
3+
4+
5+
def test_webrtc_usb_client_none_when_no_viewer():
6+
# No WebRTC viewer is active in a fresh process.
7+
registry._webrtc_viewer = None # noqa: SLF001 test setup
8+
assert registry.webrtc_usb_client() is None
9+
10+
11+
def test_webrtc_usb_client_delegates_to_viewer():
12+
sentinel = object()
13+
14+
class _FakeViewer:
15+
def usb_client(self):
16+
return sentinel
17+
18+
registry._webrtc_viewer = _FakeViewer() # noqa: SLF001 test setup
19+
try:
20+
assert registry.webrtc_usb_client() is sentinel
21+
finally:
22+
registry._webrtc_viewer = None # noqa: SLF001 cleanup
23+
24+
25+
def test_webrtc_usb_client_tolerates_viewer_without_method():
26+
class _OldViewer:
27+
pass
28+
29+
registry._webrtc_viewer = _OldViewer() # noqa: SLF001 test setup
30+
try:
31+
assert registry.webrtc_usb_client() is None
32+
finally:
33+
registry._webrtc_viewer = None # noqa: SLF001 cleanup

0 commit comments

Comments
 (0)