Skip to content

Commit b91689b

Browse files
committed
Show frames on both ends of Remote Desktop and harden viewer connect
The Host sub-tab previously had only text status — the user being remoted could not tell what the connected viewers actually saw. Adds a preview pane below the controls driven by a 4 fps QTimer that polls the host's new public latest_frame() helper. The pane is disabled so a host watching themselves cannot self-trigger fake input through the local widget. Viewer connect was racy: callbacks were patched on the viewer instance *after* connect() returned, so frames received in the gap between the receiver thread starting and the GUI patching _on_frame were dropped silently. registry.connect_viewer now accepts on_frame / on_error and threads them through RemoteDesktopViewer.__init__, so the receiver thread is born with the right callbacks. Adds three Qt integration tests that run against an offscreen QApplication and prove end-to-end: viewer panel decodes and shows incoming JPEG frames, host preview mirrors what is streamed, and viewer mouse events round-trip back to the host's input dispatcher.
1 parent 911aaf7 commit b91689b

8 files changed

Lines changed: 207 additions & 10 deletions

File tree

je_auto_control/gui/language_wrapper/english.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@
400400
"rd_host_stop": "Stop host",
401401
"rd_host_status_running": "Running on port {port} — {n} viewer(s)",
402402
"rd_host_status_stopped": "Host is stopped",
403+
"rd_host_preview_label": "Preview (what viewers see):",
403404
"rd_viewer_connect": "Connect",
404405
"rd_viewer_disconnect": "Disconnect",
405406
"rd_viewer_required_fields": (

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@
400400
"rd_host_stop": "ホスト停止",
401401
"rd_host_status_running": "稼働中 ポート {port} — ビューア {n} 名",
402402
"rd_host_status_stopped": "ホストは停止中",
403+
"rd_host_preview_label": "プレビュー(ビューアの表示):",
403404
"rd_viewer_connect": "接続",
404405
"rd_viewer_disconnect": "切断",
405406
"rd_viewer_required_fields": "アドレス・ポート・トークンはすべて必須です。",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@
394394
"rd_host_stop": "停止 Host",
395395
"rd_host_status_running": "运行中 端口 {port} — {n} 个 viewer",
396396
"rd_host_status_stopped": "Host 已停止",
397+
"rd_host_preview_label": "预览(viewer 看到的画面):",
397398
"rd_viewer_connect": "连接",
398399
"rd_viewer_disconnect": "断开",
399400
"rd_viewer_required_fields": "地址、端口、token 都必须填写。",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@
395395
"rd_host_stop": "停止 Host",
396396
"rd_host_status_running": "運行中 port {port} — {n} 個 viewer",
397397
"rd_host_status_stopped": "Host 已停止",
398+
"rd_host_preview_label": "預覽(viewer 看到的畫面):",
398399
"rd_viewer_connect": "連線",
399400
"rd_viewer_disconnect": "中斷連線",
400401
"rd_viewer_required_fields": "位址、port、token 都必須填寫。",

je_auto_control/gui/remote_desktop_tab.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,9 @@ def keyReleaseEvent(self, event: QKeyEvent) -> None: # noqa: N802
207207

208208

209209
class _HostPanel(TranslatableMixin, QWidget):
210-
"""Start / stop the singleton host and show its status."""
210+
"""Start / stop the singleton host and show what is being streamed."""
211+
212+
_PREVIEW_INTERVAL_MS = 250 # 4 fps preview is enough to confirm liveness
211213

212214
def __init__(self, parent: Optional[QWidget] = None) -> None:
213215
super().__init__(parent)
@@ -224,15 +226,23 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
224226
self._quality.setRange(1, 95)
225227
self._quality.setValue(70)
226228
self._status = QLabel()
229+
self._preview = _FrameDisplay()
230+
# Preview is read-only — a host watching their own stream shouldn't
231+
# trigger fake input on themselves through the local widget.
232+
self._preview.setEnabled(False)
227233
self._start_btn: Optional[QPushButton] = None
228234
self._stop_btn: Optional[QPushButton] = None
229235
self._refresh_timer = QTimer(self)
230236
self._refresh_timer.setInterval(1000)
231237
self._refresh_timer.timeout.connect(self._refresh_status)
238+
self._preview_timer = QTimer(self)
239+
self._preview_timer.setInterval(self._PREVIEW_INTERVAL_MS)
240+
self._preview_timer.timeout.connect(self._refresh_preview)
232241
self._build_layout()
233242
self._apply_placeholders()
234243
self._refresh_status()
235244
self._refresh_timer.start()
245+
self._preview_timer.start()
236246

237247
def retranslate(self) -> None:
238248
TranslatableMixin.retranslate(self)
@@ -289,8 +299,9 @@ def _build_layout(self) -> None:
289299
btn_row.addStretch()
290300
root.addLayout(btn_row)
291301

302+
root.addWidget(self._tr(QLabel(), "rd_host_preview_label"))
303+
root.addWidget(self._preview, stretch=1)
292304
root.addWidget(self._status)
293-
root.addStretch()
294305

295306
def _generate_token(self) -> None:
296307
self._token.setText(secrets.token_urlsafe(24))
@@ -331,6 +342,18 @@ def _refresh_status(self) -> None:
331342
text = _t("rd_host_status_stopped")
332343
self._status.setText(text)
333344

345+
def _refresh_preview(self) -> None:
346+
host = registry.host
347+
if host is None or not host.is_running:
348+
self._preview.clear()
349+
return
350+
frame = host.latest_frame()
351+
if frame is None:
352+
return
353+
image = QImage.fromData(frame, "JPEG")
354+
if not image.isNull():
355+
self._preview.set_image(image)
356+
334357

335358
class _ViewerPanel(TranslatableMixin, QWidget):
336359
"""Connect to a host, render frames, and forward input events."""
@@ -425,17 +448,15 @@ def _connect(self) -> None:
425448
try:
426449
registry.connect_viewer(
427450
host=host, port=port, token=token, timeout=5.0,
451+
on_frame=self._frame_signal.emit,
452+
on_error=lambda exc: self._error_signal.emit(str(exc)),
428453
)
429454
except AuthenticationError as error:
430455
QMessageBox.warning(self, _t("rd_viewer_connect"), str(error))
431456
return
432457
except (OSError, ConnectionError, RuntimeError) as error:
433458
QMessageBox.warning(self, _t("rd_viewer_connect"), str(error))
434459
return
435-
viewer = registry.viewer
436-
if viewer is not None:
437-
viewer._on_frame = self._frame_signal.emit # noqa: SLF001
438-
viewer._on_error = lambda exc: self._error_signal.emit(str(exc)) # noqa: SLF001
439460
self._connected = True
440461
self._refresh_status()
441462

je_auto_control/utils/remote_desktop/host.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,15 @@ def connected_clients(self) -> int:
252252
if client.authenticated and not client._shutdown.is_set()
253253
)
254254

