android-emu-agent 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_emu_agent/__init__.py +3 -0
- android_emu_agent/actions/__init__.py +1 -0
- android_emu_agent/actions/executor.py +288 -0
- android_emu_agent/actions/selector.py +122 -0
- android_emu_agent/actions/wait.py +193 -0
- android_emu_agent/artifacts/__init__.py +1 -0
- android_emu_agent/artifacts/manager.py +125 -0
- android_emu_agent/artifacts/py.typed +0 -0
- android_emu_agent/cli/__init__.py +1 -0
- android_emu_agent/cli/commands/__init__.py +1 -0
- android_emu_agent/cli/commands/action.py +158 -0
- android_emu_agent/cli/commands/app_cmd.py +95 -0
- android_emu_agent/cli/commands/artifact.py +81 -0
- android_emu_agent/cli/commands/daemon.py +62 -0
- android_emu_agent/cli/commands/device.py +122 -0
- android_emu_agent/cli/commands/emulator.py +46 -0
- android_emu_agent/cli/commands/file.py +139 -0
- android_emu_agent/cli/commands/reliability.py +310 -0
- android_emu_agent/cli/commands/session.py +65 -0
- android_emu_agent/cli/commands/ui.py +112 -0
- android_emu_agent/cli/commands/wait.py +132 -0
- android_emu_agent/cli/daemon_client.py +185 -0
- android_emu_agent/cli/main.py +52 -0
- android_emu_agent/cli/utils.py +171 -0
- android_emu_agent/daemon/__init__.py +1 -0
- android_emu_agent/daemon/core.py +62 -0
- android_emu_agent/daemon/health.py +177 -0
- android_emu_agent/daemon/models.py +244 -0
- android_emu_agent/daemon/server.py +1644 -0
- android_emu_agent/db/__init__.py +1 -0
- android_emu_agent/db/models.py +229 -0
- android_emu_agent/device/__init__.py +1 -0
- android_emu_agent/device/manager.py +522 -0
- android_emu_agent/device/session.py +129 -0
- android_emu_agent/errors.py +232 -0
- android_emu_agent/files/__init__.py +1 -0
- android_emu_agent/files/manager.py +311 -0
- android_emu_agent/py.typed +0 -0
- android_emu_agent/reliability/__init__.py +1 -0
- android_emu_agent/reliability/manager.py +244 -0
- android_emu_agent/ui/__init__.py +1 -0
- android_emu_agent/ui/context.py +169 -0
- android_emu_agent/ui/ref_resolver.py +149 -0
- android_emu_agent/ui/snapshotter.py +236 -0
- android_emu_agent/validation.py +59 -0
- android_emu_agent-0.1.3.dist-info/METADATA +375 -0
- android_emu_agent-0.1.3.dist-info/RECORD +50 -0
- android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
- android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
- android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Wait/synchronization CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.daemon_client import DaemonClient
|
|
8
|
+
from android_emu_agent.cli.utils import handle_response
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Wait/synchronization commands")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_selector(
|
|
14
|
+
text: str | None, resource_id: str | None, desc: str | None
|
|
15
|
+
) -> dict[str, str] | None:
|
|
16
|
+
selector: dict[str, str] = {}
|
|
17
|
+
if text:
|
|
18
|
+
selector["text"] = text
|
|
19
|
+
if resource_id:
|
|
20
|
+
selector["resourceId"] = resource_id
|
|
21
|
+
if desc:
|
|
22
|
+
selector["description"] = desc
|
|
23
|
+
return selector or None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("idle")
|
|
27
|
+
def wait_idle(
|
|
28
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
29
|
+
timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
|
|
30
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Wait for UI idle."""
|
|
33
|
+
client = DaemonClient()
|
|
34
|
+
resp = client.request(
|
|
35
|
+
"POST",
|
|
36
|
+
"/wait/idle",
|
|
37
|
+
json_body={"session_id": session_id, "timeout_ms": timeout_ms},
|
|
38
|
+
)
|
|
39
|
+
client.close()
|
|
40
|
+
handle_response(resp, json_output=json_output)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("activity")
|
|
44
|
+
def wait_activity(
|
|
45
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
46
|
+
activity: str = typer.Argument(..., help="Activity substring"),
|
|
47
|
+
timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
|
|
48
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Wait for an activity to appear."""
|
|
51
|
+
client = DaemonClient()
|
|
52
|
+
resp = client.request(
|
|
53
|
+
"POST",
|
|
54
|
+
"/wait/activity",
|
|
55
|
+
json_body={"session_id": session_id, "activity": activity, "timeout_ms": timeout_ms},
|
|
56
|
+
)
|
|
57
|
+
client.close()
|
|
58
|
+
handle_response(resp, json_output=json_output)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("text")
|
|
62
|
+
def wait_text(
|
|
63
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
64
|
+
text: str = typer.Argument(..., help="Text to wait for"),
|
|
65
|
+
timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
|
|
66
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Wait for text to appear."""
|
|
69
|
+
client = DaemonClient()
|
|
70
|
+
resp = client.request(
|
|
71
|
+
"POST",
|
|
72
|
+
"/wait/text",
|
|
73
|
+
json_body={"session_id": session_id, "text": text, "timeout_ms": timeout_ms},
|
|
74
|
+
)
|
|
75
|
+
client.close()
|
|
76
|
+
handle_response(resp, json_output=json_output)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("exists")
|
|
80
|
+
def wait_exists(
|
|
81
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
82
|
+
ref: str | None = typer.Option(None, "--ref", help="Element ref (@a1)"),
|
|
83
|
+
text: str | None = typer.Option(None, "--text", help="Text selector"),
|
|
84
|
+
resource_id: str | None = typer.Option(None, "--id", help="Resource ID selector"),
|
|
85
|
+
desc: str | None = typer.Option(None, "--desc", help="Content-desc selector"),
|
|
86
|
+
timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
|
|
87
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Wait for element to exist."""
|
|
90
|
+
selector = _build_selector(text, resource_id, desc)
|
|
91
|
+
|
|
92
|
+
client = DaemonClient()
|
|
93
|
+
resp = client.request(
|
|
94
|
+
"POST",
|
|
95
|
+
"/wait/exists",
|
|
96
|
+
json_body={
|
|
97
|
+
"session_id": session_id,
|
|
98
|
+
"ref": ref,
|
|
99
|
+
"selector": selector,
|
|
100
|
+
"timeout_ms": timeout_ms,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
client.close()
|
|
104
|
+
handle_response(resp, json_output=json_output)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("gone")
|
|
108
|
+
def wait_gone(
|
|
109
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
110
|
+
ref: str | None = typer.Option(None, "--ref", help="Element ref (@a1)"),
|
|
111
|
+
text: str | None = typer.Option(None, "--text", help="Text selector"),
|
|
112
|
+
resource_id: str | None = typer.Option(None, "--id", help="Resource ID selector"),
|
|
113
|
+
desc: str | None = typer.Option(None, "--desc", help="Content-desc selector"),
|
|
114
|
+
timeout_ms: int | None = typer.Option(None, "--timeout-ms", help="Timeout in ms"),
|
|
115
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Wait for element to disappear."""
|
|
118
|
+
selector = _build_selector(text, resource_id, desc)
|
|
119
|
+
|
|
120
|
+
client = DaemonClient()
|
|
121
|
+
resp = client.request(
|
|
122
|
+
"POST",
|
|
123
|
+
"/wait/gone",
|
|
124
|
+
json_body={
|
|
125
|
+
"session_id": session_id,
|
|
126
|
+
"ref": ref,
|
|
127
|
+
"selector": selector,
|
|
128
|
+
"timeout_ms": timeout_ms,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
client.close()
|
|
132
|
+
handle_response(resp, json_output=json_output)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Daemon control and HTTP client for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
STATE_DIR = Path.home() / ".android-emu-agent"
|
|
17
|
+
SOCKET_PATH = Path("/tmp/android-emu-agent.sock")
|
|
18
|
+
PID_FILE = STATE_DIR / "daemon.pid"
|
|
19
|
+
LOG_FILE = STATE_DIR / "daemon.log"
|
|
20
|
+
BASE_URL = "http://android-emu-agent"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DaemonController:
|
|
24
|
+
"""Start/stop/status for the daemon process."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, socket_path: Path = SOCKET_PATH) -> None:
|
|
27
|
+
self.socket_path = socket_path
|
|
28
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
def _pid_running(self, pid: int) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
os.kill(pid, 0)
|
|
33
|
+
except OSError:
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def _read_pid(self) -> int | None:
|
|
38
|
+
if not PID_FILE.exists():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return int(PID_FILE.read_text().strip())
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def _socket_healthy(self) -> bool:
|
|
46
|
+
if not self.socket_path.exists():
|
|
47
|
+
return False
|
|
48
|
+
client = httpx.Client(
|
|
49
|
+
transport=httpx.HTTPTransport(uds=str(self.socket_path)),
|
|
50
|
+
base_url=BASE_URL,
|
|
51
|
+
timeout=1.0,
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
resp = client.get("/health")
|
|
55
|
+
return resp.status_code == 200
|
|
56
|
+
except httpx.HTTPError:
|
|
57
|
+
return False
|
|
58
|
+
finally:
|
|
59
|
+
client.close()
|
|
60
|
+
|
|
61
|
+
def health(self) -> bool:
|
|
62
|
+
"""Return True if the daemon socket responds to /health."""
|
|
63
|
+
return self._socket_healthy()
|
|
64
|
+
|
|
65
|
+
def start(self) -> int:
|
|
66
|
+
"""Start the daemon; returns PID, or -1 if already running but PID unknown."""
|
|
67
|
+
pid = self._read_pid()
|
|
68
|
+
if pid and self._pid_running(pid):
|
|
69
|
+
return pid
|
|
70
|
+
if pid:
|
|
71
|
+
PID_FILE.unlink(missing_ok=True)
|
|
72
|
+
if self._socket_healthy():
|
|
73
|
+
return -1
|
|
74
|
+
|
|
75
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
args = [
|
|
77
|
+
sys.executable,
|
|
78
|
+
"-m",
|
|
79
|
+
"uvicorn",
|
|
80
|
+
"android_emu_agent.daemon.server:app",
|
|
81
|
+
"--uds",
|
|
82
|
+
str(self.socket_path),
|
|
83
|
+
"--log-level",
|
|
84
|
+
"info",
|
|
85
|
+
]
|
|
86
|
+
with LOG_FILE.open("a", encoding="utf-8") as log_handle:
|
|
87
|
+
proc = subprocess.Popen(
|
|
88
|
+
args,
|
|
89
|
+
stdout=log_handle,
|
|
90
|
+
stderr=log_handle,
|
|
91
|
+
start_new_session=True,
|
|
92
|
+
)
|
|
93
|
+
PID_FILE.write_text(str(proc.pid))
|
|
94
|
+
return proc.pid
|
|
95
|
+
|
|
96
|
+
def stop(self) -> bool:
|
|
97
|
+
"""Stop the daemon if running."""
|
|
98
|
+
pid = self._read_pid()
|
|
99
|
+
if not pid:
|
|
100
|
+
return False
|
|
101
|
+
if not self._pid_running(pid):
|
|
102
|
+
PID_FILE.unlink(missing_ok=True)
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
os.kill(pid, signal.SIGTERM)
|
|
106
|
+
for _ in range(20):
|
|
107
|
+
if not self._pid_running(pid):
|
|
108
|
+
PID_FILE.unlink(missing_ok=True)
|
|
109
|
+
return True
|
|
110
|
+
time.sleep(0.1)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def status(self) -> dict[str, Any]:
|
|
114
|
+
"""Return daemon status summary."""
|
|
115
|
+
pid = self._read_pid()
|
|
116
|
+
return {
|
|
117
|
+
"pid": pid,
|
|
118
|
+
"pid_running": self._pid_running(pid) if pid else False,
|
|
119
|
+
"socket": str(self.socket_path),
|
|
120
|
+
"socket_exists": self.socket_path.exists(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DaemonClient:
|
|
125
|
+
"""HTTP client using Unix Domain Socket transport."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
socket_path: Path = SOCKET_PATH,
|
|
130
|
+
*,
|
|
131
|
+
auto_start: bool = True,
|
|
132
|
+
timeout: float = 10.0,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.socket_path = socket_path
|
|
135
|
+
self.auto_start = auto_start
|
|
136
|
+
self.controller = DaemonController(socket_path)
|
|
137
|
+
self._client = httpx.Client(
|
|
138
|
+
transport=httpx.HTTPTransport(uds=str(self.socket_path)),
|
|
139
|
+
base_url=BASE_URL,
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
self._client.close()
|
|
145
|
+
|
|
146
|
+
def request(
|
|
147
|
+
self, method: str, path: str, json_body: dict[str, Any] | None = None
|
|
148
|
+
) -> httpx.Response:
|
|
149
|
+
if self.auto_start:
|
|
150
|
+
self._ensure_ready()
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return self._client.request(method, path, json=json_body)
|
|
154
|
+
except httpx.TransportError:
|
|
155
|
+
if not self.auto_start:
|
|
156
|
+
raise
|
|
157
|
+
self.controller.start()
|
|
158
|
+
self._wait_for_health()
|
|
159
|
+
return self._client.request(method, path, json=json_body)
|
|
160
|
+
|
|
161
|
+
def _ensure_ready(self) -> None:
|
|
162
|
+
try:
|
|
163
|
+
resp = self._client.get("/health")
|
|
164
|
+
if resp.status_code == 200:
|
|
165
|
+
return
|
|
166
|
+
except httpx.TransportError:
|
|
167
|
+
pass
|
|
168
|
+
self.controller.start()
|
|
169
|
+
self._wait_for_health()
|
|
170
|
+
|
|
171
|
+
def _wait_for_health(self) -> None:
|
|
172
|
+
deadline = time.time() + 5
|
|
173
|
+
while time.time() < deadline:
|
|
174
|
+
try:
|
|
175
|
+
resp = self._client.get("/health")
|
|
176
|
+
if resp.status_code == 200:
|
|
177
|
+
return
|
|
178
|
+
except httpx.TransportError:
|
|
179
|
+
pass
|
|
180
|
+
time.sleep(0.1)
|
|
181
|
+
raise RuntimeError("Daemon did not become healthy in time")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_json(data: Any) -> str:
|
|
185
|
+
return json.dumps(data, indent=2, ensure_ascii=True)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""CLI entry point using Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.commands import (
|
|
8
|
+
action,
|
|
9
|
+
app_cmd,
|
|
10
|
+
artifact,
|
|
11
|
+
daemon,
|
|
12
|
+
device,
|
|
13
|
+
emulator,
|
|
14
|
+
reliability,
|
|
15
|
+
session,
|
|
16
|
+
ui,
|
|
17
|
+
wait,
|
|
18
|
+
)
|
|
19
|
+
from android_emu_agent.cli.commands import (
|
|
20
|
+
file as file_commands,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="android-emu-agent",
|
|
25
|
+
help="LLM-driven Android UI control system",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def version() -> None:
|
|
32
|
+
"""Show version information."""
|
|
33
|
+
from android_emu_agent import __version__
|
|
34
|
+
|
|
35
|
+
typer.echo(f"android-emu-agent v{__version__}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app.add_typer(daemon.app, name="daemon")
|
|
39
|
+
app.add_typer(device.app, name="device")
|
|
40
|
+
app.add_typer(session.app, name="session")
|
|
41
|
+
app.add_typer(ui.app, name="ui")
|
|
42
|
+
app.add_typer(action.app, name="action")
|
|
43
|
+
app.add_typer(wait.app, name="wait")
|
|
44
|
+
app.add_typer(app_cmd.app, name="app")
|
|
45
|
+
app.add_typer(artifact.app, name="artifact")
|
|
46
|
+
app.add_typer(emulator.app, name="emulator")
|
|
47
|
+
app.add_typer(reliability.app, name="reliability")
|
|
48
|
+
app.add_typer(file_commands.app, name="file")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
app()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Shared CLI helpers and constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from android_emu_agent.cli.daemon_client import format_json
|
|
12
|
+
|
|
13
|
+
RELIABILITY_TIMEOUT = 60.0
|
|
14
|
+
RELIABILITY_BUGREPORT_TIMEOUT = 300.0
|
|
15
|
+
RELIABILITY_PULL_TIMEOUT = 180.0
|
|
16
|
+
RELIABILITY_DUMPHEAP_TIMEOUT = 180.0
|
|
17
|
+
FILE_TRANSFER_TIMEOUT = 120.0
|
|
18
|
+
FILE_APP_TRANSFER_TIMEOUT = 180.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_response_json(resp: Any) -> dict[str, Any]:
|
|
22
|
+
try:
|
|
23
|
+
return cast(dict[str, Any], resp.json())
|
|
24
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
25
|
+
typer.echo("Failed to parse response")
|
|
26
|
+
raise typer.Exit(code=1) from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _maybe_render_error(data: dict[str, Any]) -> None:
|
|
30
|
+
if not (isinstance(data, dict) and data.get("error")):
|
|
31
|
+
return
|
|
32
|
+
error = data["error"]
|
|
33
|
+
message = f"{error.get('code')}: {error.get('message')}"
|
|
34
|
+
remediation = error.get("remediation")
|
|
35
|
+
typer.echo(message)
|
|
36
|
+
if remediation:
|
|
37
|
+
typer.echo(f"Hint: {remediation}")
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _maybe_render_done(data: dict[str, Any]) -> bool:
|
|
42
|
+
if not (isinstance(data, dict) and data.get("status") == "done"):
|
|
43
|
+
return False
|
|
44
|
+
message = "✓ Done"
|
|
45
|
+
if "elapsed_ms" in data:
|
|
46
|
+
message += f" ({data['elapsed_ms']} ms)"
|
|
47
|
+
if "path" in data:
|
|
48
|
+
message += f" -> {data['path']}"
|
|
49
|
+
typer.echo(message)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _maybe_render_output(data: dict[str, Any]) -> bool:
|
|
54
|
+
if not (isinstance(data, dict) and "output" in data):
|
|
55
|
+
return False
|
|
56
|
+
output = data.get("output")
|
|
57
|
+
if output:
|
|
58
|
+
typer.echo(output)
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def handle_response(resp: Any, json_output: bool = False) -> None:
|
|
64
|
+
data = _parse_response_json(resp)
|
|
65
|
+
if json_output:
|
|
66
|
+
typer.echo(format_json(data))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
_maybe_render_error(data)
|
|
70
|
+
if _maybe_render_done(data):
|
|
71
|
+
return
|
|
72
|
+
typer.echo(format_json(data))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def handle_output_response(resp: Any, json_output: bool = False) -> None:
|
|
76
|
+
data = _parse_response_json(resp)
|
|
77
|
+
if json_output:
|
|
78
|
+
typer.echo(format_json(data))
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
_maybe_render_error(data)
|
|
82
|
+
if _maybe_render_output(data):
|
|
83
|
+
return
|
|
84
|
+
if _maybe_render_done(data):
|
|
85
|
+
return
|
|
86
|
+
typer.echo(format_json(data))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def handle_response_with_pull(
|
|
90
|
+
resp: Any,
|
|
91
|
+
*,
|
|
92
|
+
json_output: bool = False,
|
|
93
|
+
pull: bool = False,
|
|
94
|
+
output: str | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
data = _parse_response_json(resp)
|
|
97
|
+
pulled_path: Path | None = None
|
|
98
|
+
|
|
99
|
+
if pull and not data.get("error"):
|
|
100
|
+
try:
|
|
101
|
+
path = data.get("path")
|
|
102
|
+
if not path:
|
|
103
|
+
raise typer.BadParameter("Response missing path; cannot pull artifact")
|
|
104
|
+
pulled_path = pull_artifact_path(path, output)
|
|
105
|
+
data["pulled_path"] = str(pulled_path)
|
|
106
|
+
except typer.BadParameter as exc:
|
|
107
|
+
typer.echo(f"Error: {exc}")
|
|
108
|
+
raise typer.Exit(code=1) from None
|
|
109
|
+
|
|
110
|
+
if json_output:
|
|
111
|
+
typer.echo(format_json(data))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
_maybe_render_error(data)
|
|
115
|
+
if _maybe_render_done(data):
|
|
116
|
+
if pulled_path:
|
|
117
|
+
typer.echo(f"✓ Pulled -> {pulled_path}")
|
|
118
|
+
return
|
|
119
|
+
typer.echo(format_json(data))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def target_payload(device: str | None, session_id: str | None) -> dict[str, Any]:
|
|
123
|
+
if device and session_id:
|
|
124
|
+
raise typer.BadParameter("--device and --session are mutually exclusive")
|
|
125
|
+
if not device and not session_id:
|
|
126
|
+
raise typer.BadParameter("Provide --device or --session")
|
|
127
|
+
if device:
|
|
128
|
+
return {"serial": device}
|
|
129
|
+
assert session_id is not None
|
|
130
|
+
return {"session_id": session_id}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_session_id(session_arg: str | None, session_opt: str | None) -> str | None:
|
|
134
|
+
if session_arg and session_opt:
|
|
135
|
+
raise typer.BadParameter("Provide session ID once (argument or --session)")
|
|
136
|
+
return session_arg or session_opt
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def pull_artifact_path(path_str: str, output: str | None = None) -> Path:
|
|
140
|
+
src = Path(path_str).expanduser()
|
|
141
|
+
if not src.exists():
|
|
142
|
+
raise typer.BadParameter(f"Artifact not found: {src}")
|
|
143
|
+
|
|
144
|
+
if output is None:
|
|
145
|
+
dest = Path.cwd() / src.name
|
|
146
|
+
else:
|
|
147
|
+
output_path = Path(output).expanduser()
|
|
148
|
+
output_str = str(output)
|
|
149
|
+
if output_path.exists() and output_path.is_dir():
|
|
150
|
+
dest = output_path / src.name
|
|
151
|
+
elif output_str.endswith(("/", "\\")):
|
|
152
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
dest = output_path / src.name
|
|
154
|
+
else:
|
|
155
|
+
dest = output_path
|
|
156
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
if src.resolve() == dest.resolve():
|
|
159
|
+
return dest
|
|
160
|
+
|
|
161
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
shutil.copy2(src, dest)
|
|
163
|
+
return dest
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def require_target(device: str | None, session_id: str | None) -> dict[str, Any]:
|
|
167
|
+
try:
|
|
168
|
+
return target_payload(device, session_id)
|
|
169
|
+
except typer.BadParameter as exc:
|
|
170
|
+
typer.echo(f"Error: {exc}")
|
|
171
|
+
raise typer.Exit(code=1) from None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Daemon - Core server managing device connections and sessions."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Daemon core - lifecycle, request routing, state management."""
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
|
|
5
|
+
from android_emu_agent.actions.executor import ActionExecutor
|
|
6
|
+
from android_emu_agent.actions.wait import WaitEngine
|
|
7
|
+
from android_emu_agent.artifacts.manager import ArtifactManager
|
|
8
|
+
from android_emu_agent.daemon.health import HealthMonitor
|
|
9
|
+
from android_emu_agent.db.models import Database
|
|
10
|
+
from android_emu_agent.device.manager import DeviceManager
|
|
11
|
+
from android_emu_agent.device.session import SessionManager
|
|
12
|
+
from android_emu_agent.files.manager import FileManager
|
|
13
|
+
from android_emu_agent.reliability.manager import ReliabilityManager
|
|
14
|
+
from android_emu_agent.ui.context import ContextResolver
|
|
15
|
+
from android_emu_agent.ui.ref_resolver import RefResolver
|
|
16
|
+
from android_emu_agent.ui.snapshotter import UISnapshotter
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DaemonCore:
|
|
22
|
+
"""Central daemon coordinator managing all subsystems."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.database = Database()
|
|
26
|
+
self.device_manager = DeviceManager()
|
|
27
|
+
self.session_manager = SessionManager(self.database)
|
|
28
|
+
self.snapshotter = UISnapshotter()
|
|
29
|
+
self.ref_resolver = RefResolver()
|
|
30
|
+
self.action_executor = ActionExecutor()
|
|
31
|
+
self.wait_engine = WaitEngine()
|
|
32
|
+
self.artifact_manager = ArtifactManager()
|
|
33
|
+
self.file_manager = FileManager()
|
|
34
|
+
self.reliability_manager = ReliabilityManager()
|
|
35
|
+
self.context_resolver = ContextResolver()
|
|
36
|
+
self.health_monitor = HealthMonitor(self.device_manager, self.session_manager)
|
|
37
|
+
self._running = False
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
"""Initialize all subsystems."""
|
|
41
|
+
logger.info("daemon_core_starting")
|
|
42
|
+
await self.database.connect()
|
|
43
|
+
await self.device_manager.start()
|
|
44
|
+
await self.session_manager.start()
|
|
45
|
+
await self.health_monitor.start()
|
|
46
|
+
self._running = True
|
|
47
|
+
logger.info("daemon_core_started")
|
|
48
|
+
|
|
49
|
+
async def stop(self) -> None:
|
|
50
|
+
"""Gracefully shutdown all subsystems."""
|
|
51
|
+
logger.info("daemon_core_stopping")
|
|
52
|
+
self._running = False
|
|
53
|
+
await self.health_monitor.stop()
|
|
54
|
+
await self.session_manager.stop()
|
|
55
|
+
await self.device_manager.stop()
|
|
56
|
+
await self.database.disconnect()
|
|
57
|
+
logger.info("daemon_core_stopped")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_running(self) -> bool:
|
|
61
|
+
"""Check if daemon is running."""
|
|
62
|
+
return self._running
|