Skip to content

Commit b7c8320

Browse files
committed
Wire AC_remote_* commands and facade re-exports for remote_desktop
A small registry singleton holds at most one host and one viewer so JSON action scripts and the GUI can talk to the running pair without juggling handles. The new AC_start_remote_host / AC_stop_remote_host / AC_remote_host_status, AC_remote_connect / AC_remote_disconnect / AC_remote_viewer_status / AC_remote_send_input commands are thin adapters over the registry, so the executor stays unaware of the host and viewer classes' lifecycle details. Tests cover the AC_* command surface and an end-to-end round-trip (executor-driven host start, viewer connect, send_input, disconnect, stop) with stub frame provider and dispatcher so no real screen capture or OS input is needed.
1 parent b0cb03c commit b7c8320

5 files changed

Lines changed: 284 additions & 1 deletion

File tree

je_auto_control/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@
6868
LLMBackend, LLMNotAvailableError, LLMPlanError,
6969
plan_actions, run_from_description,
7070
)
71+
# Remote desktop (headless)
72+
from je_auto_control.utils.remote_desktop import (
73+
AuthenticationError as RemoteDesktopAuthError,
74+
InputDispatchError as RemoteDesktopInputError,
75+
ProtocolError as RemoteDesktopProtocolError,
76+
RemoteDesktopHost, RemoteDesktopViewer,
77+
)
78+
from je_auto_control.utils.remote_desktop.registry import (
79+
registry as remote_desktop_registry,
80+
)
7181
# MCP server (headless stdio bridge for Claude / other MCP clients)
7282
from je_auto_control.utils.mcp_server import (
7383
AuditLogger, HttpMCPServer, MCPContent, MCPPrompt, MCPPromptArgument,
@@ -258,6 +268,10 @@ def start_autocontrol_gui(*args, **kwargs):
258268
# LLM action planner
259269
"LLMBackend", "LLMNotAvailableError", "LLMPlanError",
260270
"plan_actions", "run_from_description",
271+
# Remote desktop
272+
"RemoteDesktopHost", "RemoteDesktopViewer",
273+
"RemoteDesktopAuthError", "RemoteDesktopInputError",
274+
"RemoteDesktopProtocolError", "remote_desktop_registry",
261275
"generate_html", "generate_html_report", "generate_json", "generate_json_report", "generate_xml",
262276
"generate_xml_report", "get_dir_files_as_list", "create_project_dir", "start_autocontrol_socket_server",
263277
"callback_executor", "package_manager", "ShellManager", "default_shell_manager",

je_auto_control/utils/executor/action_executor.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
plan_actions as llm_plan_actions,
2828
run_from_description as llm_run_from_description,
2929
)
30+
from je_auto_control.utils.remote_desktop.registry import (
31+
registry as remote_desktop_registry,
32+
)
3033
from je_auto_control.utils.ocr.ocr_engine import (
3134
click_text as ocr_click_text,
3235
find_text_regex as ocr_find_text_regex,
@@ -101,6 +104,49 @@ def _vlm_locate_as_list(description: str,
101104
return None if coords is None else [coords[0], coords[1]]
102105

103106

107+
def _remote_start_host(token: str,
108+
bind: str = "127.0.0.1",
109+
port: int = 0,
110+
fps: float = 10.0,
111+
quality: int = 70,
112+
region: Optional[List[int]] = None,
113+
max_clients: int = 4) -> Dict[str, Any]:
114+
"""Executor adapter: start the singleton remote-desktop host."""
115+
return remote_desktop_registry.start_host(
116+
token=token, bind=bind, port=int(port),
117+
fps=float(fps), quality=int(quality),
118+
region=region, max_clients=int(max_clients),
119+
)
120+
121+
122+
def _remote_stop_host() -> Dict[str, Any]:
123+
return remote_desktop_registry.stop_host()
124+
125+
126+
def _remote_host_status() -> Dict[str, Any]:
127+
return remote_desktop_registry.host_status()
128+
129+
130+
def _remote_connect(host: str, port: int, token: str,
131+
timeout: float = 5.0) -> Dict[str, Any]:
132+
"""Executor adapter: connect the singleton viewer."""
133+
return remote_desktop_registry.connect_viewer(
134+
host=host, port=int(port), token=token, timeout=float(timeout),
135+
)
136+
137+
138+
def _remote_disconnect() -> Dict[str, Any]:
139+
return remote_desktop_registry.disconnect_viewer()
140+
141+
142+
def _remote_viewer_status() -> Dict[str, Any]:
143+
return remote_desktop_registry.viewer_status()
144+
145+
146+
def _remote_send_input(action: Dict[str, Any]) -> Dict[str, Any]:
147+
return remote_desktop_registry.send_input(action)
148+
149+
104150
def _llm_plan_for_executor(description: str,
105151
examples: Optional[list] = None,
106152
model: Optional[str] = None,
@@ -296,6 +342,17 @@ def __init__(self):
296342
# LLM action planner
297343
"AC_llm_plan": _llm_plan_for_executor,
298344
"AC_llm_run": _llm_run_for_executor,
345+
346+
# Remote desktop host (this machine streams to others)
347+
"AC_start_remote_host": _remote_start_host,
348+
"AC_stop_remote_host": _remote_stop_host,
349+
"AC_remote_host_status": _remote_host_status,
350+
351+
# Remote desktop viewer (this machine controls others)
352+
"AC_remote_connect": _remote_connect,
353+
"AC_remote_disconnect": _remote_disconnect,
354+
"AC_remote_viewer_status": _remote_viewer_status,
355+
"AC_remote_send_input": _remote_send_input,
299356
}
300357

301358
def known_commands(self) -> set:

je_auto_control/utils/remote_desktop/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
AuthenticationError, MessageType, ProtocolError,
1818
decode_frame_header, encode_frame,
1919
)
20+
from je_auto_control.utils.remote_desktop.registry import registry
2021
from je_auto_control.utils.remote_desktop.viewer import RemoteDesktopViewer
2122

2223
__all__ = [
2324
"RemoteDesktopHost", "RemoteDesktopViewer",
2425
"InputDispatchError", "AuthenticationError", "ProtocolError",
2526
"MessageType", "encode_frame", "decode_frame_header",
26-
"dispatch_input",
27+
"dispatch_input", "registry",
2728
]
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Process-global singletons used by AC_remote_* executor commands.
2+
3+
JSON action scripts and the GUI both want to talk to one running host
4+
and at most one active viewer without juggling handles. Holding those
5+
references here keeps :mod:`action_executor` thin and avoids circular
6+
imports between the executor and the host/viewer classes.
7+
"""
8+
from typing import Any, Dict, Optional, Sequence
9+
10+
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
11+
from je_auto_control.utils.remote_desktop.viewer import RemoteDesktopViewer
12+
13+
14+
class _RemoteDesktopRegistry:
15+
"""Hold one host + one viewer for the executor command surface."""
16+
17+
def __init__(self) -> None:
18+
self._host: Optional[RemoteDesktopHost] = None
19+
self._viewer: Optional[RemoteDesktopViewer] = None
20+
21+
@property
22+
def host(self) -> Optional[RemoteDesktopHost]:
23+
return self._host
24+
25+
@property
26+
def viewer(self) -> Optional[RemoteDesktopViewer]:
27+
return self._viewer
28+
29+
def start_host(self, token: str,
30+
bind: str = "127.0.0.1",
31+
port: int = 0,
32+
fps: float = 10.0,
33+
quality: int = 70,
34+
region: Optional[Sequence[int]] = None,
35+
max_clients: int = 4) -> Dict[str, Any]:
36+
"""Stop any existing host, then start a fresh one with the given config."""
37+
self.stop_host()
38+
host = RemoteDesktopHost(
39+
token=token, bind=bind, port=int(port),
40+
fps=float(fps), quality=int(quality),
41+
region=region, max_clients=int(max_clients),
42+
)
43+
host.start()
44+
self._host = host
45+
return self.host_status()
46+
47+
def stop_host(self, timeout: float = 2.0) -> Dict[str, Any]:
48+
"""Stop the active host (if any) and clear the slot."""
49+
if self._host is not None:
50+
self._host.stop(timeout=timeout)
51+
self._host = None
52+
return self.host_status()
53+
54+
def host_status(self) -> Dict[str, Any]:
55+
host = self._host
56+
if host is None:
57+
return {"running": False, "port": 0, "connected_clients": 0}
58+
return {
59+
"running": host.is_running,
60+
"port": host.port,
61+
"connected_clients": host.connected_clients,
62+
}
63+
64+
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."""
67+
self.disconnect_viewer()
68+
viewer = RemoteDesktopViewer(host=host, port=int(port), token=token)
69+
viewer.connect(timeout=float(timeout))
70+
self._viewer = viewer
71+
return self.viewer_status()
72+
73+
def disconnect_viewer(self, timeout: float = 2.0) -> Dict[str, Any]:
74+
"""Disconnect the active viewer (if any) and clear the slot."""
75+
if self._viewer is not None:
76+
self._viewer.disconnect(timeout=timeout)
77+
self._viewer = None
78+
return self.viewer_status()
79+
80+
def viewer_status(self) -> Dict[str, Any]:
81+
viewer = self._viewer
82+
if viewer is None:
83+
return {"connected": False}
84+
return {"connected": viewer.connected}
85+
86+
def send_input(self, action: Dict[str, Any]) -> Dict[str, Any]:
87+
"""Forward ``action`` through the connected viewer, raise if offline."""
88+
if self._viewer is None or not self._viewer.connected:
89+
raise ConnectionError("no remote viewer is connected")
90+
self._viewer.send_input(action)
91+
return {"sent": True}
92+
93+
94+
registry = _RemoteDesktopRegistry()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for the AC_remote_* executor commands and registry singleton."""
2+
import time
3+
4+
import pytest
5+
6+
from je_auto_control.utils.executor.action_executor import executor
7+
from je_auto_control.utils.remote_desktop.registry import registry
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def reset_registry():
12+
"""Tear down any leftover host/viewer before and after each test."""
13+
registry.disconnect_viewer()
14+
registry.stop_host()
15+
yield
16+
registry.disconnect_viewer()
17+
registry.stop_host()
18+
19+
20+
def _wait_until(predicate, timeout: float = 2.0,
21+
interval: float = 0.02) -> bool:
22+
deadline = time.monotonic() + timeout
23+
while time.monotonic() < deadline:
24+
if predicate():
25+
return True
26+
time.sleep(interval)
27+
return predicate()
28+
29+
30+
def test_known_commands_include_remote_desktop():
31+
assert "AC_start_remote_host" in executor.known_commands()
32+
assert "AC_stop_remote_host" in executor.known_commands()
33+
assert "AC_remote_host_status" in executor.known_commands()
34+
assert "AC_remote_connect" in executor.known_commands()
35+
assert "AC_remote_disconnect" in executor.known_commands()
36+
assert "AC_remote_viewer_status" in executor.known_commands()
37+
assert "AC_remote_send_input" in executor.known_commands()
38+
39+
40+
def test_start_host_then_status_via_executor():
41+
captured = []
42+
43+
def stub_provider() -> bytes:
44+
return b"test-frame"
45+
46+
# Reach into the registry to install a stub provider so this test
47+
# never touches PIL.ImageGrab; mirrors what GUI would do for a fake.
48+
from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost
49+
50+
host = RemoteDesktopHost(
51+
token="t", bind="127.0.0.1", port=0, fps=50.0, quality=70,
52+
frame_provider=stub_provider, input_dispatcher=captured.append,
53+
)
54+
host.start()
55+
registry._host = host # noqa: SLF001 # test-only injection
56+
57+
record = executor.execute_action([["AC_remote_host_status"]])
58+
status_value = next(iter(record.values()))
59+
assert status_value["running"] is True
60+
assert status_value["port"] > 0
61+
62+
63+
def test_start_host_with_blank_token_records_error():
64+
record = executor.execute_action([
65+
["AC_start_remote_host", {"token": ""}],
66+
])
67+
assert any("ValueError" in repr(v) for v in record.values())
68+
69+
70+
def test_send_input_without_viewer_records_connection_error():
71+
record = executor.execute_action([
72+
["AC_remote_send_input", {"action": {"action": "ping"}}],
73+
])
74+
assert any("ConnectionError" in repr(v) for v in record.values())
75+
76+
77+
def test_remote_round_trip_through_executor():
78+
"""Start host + connect viewer + send input via executor commands."""
79+
record = executor.execute_action([
80+
["AC_start_remote_host", {
81+
"token": "tok", "bind": "127.0.0.1", "port": 0,
82+
"fps": 50, "quality": 70,
83+
}],
84+
])
85+
start_status = next(iter(record.values()))
86+
assert start_status["running"] is True
87+
port = start_status["port"]
88+
assert port > 0
89+
90+
# Replace the default frame provider (PIL.ImageGrab) with a stub so
91+
# the test does not depend on a real screen being available.
92+
registry._host._frame_provider = lambda: b"executor-frame" # noqa: SLF001
93+
captured = []
94+
registry._host._dispatch = captured.append # noqa: SLF001
95+
96+
executor.execute_action([
97+
["AC_remote_connect", {
98+
"host": "127.0.0.1", "port": port, "token": "tok",
99+
}],
100+
])
101+
viewer_status = registry.viewer_status()
102+
assert viewer_status["connected"] is True
103+
assert _wait_until(lambda: registry.host.connected_clients == 1)
104+
105+
executor.execute_action([
106+
["AC_remote_send_input", {
107+
"action": {"action": "mouse_move", "x": 5, "y": 7},
108+
}],
109+
])
110+
assert _wait_until(lambda: captured == [
111+
{"action": "mouse_move", "x": 5, "y": 7}
112+
])
113+
114+
executor.execute_action([["AC_remote_disconnect"]])
115+
assert registry.viewer_status()["connected"] is False
116+
executor.execute_action([["AC_stop_remote_host"]])
117+
assert registry.host_status()["running"] is False

0 commit comments

Comments
 (0)