255+
def latest_frame(self) -> Optional[bytes]:
256+
"""Return the most recent encoded frame (JPEG bytes) or ``None``.
257+
258+
Useful for a local preview pane: the GUI can poll this without
259+
opening a TCP connection back to the host.
260+
"""
261+
with self._frame_cond:
262+
return self._latest_frame
263+
255264
def start(self) -> None:
256265
"""Bind, then launch accept + capture threads."""
257266
if self.is_running:

je_auto_control/utils/remote_desktop/registry.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
references here keeps :mod:`action_executor` thin and avoids circular
66
imports between the executor and the host/viewer classes.
77
"""
8-
from typing import Any, Dict, Optional, Sequence
8+
from typing import Any, Callable, Dict, Optional, Sequence
99

1010
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
1111
from je_auto_control.utils.remote_desktop.viewer import RemoteDesktopViewer
1212

13+
FrameCallback = Callable[[bytes], None]
14+
ErrorCallback = Callable[[Exception], None]
15+
1316

1417
class _RemoteDesktopRegistry:
1518
"""Hold one host + one viewer for the executor command surface."""
@@ -62,10 +65,21 @@ def host_status(self) -> Dict[str, Any]:
6265
}
6366

6467
def connect_viewer(self, host: str, port: int, token: str,
65-
timeout: float = 5.0) -> Dict[str, Any]:
66-
"""Disconnect any existing viewer, then connect a fresh one."""
68+
timeout: float = 5.0,
69+
on_frame: Optional[FrameCallback] = None,
70+
on_error: Optional[ErrorCallback] = None,
71+
) -> Dict[str, Any]:
72+
"""Disconnect any existing viewer, then connect a fresh one.
73+
74+
``on_frame`` and ``on_error`` are wired before the receiver
75+
thread starts, so no frame can arrive while the GUI is still
76+
attaching its callbacks.
77+
"""
6778
self.disconnect_viewer()
68-
viewer = RemoteDesktopViewer(host=host, port=int(port), token=token)
79+
viewer = RemoteDesktopViewer(
80+
host=host, port=int(port), token=token,
81+
on_frame=on_frame, on_error=on_error,
82+
)
6983
viewer.connect(timeout=float(timeout))
7084
self._viewer = viewer
7185
return self.viewer_status()
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Qt integration tests for the Remote Desktop GUI tab.
2+
3+
Runs against an offscreen QApplication so it stays headless. Verifies
4+
the viewer's FrameDisplay actually receives and decodes JPEG frames
5+
end-to-end, and that the host preview pane mirrors what is being sent.
6+
"""
7+
import os
8+
import time
9+
from io import BytesIO
10+
11+
import pytest
12+
13+
# Force Qt to use the offscreen platform plugin so the test runs without a
14+
# display server (and without flashing windows on a real desktop).
15+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
16+
17+
PIL = pytest.importorskip("PIL.Image")
18+
pyside = pytest.importorskip("PySide6.QtWidgets")
19+
20+
from PySide6.QtCore import Qt # noqa: E402
21+
from PySide6.QtWidgets import QApplication # noqa: E402
22+
23+
from je_auto_control.utils.remote_desktop.registry import registry # noqa: E402
24+
25+
26+
@pytest.fixture(scope="module")
27+
def qapp():
28+
app = QApplication.instance() or QApplication([])
29+
yield app
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def reset_registry():
34+
registry.disconnect_viewer()
35+
registry.stop_host()
36+
yield
37+
registry.disconnect_viewer()
38+
registry.stop_host()
39+
40+
41+
def _make_jpeg(width: int = 64, height: int = 48) -> bytes:
42+
"""Encode a small solid-color image to JPEG."""
43+
from PIL import Image
44+
img = Image.new("RGB", (width, height), color=(255, 0, 0))
45+
buf = BytesIO()
46+
img.save(buf, format="JPEG", quality=70)
47+
return buf.getvalue()
48+
49+
50+
def _process_until(app: QApplication, predicate, timeout: float = 3.0,
51+
interval_ms: int = 20) -> bool:
52+
deadline = time.monotonic() + timeout
53+
while time.monotonic() < deadline:
54+
app.processEvents()
55+
if predicate():
56+
return True
57+
time.sleep(interval_ms / 1000.0)
58+
app.processEvents()
59+
return predicate()
60+
61+
62+
def test_viewer_panel_renders_frame_from_host(qapp):
63+
from je_auto_control.gui.remote_desktop_tab import _ViewerPanel
64+
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
65+
66+
jpeg = _make_jpeg()
67+
captured_input = []
68+
69+
host = RemoteDesktopHost(
70+
token="t", bind="127.0.0.1", port=0, fps=30.0,
71+
frame_provider=lambda: jpeg,
72+
input_dispatcher=captured_input.append,
73+
)
74+
host.start()
75+
registry._host = host # noqa: SLF001 # test-only injection
76+
try:
77+
panel = _ViewerPanel()
78+
panel._host_field.setText("127.0.0.1") # noqa: SLF001
79+
panel._port.setValue(host.port) # noqa: SLF001
80+
panel._token.setText("t") # noqa: SLF001
81+
panel._connect() # noqa: SLF001
82+
assert _process_until(qapp, panel._display.has_image) # noqa: SLF001
83+
# Display image must match the encoded frame size.
84+
assert panel._display._image.width() == 64 # noqa: SLF001
85+
assert panel._display._image.height() == 48 # noqa: SLF001
86+
finally:
87+
registry.disconnect_viewer()
88+
host.stop(timeout=1.0)
89+
registry._host = None # noqa: SLF001
90+
91+
92+
def test_host_preview_shows_streamed_frame(qapp):
93+
from je_auto_control.gui.remote_desktop_tab import _HostPanel
94+
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
95+
96+
jpeg = _make_jpeg(80, 60)
97+
host = RemoteDesktopHost(
98+
token="t", bind="127.0.0.1", port=0, fps=30.0,
99+
frame_provider=lambda: jpeg,
100+
)
101+
host.start()
102+
registry._host = host # noqa: SLF001
103+
try:
104+
panel = _HostPanel()
105+
# Speed the preview poll up so the test does not need to wait 250ms+.
106+
panel._preview_timer.setInterval(20) # noqa: SLF001
107+
assert _process_until(qapp, panel._preview.has_image) # noqa: SLF001
108+
assert panel._preview._image.width() == 80 # noqa: SLF001
109+
assert panel._preview._image.height() == 60 # noqa: SLF001
110+
finally:
111+
host.stop(timeout=1.0)
112+
registry._host = None # noqa: SLF001
113+
114+
115+
def test_viewer_input_round_trips_to_dispatcher(qapp):
116+
from je_auto_control.gui.remote_desktop_tab import _ViewerPanel
117+
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
118+
119+
jpeg = _make_jpeg()
120+
captured = []
121+
host = RemoteDesktopHost(
122+
token="t", bind="127.0.0.1", port=0, fps=30.0,
123+
frame_provider=lambda: jpeg,
124+
input_dispatcher=captured.append,
125+
)
126+
host.start()
127+
registry._host = host # noqa: SLF001
128+
try:
129+
panel = _ViewerPanel()
130+
panel._host_field.setText("127.0.0.1") # noqa: SLF001
131+
panel._port.setValue(host.port) # noqa: SLF001
132+
panel._token.setText("t") # noqa: SLF001
133+
panel._connect() # noqa: SLF001
134+
assert _process_until(qapp, panel._display.has_image) # noqa: SLF001
135+
136+
panel._send_mouse_move(11, 13) # noqa: SLF001
137+
panel._send_mouse_press(11, 13, "mouse_left") # noqa: SLF001
138+
139+
assert _process_until(
140+
qapp,
141+
lambda: any(c.get("action") == "mouse_press" for c in captured),
142+
)
143+
moves = [c for c in captured if c.get("action") == "mouse_move"]
144+
assert any(c == {"action": "mouse_move", "x": 11, "y": 13}
145+
for c in moves)
146+
finally:
147+
registry.disconnect_viewer()
148+
host.stop(timeout=1.0)
149+
registry._host = None # noqa: SLF001

0 commit comments

Comments
 (0)