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,266 @@
|
|
|
1
|
+
"""active.json persistence for androidctld."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from contextlib import contextmanager, suppress
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from androidctl_contracts.user_state import ActiveDaemonRecord
|
|
18
|
+
from androidctld.auth.secret_files import write_secret_json_file_atomically
|
|
19
|
+
from androidctld.config import DaemonConfig, normalize_loopback_host
|
|
20
|
+
from androidctld.daemon.ownership_probe import (
|
|
21
|
+
OwnershipHealthProbe,
|
|
22
|
+
OwnershipHealthProbeResult,
|
|
23
|
+
OwnershipHealthStatus,
|
|
24
|
+
)
|
|
25
|
+
from androidctld.schema.persistence import (
|
|
26
|
+
ActiveDaemonFile,
|
|
27
|
+
build_persistence_model,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = ["ActiveDaemonRecord", "ActiveDaemonRegistry"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ActiveDaemonRegistry:
|
|
34
|
+
_LOCK_ACQUIRE_TIMEOUT_SECONDS = 2.0
|
|
35
|
+
_LOCK_RETRY_INTERVAL_SECONDS = 0.01
|
|
36
|
+
_LOCK_STALE_SECONDS = 30.0
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
config: DaemonConfig,
|
|
41
|
+
*,
|
|
42
|
+
live_checker: (
|
|
43
|
+
Callable[[ActiveDaemonRecord], OwnershipHealthProbeResult] | None
|
|
44
|
+
) = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._config = config
|
|
47
|
+
self._live_checker = live_checker or self._default_live_checker
|
|
48
|
+
|
|
49
|
+
def build_record(self, host: str, port: int, token: str) -> ActiveDaemonRecord:
|
|
50
|
+
pid = os.getpid()
|
|
51
|
+
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
52
|
+
return ActiveDaemonRecord(
|
|
53
|
+
pid=pid,
|
|
54
|
+
host=normalize_loopback_host(host),
|
|
55
|
+
port=port,
|
|
56
|
+
token=token,
|
|
57
|
+
started_at=started_at,
|
|
58
|
+
workspace_root=self._config.workspace_root.as_posix(),
|
|
59
|
+
owner_id=self._config.owner_id,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def publish(self, record: ActiveDaemonRecord) -> None:
|
|
63
|
+
self._config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with self._active_lock():
|
|
65
|
+
existing = self.read()
|
|
66
|
+
if existing is not None and self._active_record_conflicts(
|
|
67
|
+
existing,
|
|
68
|
+
record,
|
|
69
|
+
):
|
|
70
|
+
raise RuntimeError("live daemon already owns active slot")
|
|
71
|
+
payload = self._build_active_file_payload(record)
|
|
72
|
+
self._write_active_file_atomically(payload)
|
|
73
|
+
|
|
74
|
+
def clear(self, *, record: ActiveDaemonRecord | None = None) -> None:
|
|
75
|
+
with self._active_lock():
|
|
76
|
+
if record is not None:
|
|
77
|
+
try:
|
|
78
|
+
active_file = self._read_active_file_model()
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return
|
|
81
|
+
except OSError:
|
|
82
|
+
return
|
|
83
|
+
except (ValueError, json.JSONDecodeError, ValidationError):
|
|
84
|
+
pass
|
|
85
|
+
else:
|
|
86
|
+
if (
|
|
87
|
+
active_file.pid != record.pid
|
|
88
|
+
or active_file.started_at != record.started_at
|
|
89
|
+
):
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
self._config.active_file_path.unlink()
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
def restore(self, record: ActiveDaemonRecord) -> None:
|
|
97
|
+
payload = self._build_active_file_payload(record)
|
|
98
|
+
with self._active_lock():
|
|
99
|
+
self._write_active_file_atomically(payload)
|
|
100
|
+
|
|
101
|
+
def read(self) -> ActiveDaemonRecord | None:
|
|
102
|
+
if not self._config.active_file_path.exists():
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
record = self._read_active_file_model()
|
|
106
|
+
except OSError:
|
|
107
|
+
return None
|
|
108
|
+
except (ValueError, json.JSONDecodeError, ValidationError):
|
|
109
|
+
with suppress(OSError):
|
|
110
|
+
self._config.active_file_path.unlink()
|
|
111
|
+
return None
|
|
112
|
+
return ActiveDaemonRecord(
|
|
113
|
+
pid=record.pid,
|
|
114
|
+
host=record.host,
|
|
115
|
+
port=record.port,
|
|
116
|
+
token=record.token,
|
|
117
|
+
started_at=record.started_at,
|
|
118
|
+
workspace_root=record.workspace_root,
|
|
119
|
+
owner_id=record.owner_id,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _active_record_conflicts(
|
|
123
|
+
self,
|
|
124
|
+
existing: ActiveDaemonRecord,
|
|
125
|
+
record: ActiveDaemonRecord,
|
|
126
|
+
) -> bool:
|
|
127
|
+
if existing.identity == record.identity:
|
|
128
|
+
return False
|
|
129
|
+
probe_result = self._live_checker(existing)
|
|
130
|
+
if probe_result.is_live:
|
|
131
|
+
return True
|
|
132
|
+
if probe_result.status == OwnershipHealthStatus.UNREACHABLE:
|
|
133
|
+
return False
|
|
134
|
+
return self._is_pid_live(existing.pid)
|
|
135
|
+
|
|
136
|
+
def _default_live_checker(
|
|
137
|
+
self,
|
|
138
|
+
record: ActiveDaemonRecord,
|
|
139
|
+
) -> OwnershipHealthProbeResult:
|
|
140
|
+
return OwnershipHealthProbe().probe_active_record(
|
|
141
|
+
record,
|
|
142
|
+
expected_workspace_root=self._config.workspace_root.as_posix(),
|
|
143
|
+
expected_owner_id=self._config.owner_id,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _read_active_file_model(self) -> ActiveDaemonFile:
|
|
147
|
+
with self._config.active_file_path.open("r", encoding="utf-8") as handle:
|
|
148
|
+
payload = json.load(handle)
|
|
149
|
+
return ActiveDaemonFile.model_validate(payload)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _build_active_file_payload(record: ActiveDaemonRecord) -> dict[str, object]:
|
|
153
|
+
return cast(
|
|
154
|
+
dict[str, object],
|
|
155
|
+
build_persistence_model(
|
|
156
|
+
ActiveDaemonFile,
|
|
157
|
+
pid=record.pid,
|
|
158
|
+
host=normalize_loopback_host(record.host),
|
|
159
|
+
port=record.port,
|
|
160
|
+
token=record.token,
|
|
161
|
+
started_at=record.started_at,
|
|
162
|
+
workspace_root=record.workspace_root,
|
|
163
|
+
owner_id=record.owner_id,
|
|
164
|
+
).model_dump(by_alias=True, mode="json"),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _is_pid_live(pid: int) -> bool:
|
|
169
|
+
if pid <= 0:
|
|
170
|
+
return False
|
|
171
|
+
if platform.system() == "Windows":
|
|
172
|
+
return ActiveDaemonRegistry._windows_is_pid_live(pid)
|
|
173
|
+
try:
|
|
174
|
+
os.kill(pid, 0)
|
|
175
|
+
except ProcessLookupError:
|
|
176
|
+
return False
|
|
177
|
+
except PermissionError:
|
|
178
|
+
return True
|
|
179
|
+
except OSError:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _windows_is_pid_live(pid: int, kernel32: Any | None = None) -> bool:
|
|
185
|
+
import ctypes
|
|
186
|
+
|
|
187
|
+
process_query_limited_information = 0x1000
|
|
188
|
+
still_active = 259
|
|
189
|
+
if kernel32 is None:
|
|
190
|
+
windll = getattr(ctypes, "windll", None)
|
|
191
|
+
if windll is None:
|
|
192
|
+
return False
|
|
193
|
+
kernel32 = windll.kernel32
|
|
194
|
+
|
|
195
|
+
process_handle = kernel32.OpenProcess(
|
|
196
|
+
process_query_limited_information,
|
|
197
|
+
False,
|
|
198
|
+
pid,
|
|
199
|
+
)
|
|
200
|
+
if not process_handle:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
exit_code = ctypes.c_ulong(0)
|
|
205
|
+
if not kernel32.GetExitCodeProcess(process_handle, ctypes.byref(exit_code)):
|
|
206
|
+
return False
|
|
207
|
+
return exit_code.value == still_active
|
|
208
|
+
finally:
|
|
209
|
+
kernel32.CloseHandle(process_handle)
|
|
210
|
+
|
|
211
|
+
@contextmanager
|
|
212
|
+
def _active_lock(self) -> Iterator[None]:
|
|
213
|
+
lock_path = self._config.active_lock_path
|
|
214
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
deadline = time.monotonic() + self._LOCK_ACQUIRE_TIMEOUT_SECONDS
|
|
216
|
+
while True:
|
|
217
|
+
fd: int | None = None
|
|
218
|
+
try:
|
|
219
|
+
fd = os.open(
|
|
220
|
+
lock_path,
|
|
221
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
222
|
+
0o600,
|
|
223
|
+
)
|
|
224
|
+
os.write(fd, str(os.getpid()).encode("utf-8"))
|
|
225
|
+
os.close(fd)
|
|
226
|
+
fd = None
|
|
227
|
+
break
|
|
228
|
+
except FileExistsError:
|
|
229
|
+
if self._recover_stale_lock(lock_path):
|
|
230
|
+
continue
|
|
231
|
+
if time.monotonic() >= deadline:
|
|
232
|
+
raise RuntimeError("timed out acquiring active slot lock") from None
|
|
233
|
+
time.sleep(self._LOCK_RETRY_INTERVAL_SECONDS)
|
|
234
|
+
finally:
|
|
235
|
+
if fd is not None:
|
|
236
|
+
os.close(fd)
|
|
237
|
+
try:
|
|
238
|
+
yield
|
|
239
|
+
finally:
|
|
240
|
+
with suppress(FileNotFoundError):
|
|
241
|
+
lock_path.unlink()
|
|
242
|
+
|
|
243
|
+
def _recover_stale_lock(self, lock_path: Path) -> bool:
|
|
244
|
+
try:
|
|
245
|
+
raw = lock_path.read_text(encoding="utf-8").strip()
|
|
246
|
+
pid = int(raw) if raw else -1
|
|
247
|
+
except (OSError, ValueError):
|
|
248
|
+
pid = -1
|
|
249
|
+
if pid > 0 and self._is_pid_live(pid):
|
|
250
|
+
return False
|
|
251
|
+
try:
|
|
252
|
+
lock_age_seconds = time.time() - lock_path.stat().st_mtime
|
|
253
|
+
except OSError:
|
|
254
|
+
return False
|
|
255
|
+
if pid <= 0 and lock_age_seconds < self._LOCK_STALE_SECONDS:
|
|
256
|
+
return False
|
|
257
|
+
try:
|
|
258
|
+
lock_path.unlink()
|
|
259
|
+
except FileNotFoundError:
|
|
260
|
+
return True
|
|
261
|
+
except OSError:
|
|
262
|
+
return False
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
def _write_active_file_atomically(self, payload: dict[str, object]) -> None:
|
|
266
|
+
write_secret_json_file_atomically(self._config.active_file_path, payload)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Restricted atomic writers for daemon-local sensitive state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from androidctld.schema.persistence_io import dump_formatted_json
|
|
12
|
+
|
|
13
|
+
SECRET_FILE_MODE = 0o600
|
|
14
|
+
SECRET_DIR_MODE = 0o700
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_secret_json_file_atomically(path: Path, payload: dict[str, Any]) -> None:
|
|
18
|
+
"""Write sensitive daemon JSON state via a restricted atomic sidecar."""
|
|
19
|
+
_ensure_secret_parent_dir(path.parent)
|
|
20
|
+
temp_path = _unique_temp_path(path)
|
|
21
|
+
fd: int | None = None
|
|
22
|
+
try:
|
|
23
|
+
fd = os.open(
|
|
24
|
+
temp_path,
|
|
25
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
26
|
+
SECRET_FILE_MODE,
|
|
27
|
+
)
|
|
28
|
+
if os.name == "posix":
|
|
29
|
+
os.fchmod(fd, SECRET_FILE_MODE)
|
|
30
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
31
|
+
fd = None
|
|
32
|
+
dump_formatted_json(handle, payload)
|
|
33
|
+
handle.flush()
|
|
34
|
+
os.fsync(handle.fileno())
|
|
35
|
+
os.replace(temp_path, path)
|
|
36
|
+
except BaseException:
|
|
37
|
+
if fd is not None:
|
|
38
|
+
os.close(fd)
|
|
39
|
+
raise
|
|
40
|
+
finally:
|
|
41
|
+
with suppress(FileNotFoundError):
|
|
42
|
+
temp_path.unlink()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unique_temp_path(path: Path) -> Path:
|
|
46
|
+
return path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_secret_parent_dir(path: Path) -> None:
|
|
50
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
if os.name == "posix":
|
|
52
|
+
os.chmod(path, SECRET_DIR_MODE)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Daemon token management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from androidctld.auth.secret_files import write_secret_json_file_atomically
|
|
11
|
+
from androidctld.config import DaemonConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DaemonTokenStore:
|
|
16
|
+
config: DaemonConfig | None = None
|
|
17
|
+
_token: str = ""
|
|
18
|
+
|
|
19
|
+
def current_token(self) -> str:
|
|
20
|
+
if self._token:
|
|
21
|
+
return self._token
|
|
22
|
+
token_path = self._token_path()
|
|
23
|
+
if token_path is not None:
|
|
24
|
+
loaded = self._load_token(token_path)
|
|
25
|
+
if loaded:
|
|
26
|
+
self._token = loaded
|
|
27
|
+
return self._token
|
|
28
|
+
self._token = secrets.token_urlsafe(32)
|
|
29
|
+
if token_path is not None:
|
|
30
|
+
self._persist_token(token_path, self._token)
|
|
31
|
+
return self._token
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def load_existing_token(cls, path: Path) -> str | None:
|
|
35
|
+
return cls._load_token(path)
|
|
36
|
+
|
|
37
|
+
def _token_path(self) -> Path | None:
|
|
38
|
+
if self.config is None:
|
|
39
|
+
return None
|
|
40
|
+
return self.config.token_file_path
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _load_token(path: Path) -> str | None:
|
|
44
|
+
try:
|
|
45
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(payload, dict):
|
|
49
|
+
return None
|
|
50
|
+
token = payload.get("token")
|
|
51
|
+
if isinstance(token, str):
|
|
52
|
+
token = token.strip()
|
|
53
|
+
if token:
|
|
54
|
+
return token
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _persist_token(path: Path, token: str) -> None:
|
|
59
|
+
write_secret_json_file_atomically(path, {"token": token})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command handling for androidctld."""
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Command service object-graph assembly helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from androidctld.actions.executor import ActionExecutor
|
|
10
|
+
from androidctld.actions.repair import ActionCommandRepairer
|
|
11
|
+
from androidctld.actions.settle import ActionSettler
|
|
12
|
+
from androidctld.artifacts.writer import ArtifactWriter
|
|
13
|
+
from androidctld.commands.dispatch import CommandDispatch
|
|
14
|
+
from androidctld.commands.executor import CommandExecutor
|
|
15
|
+
from androidctld.commands.handlers.action import ActionCommandHandler
|
|
16
|
+
from androidctld.commands.handlers.connect import ConnectCommandHandler
|
|
17
|
+
from androidctld.commands.handlers.list_apps import ListAppsCommandHandler
|
|
18
|
+
from androidctld.commands.handlers.observe import ObserveCommandHandler
|
|
19
|
+
from androidctld.commands.handlers.screenshot import ScreenshotCommandHandler
|
|
20
|
+
from androidctld.commands.handlers.wait import WaitCommandHandler
|
|
21
|
+
from androidctld.commands.orchestration import CommandRunOrchestrator
|
|
22
|
+
from androidctld.device.bootstrap import DeviceBootstrapper
|
|
23
|
+
from androidctld.device.interfaces import (
|
|
24
|
+
DeviceClientFactory,
|
|
25
|
+
DeviceClientProvider,
|
|
26
|
+
DeviceRuntimeClient,
|
|
27
|
+
)
|
|
28
|
+
from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease, RuntimeStore
|
|
29
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
30
|
+
from androidctld.semantics.compiler import SemanticCompiler
|
|
31
|
+
from androidctld.snapshots.refresh import ScreenRefreshService
|
|
32
|
+
from androidctld.snapshots.service import SnapshotService
|
|
33
|
+
from androidctld.waits.loop import WaitRuntimeLoop
|
|
34
|
+
|
|
35
|
+
SleepFn = Callable[[float], None]
|
|
36
|
+
TimeFn = Callable[[], float]
|
|
37
|
+
|
|
38
|
+
__all__ = ["CommandServiceAssembly", "SleepFn", "assemble_command_service"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RuntimePersistencePort:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
runtime_kernel: RuntimeKernel,
|
|
46
|
+
runtime_store: RuntimeStore,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._runtime_kernel = runtime_kernel
|
|
49
|
+
self._runtime_store = runtime_store
|
|
50
|
+
|
|
51
|
+
def ensure_runtime(self) -> WorkspaceRuntime:
|
|
52
|
+
return self._runtime_kernel.ensure_runtime()
|
|
53
|
+
|
|
54
|
+
def persist_runtime(self, runtime: WorkspaceRuntime) -> None:
|
|
55
|
+
self._runtime_store.persist_runtime(runtime)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class CommandServiceAssembly:
|
|
60
|
+
sleep_fn: SleepFn
|
|
61
|
+
time_fn: TimeFn
|
|
62
|
+
runtime_kernel: RuntimeKernel
|
|
63
|
+
runtime_store: _RuntimePersistencePort
|
|
64
|
+
bootstrapper: DeviceBootstrapper
|
|
65
|
+
snapshot_service: SnapshotService
|
|
66
|
+
semantic_compiler: SemanticCompiler
|
|
67
|
+
device_client_factory: DeviceClientFactory
|
|
68
|
+
artifact_writer: ArtifactWriter
|
|
69
|
+
screen_refresh: ScreenRefreshService
|
|
70
|
+
action_executor: ActionExecutor
|
|
71
|
+
wait_runtime_loop: WaitRuntimeLoop
|
|
72
|
+
connect_handler: ConnectCommandHandler
|
|
73
|
+
observe_handler: ObserveCommandHandler
|
|
74
|
+
list_apps_handler: ListAppsCommandHandler
|
|
75
|
+
action_handler: ActionCommandHandler
|
|
76
|
+
wait_handler: WaitCommandHandler
|
|
77
|
+
screenshot_handler: ScreenshotCommandHandler
|
|
78
|
+
orchestrator: CommandRunOrchestrator
|
|
79
|
+
dispatch: CommandDispatch
|
|
80
|
+
executor: CommandExecutor
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def assemble_command_service(
|
|
84
|
+
*,
|
|
85
|
+
runtime_store: RuntimeStore,
|
|
86
|
+
bootstrapper: DeviceBootstrapper | None = None,
|
|
87
|
+
snapshot_service: SnapshotService | None = None,
|
|
88
|
+
semantic_compiler: SemanticCompiler | None = None,
|
|
89
|
+
artifact_writer: ArtifactWriter | None = None,
|
|
90
|
+
device_client_factory: DeviceClientFactory | None = None,
|
|
91
|
+
sleep_fn: SleepFn | None = None,
|
|
92
|
+
time_fn: TimeFn | None = None,
|
|
93
|
+
) -> CommandServiceAssembly:
|
|
94
|
+
resolved_sleep_fn = sleep_fn or time.sleep
|
|
95
|
+
resolved_time_fn = time_fn or time.monotonic
|
|
96
|
+
runtime_kernel = RuntimeKernel(
|
|
97
|
+
runtime_store,
|
|
98
|
+
sleep_fn=resolved_sleep_fn,
|
|
99
|
+
time_fn=resolved_time_fn,
|
|
100
|
+
)
|
|
101
|
+
persistence_port = _RuntimePersistencePort(
|
|
102
|
+
runtime_kernel=runtime_kernel,
|
|
103
|
+
runtime_store=runtime_store,
|
|
104
|
+
)
|
|
105
|
+
resolved_bootstrapper = bootstrapper or DeviceBootstrapper()
|
|
106
|
+
default_snapshot_service = SnapshotService(
|
|
107
|
+
bootstrapper=resolved_bootstrapper,
|
|
108
|
+
runtime_kernel=runtime_kernel,
|
|
109
|
+
)
|
|
110
|
+
resolved_snapshot_service = snapshot_service or default_snapshot_service
|
|
111
|
+
resolved_semantic_compiler = semantic_compiler or SemanticCompiler()
|
|
112
|
+
resolved_device_client_factory = _resolve_device_client_factory(
|
|
113
|
+
snapshot_service=resolved_snapshot_service,
|
|
114
|
+
explicit_factory=device_client_factory,
|
|
115
|
+
)
|
|
116
|
+
resolved_artifact_writer = artifact_writer or ArtifactWriter()
|
|
117
|
+
screen_refresh = ScreenRefreshService(
|
|
118
|
+
runtime_kernel=runtime_kernel,
|
|
119
|
+
semantic_compiler=resolved_semantic_compiler,
|
|
120
|
+
artifact_writer=resolved_artifact_writer,
|
|
121
|
+
)
|
|
122
|
+
action_executor = ActionExecutor(
|
|
123
|
+
device_client_factory=resolved_device_client_factory,
|
|
124
|
+
screen_refresh=screen_refresh,
|
|
125
|
+
settler=ActionSettler(
|
|
126
|
+
snapshot_service=resolved_snapshot_service,
|
|
127
|
+
semantic_compiler=resolved_semantic_compiler,
|
|
128
|
+
sleep_fn=resolved_sleep_fn,
|
|
129
|
+
time_fn=resolved_time_fn,
|
|
130
|
+
),
|
|
131
|
+
repairer=ActionCommandRepairer(
|
|
132
|
+
snapshot_service=resolved_snapshot_service,
|
|
133
|
+
screen_refresh=screen_refresh,
|
|
134
|
+
),
|
|
135
|
+
runtime_kernel=runtime_kernel,
|
|
136
|
+
)
|
|
137
|
+
wait_runtime_loop = WaitRuntimeLoop(
|
|
138
|
+
snapshot_service=resolved_snapshot_service,
|
|
139
|
+
screen_refresh=screen_refresh,
|
|
140
|
+
device_client_factory=resolved_device_client_factory,
|
|
141
|
+
sleep_fn=resolved_sleep_fn,
|
|
142
|
+
time_fn=resolved_time_fn,
|
|
143
|
+
)
|
|
144
|
+
connect_handler = ConnectCommandHandler(
|
|
145
|
+
runtime_kernel=runtime_kernel,
|
|
146
|
+
bootstrapper=resolved_bootstrapper,
|
|
147
|
+
snapshot_service=resolved_snapshot_service,
|
|
148
|
+
screen_refresh=screen_refresh,
|
|
149
|
+
)
|
|
150
|
+
observe_handler = ObserveCommandHandler(
|
|
151
|
+
runtime_kernel=runtime_kernel,
|
|
152
|
+
snapshot_service=resolved_snapshot_service,
|
|
153
|
+
screen_refresh=screen_refresh,
|
|
154
|
+
)
|
|
155
|
+
list_apps_handler = ListAppsCommandHandler(
|
|
156
|
+
runtime_kernel=runtime_kernel,
|
|
157
|
+
bootstrapper=resolved_bootstrapper,
|
|
158
|
+
)
|
|
159
|
+
action_handler = ActionCommandHandler(
|
|
160
|
+
runtime_kernel=runtime_kernel,
|
|
161
|
+
action_executor=action_executor,
|
|
162
|
+
)
|
|
163
|
+
wait_handler = WaitCommandHandler(
|
|
164
|
+
runtime_kernel=runtime_kernel,
|
|
165
|
+
wait_runtime_loop=wait_runtime_loop,
|
|
166
|
+
)
|
|
167
|
+
screenshot_handler = ScreenshotCommandHandler(
|
|
168
|
+
runtime_kernel=runtime_kernel,
|
|
169
|
+
device_client_factory=resolved_device_client_factory,
|
|
170
|
+
artifact_writer=resolved_artifact_writer,
|
|
171
|
+
)
|
|
172
|
+
orchestrator = CommandRunOrchestrator(
|
|
173
|
+
serial_admission=runtime_store.begin_serial_command,
|
|
174
|
+
time_fn=resolved_time_fn,
|
|
175
|
+
)
|
|
176
|
+
dispatch = CommandDispatch(
|
|
177
|
+
connect_handler=connect_handler,
|
|
178
|
+
observe_handler=observe_handler,
|
|
179
|
+
list_apps_handler=list_apps_handler,
|
|
180
|
+
action_handler=action_handler,
|
|
181
|
+
wait_handler=wait_handler,
|
|
182
|
+
screenshot_handler=screenshot_handler,
|
|
183
|
+
)
|
|
184
|
+
executor = CommandExecutor(handlers=dispatch.build_handlers())
|
|
185
|
+
return CommandServiceAssembly(
|
|
186
|
+
sleep_fn=resolved_sleep_fn,
|
|
187
|
+
time_fn=resolved_time_fn,
|
|
188
|
+
runtime_kernel=runtime_kernel,
|
|
189
|
+
runtime_store=persistence_port,
|
|
190
|
+
bootstrapper=resolved_bootstrapper,
|
|
191
|
+
snapshot_service=resolved_snapshot_service,
|
|
192
|
+
semantic_compiler=resolved_semantic_compiler,
|
|
193
|
+
device_client_factory=resolved_device_client_factory,
|
|
194
|
+
artifact_writer=resolved_artifact_writer,
|
|
195
|
+
screen_refresh=screen_refresh,
|
|
196
|
+
action_executor=action_executor,
|
|
197
|
+
wait_runtime_loop=wait_runtime_loop,
|
|
198
|
+
connect_handler=connect_handler,
|
|
199
|
+
observe_handler=observe_handler,
|
|
200
|
+
list_apps_handler=list_apps_handler,
|
|
201
|
+
action_handler=action_handler,
|
|
202
|
+
wait_handler=wait_handler,
|
|
203
|
+
screenshot_handler=screenshot_handler,
|
|
204
|
+
orchestrator=orchestrator,
|
|
205
|
+
dispatch=dispatch,
|
|
206
|
+
executor=executor,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _resolve_device_client_factory(
|
|
211
|
+
*,
|
|
212
|
+
snapshot_service: object,
|
|
213
|
+
explicit_factory: DeviceClientFactory | None,
|
|
214
|
+
) -> DeviceClientFactory:
|
|
215
|
+
if explicit_factory is not None:
|
|
216
|
+
return explicit_factory
|
|
217
|
+
if isinstance(snapshot_service, DeviceClientProvider):
|
|
218
|
+
return snapshot_service.device_client
|
|
219
|
+
return _missing_device_client_factory
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _missing_device_client_factory(
|
|
223
|
+
session: WorkspaceRuntime,
|
|
224
|
+
*,
|
|
225
|
+
lifecycle_lease: RuntimeLifecycleLease | None = None,
|
|
226
|
+
) -> DeviceRuntimeClient:
|
|
227
|
+
del session, lifecycle_lease
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
"CommandService requires device_client_factory when snapshot_service "
|
|
230
|
+
"does not provide device_client()"
|
|
231
|
+
)
|