androidctl 0.1.0__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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- androidctld/waits/matcher.py +41 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""HTTP server for androidctld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from http.server import BaseHTTPRequestHandler
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from androidctld import SERVICE_NAME, __version__
|
|
13
|
+
from androidctld.auth.active_registry import ActiveDaemonRecord, ActiveDaemonRegistry
|
|
14
|
+
from androidctld.auth.token_store import DaemonTokenStore
|
|
15
|
+
from androidctld.commands.service import CommandService
|
|
16
|
+
from androidctld.config import DaemonConfig
|
|
17
|
+
from androidctld.daemon.active_slot import ActiveSlotCoordinator
|
|
18
|
+
from androidctld.daemon.envelope import error_envelope, success_envelope
|
|
19
|
+
from androidctld.daemon.http_host import DaemonHttpHost
|
|
20
|
+
from androidctld.daemon.ingress import DaemonIngress
|
|
21
|
+
from androidctld.daemon.service import DaemonService
|
|
22
|
+
from androidctld.errors import DaemonError, DaemonErrorCode, bad_request
|
|
23
|
+
from androidctld.logging import configure_logging
|
|
24
|
+
from androidctld.runtime.store import RuntimeStore
|
|
25
|
+
from androidctld.runtime_policy import (
|
|
26
|
+
DAEMON_HTTP_MAX_REQUEST_BODY_BYTES,
|
|
27
|
+
DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AndroidctldHttpServer:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
config: DaemonConfig,
|
|
35
|
+
token_store: DaemonTokenStore | None = None,
|
|
36
|
+
active_registry: ActiveDaemonRegistry | None = None,
|
|
37
|
+
runtime_store: RuntimeStore | None = None,
|
|
38
|
+
command_service: CommandService | None = None,
|
|
39
|
+
logger: logging.Logger | None = None,
|
|
40
|
+
shutdown_callback: Callable[[], None] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._token_store = token_store or DaemonTokenStore(self._config)
|
|
44
|
+
self._active_registry = active_registry or ActiveDaemonRegistry(self._config)
|
|
45
|
+
self._logger = logger or configure_logging()
|
|
46
|
+
self._runtime_store = runtime_store or RuntimeStore(self._config)
|
|
47
|
+
self._shutdown_callback = shutdown_callback or (lambda: None)
|
|
48
|
+
self._closing = False
|
|
49
|
+
self._shutdown_after_close_requested = False
|
|
50
|
+
self._stop_lock = threading.Lock()
|
|
51
|
+
self._stop_completed = False
|
|
52
|
+
self._service = DaemonService(
|
|
53
|
+
runtime_store=self._runtime_store,
|
|
54
|
+
command_service=command_service or CommandService(self._runtime_store),
|
|
55
|
+
bound_owner_id=self._config.owner_id,
|
|
56
|
+
)
|
|
57
|
+
self._ingress = DaemonIngress(
|
|
58
|
+
token_provider=self._token_store.current_token,
|
|
59
|
+
owner_id_provider=lambda: self._config.owner_id,
|
|
60
|
+
dispatcher=self,
|
|
61
|
+
)
|
|
62
|
+
self._active_slot = ActiveSlotCoordinator(
|
|
63
|
+
config=self._config,
|
|
64
|
+
active_registry=self._active_registry,
|
|
65
|
+
existing_token_reader=lambda: DaemonTokenStore.load_existing_token(
|
|
66
|
+
self._config.token_file_path
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
self._http_host = DaemonHttpHost(config=self._config, logger=self._logger)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def active_record(self) -> ActiveDaemonRecord | None:
|
|
73
|
+
return self._active_slot.active_record
|
|
74
|
+
|
|
75
|
+
def handle(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
headers: dict[str, str],
|
|
80
|
+
body: bytes,
|
|
81
|
+
) -> tuple[int, dict[str, Any]]:
|
|
82
|
+
if (
|
|
83
|
+
self._closing
|
|
84
|
+
and method == "POST"
|
|
85
|
+
and path
|
|
86
|
+
in {
|
|
87
|
+
"/runtime/get",
|
|
88
|
+
"/runtime/close",
|
|
89
|
+
"/commands/run",
|
|
90
|
+
}
|
|
91
|
+
):
|
|
92
|
+
raise DaemonError(
|
|
93
|
+
code=DaemonErrorCode.RUNTIME_BUSY,
|
|
94
|
+
message="daemon is shutting down",
|
|
95
|
+
retryable=True,
|
|
96
|
+
details={"reason": "daemon_shutting_down"},
|
|
97
|
+
http_status=200,
|
|
98
|
+
)
|
|
99
|
+
return self._service.handle(
|
|
100
|
+
method=method,
|
|
101
|
+
path=path,
|
|
102
|
+
headers=headers,
|
|
103
|
+
body=body,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def start(self) -> ActiveDaemonRecord:
|
|
107
|
+
with self._stop_lock:
|
|
108
|
+
if self._http_host.is_running:
|
|
109
|
+
raise RuntimeError("androidctld server is already running")
|
|
110
|
+
|
|
111
|
+
self._closing = False
|
|
112
|
+
self._shutdown_after_close_requested = False
|
|
113
|
+
self._stop_completed = False
|
|
114
|
+
self._active_slot.acquire()
|
|
115
|
+
try:
|
|
116
|
+
host, port = self._http_host.start(self._build_handler())
|
|
117
|
+
active_record = self._active_slot.prepare(
|
|
118
|
+
host=host,
|
|
119
|
+
port=port,
|
|
120
|
+
token=self._token_store.current_token(),
|
|
121
|
+
)
|
|
122
|
+
self._http_host.wait_until_ready(record=active_record)
|
|
123
|
+
self._active_slot.publish(active_record)
|
|
124
|
+
self._logger.info("androidctld listening on %s:%s", host, port)
|
|
125
|
+
return active_record
|
|
126
|
+
except Exception:
|
|
127
|
+
self._active_slot.clear_record()
|
|
128
|
+
self._http_host.stop()
|
|
129
|
+
self._active_slot.release_owner()
|
|
130
|
+
self._stop_completed = True
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
def stop(self) -> None:
|
|
134
|
+
with self._stop_lock:
|
|
135
|
+
if self._stop_completed:
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
if self._http_host.is_running:
|
|
139
|
+
self._http_host.stop()
|
|
140
|
+
finally:
|
|
141
|
+
self._active_slot.clear_record()
|
|
142
|
+
self._active_slot.release_owner()
|
|
143
|
+
self._stop_completed = True
|
|
144
|
+
self._logger.info("androidctld stopped")
|
|
145
|
+
|
|
146
|
+
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
|
|
147
|
+
outer = self
|
|
148
|
+
|
|
149
|
+
class RequestHandler(BaseHTTPRequestHandler):
|
|
150
|
+
server_version = f"{SERVICE_NAME}/{__version__}"
|
|
151
|
+
protocol_version = "HTTP/1.1"
|
|
152
|
+
|
|
153
|
+
def setup(self) -> None:
|
|
154
|
+
super().setup()
|
|
155
|
+
self.connection.settimeout(DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS)
|
|
156
|
+
|
|
157
|
+
def do_POST(self) -> None:
|
|
158
|
+
outer._handle(self)
|
|
159
|
+
|
|
160
|
+
def do_GET(self) -> None:
|
|
161
|
+
outer._handle(self)
|
|
162
|
+
|
|
163
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
164
|
+
outer._logger.info("http %s", fmt % args)
|
|
165
|
+
|
|
166
|
+
return RequestHandler
|
|
167
|
+
|
|
168
|
+
def _handle(self, handler: BaseHTTPRequestHandler) -> None:
|
|
169
|
+
try:
|
|
170
|
+
body = self._read_body(handler)
|
|
171
|
+
result = self._ingress.handle(
|
|
172
|
+
method=handler.command,
|
|
173
|
+
path=handler.path,
|
|
174
|
+
headers=self._normalize_headers(handler),
|
|
175
|
+
body=body,
|
|
176
|
+
)
|
|
177
|
+
if result.shutdown_after_write:
|
|
178
|
+
self._enter_closing_gate()
|
|
179
|
+
try:
|
|
180
|
+
self._write_json(
|
|
181
|
+
handler,
|
|
182
|
+
result.status_code,
|
|
183
|
+
success_envelope(result.payload),
|
|
184
|
+
)
|
|
185
|
+
except OSError:
|
|
186
|
+
self._logger.info("close response write failed", exc_info=True)
|
|
187
|
+
finally:
|
|
188
|
+
self._request_shutdown_after_close()
|
|
189
|
+
return
|
|
190
|
+
self._write_json(
|
|
191
|
+
handler,
|
|
192
|
+
result.status_code,
|
|
193
|
+
success_envelope(result.payload),
|
|
194
|
+
)
|
|
195
|
+
except DaemonError as error:
|
|
196
|
+
self._write_json(handler, error.http_status, error_envelope(error))
|
|
197
|
+
except Exception: # pragma: no cover - defensive fallback
|
|
198
|
+
self._logger.exception("unexpected daemon failure")
|
|
199
|
+
daemon_error = DaemonError(
|
|
200
|
+
code=DaemonErrorCode.INTERNAL_COMMAND_FAILURE,
|
|
201
|
+
message="unexpected daemon failure",
|
|
202
|
+
retryable=False,
|
|
203
|
+
details={},
|
|
204
|
+
http_status=500,
|
|
205
|
+
)
|
|
206
|
+
self._write_json(
|
|
207
|
+
handler, daemon_error.http_status, error_envelope(daemon_error)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _enter_closing_gate(self) -> None:
|
|
211
|
+
if self._closing:
|
|
212
|
+
return
|
|
213
|
+
self._closing = True
|
|
214
|
+
|
|
215
|
+
def _request_shutdown_after_close(self) -> None:
|
|
216
|
+
if self._shutdown_after_close_requested:
|
|
217
|
+
return
|
|
218
|
+
self._shutdown_after_close_requested = True
|
|
219
|
+
self._shutdown_callback()
|
|
220
|
+
|
|
221
|
+
def _read_body(self, handler: BaseHTTPRequestHandler) -> bytes:
|
|
222
|
+
content_length_header = handler.headers.get("Content-Length")
|
|
223
|
+
if content_length_header is None:
|
|
224
|
+
return b""
|
|
225
|
+
content_length_raw = content_length_header.strip()
|
|
226
|
+
if not content_length_raw:
|
|
227
|
+
raise bad_request("invalid Content-Length header")
|
|
228
|
+
try:
|
|
229
|
+
content_length = int(content_length_raw)
|
|
230
|
+
except ValueError as error:
|
|
231
|
+
raise bad_request("invalid Content-Length header") from error
|
|
232
|
+
if content_length < 0:
|
|
233
|
+
raise bad_request("invalid Content-Length header")
|
|
234
|
+
if content_length > DAEMON_HTTP_MAX_REQUEST_BODY_BYTES:
|
|
235
|
+
handler.close_connection = True
|
|
236
|
+
raise DaemonError(
|
|
237
|
+
code=DaemonErrorCode.DAEMON_BAD_REQUEST,
|
|
238
|
+
message="request body too large",
|
|
239
|
+
retryable=False,
|
|
240
|
+
details={
|
|
241
|
+
"reason": "request_body_too_large",
|
|
242
|
+
"max": DAEMON_HTTP_MAX_REQUEST_BODY_BYTES,
|
|
243
|
+
"contentLength": content_length,
|
|
244
|
+
},
|
|
245
|
+
http_status=413,
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
body = handler.rfile.read(content_length)
|
|
249
|
+
except TimeoutError as error:
|
|
250
|
+
handler.close_connection = True
|
|
251
|
+
raise DaemonError(
|
|
252
|
+
code=DaemonErrorCode.DAEMON_BAD_REQUEST,
|
|
253
|
+
message="request body read timed out",
|
|
254
|
+
retryable=False,
|
|
255
|
+
details={"reason": "request_body_timeout"},
|
|
256
|
+
http_status=408,
|
|
257
|
+
) from error
|
|
258
|
+
if len(body) != content_length:
|
|
259
|
+
handler.close_connection = True
|
|
260
|
+
raise bad_request(
|
|
261
|
+
"incomplete request body",
|
|
262
|
+
{
|
|
263
|
+
"reason": "incomplete_body",
|
|
264
|
+
"contentLength": content_length,
|
|
265
|
+
"bytesRead": len(body),
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
return body
|
|
269
|
+
|
|
270
|
+
def _normalize_headers(self, handler: BaseHTTPRequestHandler) -> dict[str, str]:
|
|
271
|
+
return dict(handler.headers.items())
|
|
272
|
+
|
|
273
|
+
def _write_json(
|
|
274
|
+
self,
|
|
275
|
+
handler: BaseHTTPRequestHandler,
|
|
276
|
+
status_code: int,
|
|
277
|
+
payload: dict[str, Any],
|
|
278
|
+
) -> None:
|
|
279
|
+
body = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode(
|
|
280
|
+
"utf-8"
|
|
281
|
+
)
|
|
282
|
+
handler.send_response(status_code)
|
|
283
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
284
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
285
|
+
handler.end_headers()
|
|
286
|
+
handler.wfile.write(body)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Route dispatch for androidctld daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from androidctl_contracts.daemon_api import RuntimeGetResult, RuntimePayload
|
|
9
|
+
from androidctld import __version__
|
|
10
|
+
from androidctld.commands.service import CommandService
|
|
11
|
+
from androidctld.errors import bad_request
|
|
12
|
+
from androidctld.runtime import RuntimeKernel
|
|
13
|
+
from androidctld.runtime.screen_state import get_authoritative_current_basis
|
|
14
|
+
from androidctld.runtime.store import RuntimeStore
|
|
15
|
+
from androidctld.schema.daemon_api import (
|
|
16
|
+
HealthResult,
|
|
17
|
+
parse_command_run_request,
|
|
18
|
+
require_empty_payload,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DaemonService:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
runtime_store: RuntimeStore,
|
|
26
|
+
command_service: CommandService,
|
|
27
|
+
bound_owner_id: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._runtime_store = runtime_store
|
|
30
|
+
self._runtime_kernel = RuntimeKernel(runtime_store)
|
|
31
|
+
self._command_service = command_service
|
|
32
|
+
self._bound_owner_id = bound_owner_id
|
|
33
|
+
|
|
34
|
+
def handle(
|
|
35
|
+
self,
|
|
36
|
+
method: str,
|
|
37
|
+
path: str,
|
|
38
|
+
headers: dict[str, str],
|
|
39
|
+
body: bytes,
|
|
40
|
+
) -> tuple[int, dict[str, Any]]:
|
|
41
|
+
del headers
|
|
42
|
+
if method != "POST":
|
|
43
|
+
raise bad_request(
|
|
44
|
+
"use POST for daemon endpoints", {"path": path, "method": method}
|
|
45
|
+
)
|
|
46
|
+
payload = self._parse_body(body)
|
|
47
|
+
if path == "/health":
|
|
48
|
+
return 200, self._handle_health(payload)
|
|
49
|
+
if path == "/runtime/get":
|
|
50
|
+
return 200, self._handle_runtime_get(payload)
|
|
51
|
+
if path == "/runtime/close":
|
|
52
|
+
return 200, self._handle_runtime_close(payload)
|
|
53
|
+
if path == "/commands/run":
|
|
54
|
+
return 200, self._handle_commands_run(payload)
|
|
55
|
+
raise bad_request("path not found", {"path": path})
|
|
56
|
+
|
|
57
|
+
def _parse_body(self, body: bytes) -> dict[str, Any]:
|
|
58
|
+
if not body or not body.strip():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
payload = json.loads(body.decode("utf-8"))
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
raise bad_request("request body must be valid JSON") from error
|
|
64
|
+
if not isinstance(payload, dict):
|
|
65
|
+
raise bad_request("request body must be a JSON object")
|
|
66
|
+
return payload
|
|
67
|
+
|
|
68
|
+
def _handle_health(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
require_empty_payload(payload, "health")
|
|
70
|
+
runtime = self._runtime_store.get_runtime()
|
|
71
|
+
return HealthResult(
|
|
72
|
+
service="androidctld",
|
|
73
|
+
version=__version__,
|
|
74
|
+
workspace_root=runtime.workspace_root.as_posix(),
|
|
75
|
+
owner_id=self._bound_owner_id or "",
|
|
76
|
+
).model_dump(mode="json")
|
|
77
|
+
|
|
78
|
+
def _handle_runtime_get(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
+
require_empty_payload(payload, "runtime/get")
|
|
80
|
+
runtime = self._runtime_kernel.ensure_runtime()
|
|
81
|
+
basis = get_authoritative_current_basis(runtime)
|
|
82
|
+
runtime_payload_kwargs: dict[str, Any] = {
|
|
83
|
+
"workspace_root": runtime.workspace_root.as_posix(),
|
|
84
|
+
"artifact_root": runtime.artifact_root.as_posix(),
|
|
85
|
+
"status": runtime.status,
|
|
86
|
+
}
|
|
87
|
+
if basis is not None:
|
|
88
|
+
runtime_payload_kwargs["current_screen_id"] = basis.screen_id
|
|
89
|
+
return RuntimeGetResult(
|
|
90
|
+
runtime=RuntimePayload(**runtime_payload_kwargs)
|
|
91
|
+
).model_dump(mode="json")
|
|
92
|
+
|
|
93
|
+
def _handle_runtime_close(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
require_empty_payload(payload, "runtime/close")
|
|
95
|
+
return self._command_service.close_runtime()
|
|
96
|
+
|
|
97
|
+
def _handle_commands_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
request = parse_command_run_request(payload)
|
|
99
|
+
return self._command_service.run(command=request.command)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Device transport and RPC primitives for androidctld."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Typed outbound device action request models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from androidctld.refs.models import NodeHandle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class HandleTarget:
|
|
13
|
+
handle: NodeHandle
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CoordinatesTarget:
|
|
18
|
+
x: int
|
|
19
|
+
y: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class NoneTarget:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
TapTarget: TypeAlias = HandleTarget | CoordinatesTarget
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TapActionRequest:
|
|
32
|
+
target: TapTarget
|
|
33
|
+
timeout_ms: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class LongTapActionRequest:
|
|
38
|
+
target: TapTarget
|
|
39
|
+
timeout_ms: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TypeActionRequest:
|
|
44
|
+
target: HandleTarget
|
|
45
|
+
text: str
|
|
46
|
+
timeout_ms: int
|
|
47
|
+
submit: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class NodeActionRequest:
|
|
52
|
+
target: HandleTarget
|
|
53
|
+
action: str
|
|
54
|
+
timeout_ms: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ScrollActionRequest:
|
|
59
|
+
target: HandleTarget
|
|
60
|
+
direction: str
|
|
61
|
+
timeout_ms: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class SwipeActionRequest:
|
|
66
|
+
target: NoneTarget
|
|
67
|
+
direction: str
|
|
68
|
+
timeout_ms: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class GlobalActionRequest:
|
|
73
|
+
target: NoneTarget
|
|
74
|
+
action: str
|
|
75
|
+
timeout_ms: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class LaunchAppActionRequest:
|
|
80
|
+
target: NoneTarget
|
|
81
|
+
package_name: str
|
|
82
|
+
timeout_ms: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class OpenUrlActionRequest:
|
|
87
|
+
target: NoneTarget
|
|
88
|
+
url: str
|
|
89
|
+
timeout_ms: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
DeviceActionTarget: TypeAlias = HandleTarget | CoordinatesTarget | NoneTarget
|
|
93
|
+
|
|
94
|
+
DeviceActionRequest: TypeAlias = (
|
|
95
|
+
TapActionRequest
|
|
96
|
+
| LongTapActionRequest
|
|
97
|
+
| TypeActionRequest
|
|
98
|
+
| NodeActionRequest
|
|
99
|
+
| ScrollActionRequest
|
|
100
|
+
| SwipeActionRequest
|
|
101
|
+
| GlobalActionRequest
|
|
102
|
+
| LaunchAppActionRequest
|
|
103
|
+
| OpenUrlActionRequest
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class BuiltDeviceActionRequest:
|
|
109
|
+
payload: DeviceActionRequest
|
|
110
|
+
request_handle: NodeHandle | None = None
|
|
111
|
+
dispatched_handle: NodeHandle | None = None
|
|
112
|
+
submit_route: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def required_action_kind_for_request(request: DeviceActionRequest) -> str:
|
|
116
|
+
if isinstance(request, TapActionRequest):
|
|
117
|
+
return "tap"
|
|
118
|
+
if isinstance(request, LongTapActionRequest):
|
|
119
|
+
return "longTap"
|
|
120
|
+
if isinstance(request, TypeActionRequest):
|
|
121
|
+
return "type"
|
|
122
|
+
if isinstance(request, NodeActionRequest):
|
|
123
|
+
return "node"
|
|
124
|
+
if isinstance(request, ScrollActionRequest):
|
|
125
|
+
return "scroll"
|
|
126
|
+
if isinstance(request, SwipeActionRequest):
|
|
127
|
+
return "gesture"
|
|
128
|
+
if isinstance(request, GlobalActionRequest):
|
|
129
|
+
return "global"
|
|
130
|
+
if isinstance(request, LaunchAppActionRequest):
|
|
131
|
+
return "launchApp"
|
|
132
|
+
if isinstance(request, OpenUrlActionRequest):
|
|
133
|
+
return "openUrl"
|
|
134
|
+
raise TypeError(f"unsupported device action request: {type(request)!r}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = [
|
|
138
|
+
"CoordinatesTarget",
|
|
139
|
+
"DeviceActionRequest",
|
|
140
|
+
"DeviceActionTarget",
|
|
141
|
+
"GlobalActionRequest",
|
|
142
|
+
"HandleTarget",
|
|
143
|
+
"LaunchAppActionRequest",
|
|
144
|
+
"LongTapActionRequest",
|
|
145
|
+
"NodeActionRequest",
|
|
146
|
+
"NoneTarget",
|
|
147
|
+
"OpenUrlActionRequest",
|
|
148
|
+
"ScrollActionRequest",
|
|
149
|
+
"SwipeActionRequest",
|
|
150
|
+
"TapActionRequest",
|
|
151
|
+
"TapTarget",
|
|
152
|
+
"TypeActionRequest",
|
|
153
|
+
"required_action_kind_for_request",
|
|
154
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Serialization helpers for outbound device action requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from androidctld.device.action_models import (
|
|
8
|
+
CoordinatesTarget,
|
|
9
|
+
DeviceActionRequest,
|
|
10
|
+
DeviceActionTarget,
|
|
11
|
+
GlobalActionRequest,
|
|
12
|
+
HandleTarget,
|
|
13
|
+
LaunchAppActionRequest,
|
|
14
|
+
LongTapActionRequest,
|
|
15
|
+
NodeActionRequest,
|
|
16
|
+
NoneTarget,
|
|
17
|
+
OpenUrlActionRequest,
|
|
18
|
+
ScrollActionRequest,
|
|
19
|
+
SwipeActionRequest,
|
|
20
|
+
TapActionRequest,
|
|
21
|
+
TypeActionRequest,
|
|
22
|
+
)
|
|
23
|
+
from androidctld.device.adapters import dump_node_handle
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dump_device_action_request(
|
|
27
|
+
request: DeviceActionRequest,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
if isinstance(request, TapActionRequest):
|
|
30
|
+
return {
|
|
31
|
+
"kind": "tap",
|
|
32
|
+
"target": dump_device_action_target(request.target),
|
|
33
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
34
|
+
}
|
|
35
|
+
if isinstance(request, LongTapActionRequest):
|
|
36
|
+
return {
|
|
37
|
+
"kind": "longTap",
|
|
38
|
+
"target": dump_device_action_target(request.target),
|
|
39
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
40
|
+
}
|
|
41
|
+
if isinstance(request, TypeActionRequest):
|
|
42
|
+
return {
|
|
43
|
+
"kind": "type",
|
|
44
|
+
"target": dump_device_action_target(request.target),
|
|
45
|
+
"input": {
|
|
46
|
+
"text": request.text,
|
|
47
|
+
"replace": True,
|
|
48
|
+
"submit": request.submit,
|
|
49
|
+
"ensureFocused": True,
|
|
50
|
+
},
|
|
51
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
52
|
+
}
|
|
53
|
+
if isinstance(request, NodeActionRequest):
|
|
54
|
+
return {
|
|
55
|
+
"kind": "node",
|
|
56
|
+
"target": dump_device_action_target(request.target),
|
|
57
|
+
"node": {"action": request.action},
|
|
58
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
59
|
+
}
|
|
60
|
+
if isinstance(request, ScrollActionRequest):
|
|
61
|
+
return {
|
|
62
|
+
"kind": "scroll",
|
|
63
|
+
"target": dump_device_action_target(request.target),
|
|
64
|
+
"scroll": {"direction": request.direction},
|
|
65
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
66
|
+
}
|
|
67
|
+
if isinstance(request, SwipeActionRequest):
|
|
68
|
+
if not isinstance(request.target, NoneTarget):
|
|
69
|
+
raise TypeError("swipe action requires none target")
|
|
70
|
+
return {
|
|
71
|
+
"kind": "gesture",
|
|
72
|
+
"target": dump_device_action_target(request.target),
|
|
73
|
+
"gesture": {"direction": request.direction},
|
|
74
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
75
|
+
}
|
|
76
|
+
if isinstance(request, GlobalActionRequest):
|
|
77
|
+
return {
|
|
78
|
+
"kind": "global",
|
|
79
|
+
"target": dump_device_action_target(request.target),
|
|
80
|
+
"global": {"action": request.action},
|
|
81
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
82
|
+
}
|
|
83
|
+
if isinstance(request, LaunchAppActionRequest):
|
|
84
|
+
return {
|
|
85
|
+
"kind": "launchApp",
|
|
86
|
+
"target": dump_device_action_target(request.target),
|
|
87
|
+
"intent": {
|
|
88
|
+
"packageName": request.package_name,
|
|
89
|
+
},
|
|
90
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
91
|
+
}
|
|
92
|
+
if isinstance(request, OpenUrlActionRequest):
|
|
93
|
+
return {
|
|
94
|
+
"kind": "openUrl",
|
|
95
|
+
"target": dump_device_action_target(request.target),
|
|
96
|
+
"intent": {
|
|
97
|
+
"url": request.url,
|
|
98
|
+
},
|
|
99
|
+
"options": {"timeoutMs": request.timeout_ms},
|
|
100
|
+
}
|
|
101
|
+
raise TypeError(f"unsupported device action request: {type(request)!r}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def dump_device_action_target(target: DeviceActionTarget) -> dict[str, Any]:
|
|
105
|
+
if isinstance(target, HandleTarget):
|
|
106
|
+
return {
|
|
107
|
+
"kind": "handle",
|
|
108
|
+
"handle": dump_node_handle(target.handle),
|
|
109
|
+
}
|
|
110
|
+
if isinstance(target, CoordinatesTarget):
|
|
111
|
+
return {
|
|
112
|
+
"kind": "coordinates",
|
|
113
|
+
"x": target.x,
|
|
114
|
+
"y": target.y,
|
|
115
|
+
}
|
|
116
|
+
if isinstance(target, NoneTarget):
|
|
117
|
+
return {"kind": "none"}
|
|
118
|
+
raise TypeError(f"unsupported device action target: {type(target)!r}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["dump_device_action_request", "dump_device_action_target"]
|