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.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,215 @@
1
+ """Thin list-apps command handler backed by wrapper-backed apps.list RPC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, NoReturn, Protocol
7
+
8
+ from androidctl_contracts.command_results import ListAppsResult
9
+ from androidctld.commands.command_models import ListAppsCommand
10
+ from androidctld.device.bootstrap import DeviceBootstrapper
11
+ from androidctld.device.rpc import DeviceRpcClient
12
+ from androidctld.device.types import DeviceEndpoint
13
+ from androidctld.errors import DaemonError, DaemonErrorCode
14
+ from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease
15
+ from androidctld.runtime.models import WorkspaceRuntime
16
+ from androidctld.runtime_policy import DEVICE_RPC_REQUEST_ID_LIST_APPS
17
+
18
+ ANDROID_APPS_LIST_METHOD = "apps.list"
19
+
20
+
21
+ class ListAppsRpcClient(Protocol):
22
+ def call_result_payload(
23
+ self,
24
+ method: str,
25
+ params: dict[str, Any] | None = None,
26
+ *,
27
+ request_id: str,
28
+ ) -> object: ...
29
+
30
+
31
+ ListAppsRpcClientFactory = Callable[[DeviceEndpoint, str], ListAppsRpcClient]
32
+
33
+
34
+ class ListAppsCommandHandler:
35
+ def __init__(
36
+ self,
37
+ *,
38
+ runtime_kernel: RuntimeKernel,
39
+ bootstrapper: DeviceBootstrapper,
40
+ rpc_client_factory: ListAppsRpcClientFactory | None = None,
41
+ ) -> None:
42
+ self._runtime_kernel = runtime_kernel
43
+ self._bootstrapper = bootstrapper
44
+ self._rpc_client_factory = rpc_client_factory or DeviceRpcClient
45
+
46
+ def handle(
47
+ self,
48
+ *,
49
+ command: ListAppsCommand,
50
+ ) -> dict[str, object]:
51
+ del command
52
+ runtime = self._runtime_kernel.ensure_runtime()
53
+ query_lane_acquired = False
54
+ try:
55
+ lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
56
+ self._runtime_kernel.acquire_query_lane(runtime)
57
+ query_lane_acquired = True
58
+ client = self._client_without_readiness(
59
+ runtime,
60
+ lifecycle_lease=lifecycle_lease,
61
+ )
62
+ payload = client.call_result_payload(
63
+ ANDROID_APPS_LIST_METHOD,
64
+ {},
65
+ request_id=DEVICE_RPC_REQUEST_ID_LIST_APPS,
66
+ )
67
+ result = build_list_apps_result(payload)
68
+ _raise_runtime_not_connected_if_stale(runtime, lifecycle_lease)
69
+ return result.model_dump(by_alias=True, mode="json")
70
+ finally:
71
+ if query_lane_acquired:
72
+ self._runtime_kernel.release_progress_lane(runtime)
73
+
74
+ def _client_without_readiness(
75
+ self,
76
+ runtime: WorkspaceRuntime,
77
+ *,
78
+ lifecycle_lease: RuntimeLifecycleLease,
79
+ ) -> ListAppsRpcClient:
80
+ with runtime.lock:
81
+ if not lifecycle_lease.is_current(runtime):
82
+ raise _runtime_not_connected_error(runtime)
83
+ if runtime.connection is None:
84
+ raise _runtime_not_connected_error(runtime)
85
+ token = _require_device_token(runtime)
86
+ if runtime.transport is not None:
87
+ endpoint = runtime.transport.endpoint
88
+ return self._rpc_client_factory(endpoint, token)
89
+
90
+ rebuilt_transport = self._runtime_kernel.rebootstrap_transport(
91
+ runtime,
92
+ bootstrap=self._bootstrapper.bootstrap_rpc_only,
93
+ lease=lifecycle_lease,
94
+ )
95
+
96
+ with runtime.lock:
97
+ if not lifecycle_lease.is_current(runtime):
98
+ raise _runtime_not_connected_error(runtime)
99
+ transport = runtime.transport or rebuilt_transport
100
+ token = _require_device_token(runtime)
101
+ return self._rpc_client_factory(
102
+ transport.endpoint,
103
+ token,
104
+ )
105
+
106
+
107
+ def build_list_apps_result(payload: object) -> ListAppsResult:
108
+ if not isinstance(payload, dict):
109
+ _raise_malformed_payload("result")
110
+ raw_apps = payload.get("apps", _MISSING)
111
+ if not isinstance(raw_apps, list):
112
+ _raise_malformed_payload("result.apps")
113
+
114
+ apps: list[dict[str, str]] = []
115
+ for index, raw_app in enumerate(raw_apps):
116
+ field_prefix = f"result.apps[{index}]"
117
+ if not isinstance(raw_app, dict):
118
+ _raise_malformed_payload(field_prefix)
119
+ package_name = _required_non_empty_string(
120
+ raw_app.get("packageName", _MISSING),
121
+ field=f"{field_prefix}.packageName",
122
+ )
123
+ app_label = _required_non_empty_string(
124
+ raw_app.get("appLabel", _MISSING),
125
+ field=f"{field_prefix}.appLabel",
126
+ )
127
+ if raw_app.get("launchable", _MISSING) is not True:
128
+ _raise_malformed_payload(f"{field_prefix}.launchable")
129
+ apps.append({"packageName": package_name, "appLabel": app_label})
130
+
131
+ try:
132
+ return ListAppsResult.model_validate(
133
+ {"ok": True, "command": "list-apps", "apps": apps},
134
+ strict=True,
135
+ )
136
+ except ValueError as error:
137
+ raise _malformed_payload_error("result") from error
138
+
139
+
140
+ def _required_non_empty_string(value: object, *, field: str) -> str:
141
+ if type(value) is not str:
142
+ _raise_malformed_payload(field)
143
+ normalized = value.strip()
144
+ if not normalized:
145
+ _raise_malformed_payload(field)
146
+ return normalized
147
+
148
+
149
+ def _raise_runtime_not_connected_if_stale(
150
+ runtime: WorkspaceRuntime,
151
+ lifecycle_lease: RuntimeLifecycleLease,
152
+ ) -> None:
153
+ with runtime.lock:
154
+ if lifecycle_lease.is_current(runtime):
155
+ return
156
+ raise _runtime_not_connected_error(
157
+ runtime,
158
+ details={"reason": "runtime_lifecycle_changed"},
159
+ )
160
+
161
+
162
+ def _require_device_token(runtime: WorkspaceRuntime) -> str:
163
+ token = runtime.device_token
164
+ if not token:
165
+ raise _runtime_not_connected_error(runtime)
166
+ return token
167
+
168
+
169
+ def _runtime_not_connected_error(
170
+ runtime: WorkspaceRuntime,
171
+ *,
172
+ details: dict[str, object] | None = None,
173
+ ) -> DaemonError:
174
+ error_details: dict[str, object] = {
175
+ "workspaceRoot": runtime.workspace_root.as_posix()
176
+ }
177
+ if details is not None:
178
+ error_details.update(details)
179
+ return DaemonError(
180
+ code=DaemonErrorCode.RUNTIME_NOT_CONNECTED,
181
+ message="runtime is not connected to a device",
182
+ retryable=False,
183
+ details=error_details,
184
+ http_status=200,
185
+ )
186
+
187
+
188
+ def _raise_malformed_payload(field: str) -> NoReturn:
189
+ raise _malformed_payload_error(field)
190
+
191
+
192
+ def _malformed_payload_error(field: str) -> DaemonError:
193
+ return DaemonError(
194
+ code=DaemonErrorCode.DEVICE_RPC_FAILED,
195
+ message="apps.list returned malformed payload",
196
+ retryable=False,
197
+ details={"field": field, "reason": "invalid_payload"},
198
+ http_status=200,
199
+ )
200
+
201
+
202
+ class _Missing:
203
+ pass
204
+
205
+
206
+ _MISSING = _Missing()
207
+
208
+
209
+ __all__ = [
210
+ "ANDROID_APPS_LIST_METHOD",
211
+ "ListAppsCommandHandler",
212
+ "ListAppsRpcClient",
213
+ "ListAppsRpcClientFactory",
214
+ "build_list_apps_result",
215
+ ]
@@ -0,0 +1,121 @@
1
+ """Semantic observe command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctl_contracts.vocabulary import SemanticResultCode
6
+ from androidctld.commands.command_models import ObserveCommand
7
+ from androidctld.commands.result_models import (
8
+ build_semantic_failure_result,
9
+ build_semantic_success_result,
10
+ )
11
+ from androidctld.commands.semantic_error_mapping import (
12
+ map_daemon_error_to_semantic_failure,
13
+ )
14
+ from androidctld.commands.semantic_truth import resolve_screen_continuity
15
+ from androidctld.errors import DaemonError
16
+ from androidctld.runtime import RuntimeKernel
17
+ from androidctld.runtime.screen_state import (
18
+ current_compiled_screen,
19
+ get_authoritative_current_basis,
20
+ )
21
+ from androidctld.snapshots.refresh import ScreenRefreshService
22
+ from androidctld.snapshots.service import SnapshotService
23
+
24
+
25
+ class ObserveCommandHandler:
26
+ def __init__(
27
+ self,
28
+ *,
29
+ runtime_kernel: RuntimeKernel,
30
+ snapshot_service: SnapshotService,
31
+ screen_refresh: ScreenRefreshService,
32
+ ) -> None:
33
+ self._runtime_kernel = runtime_kernel
34
+ self._snapshot_service = snapshot_service
35
+ self._screen_refresh = screen_refresh
36
+
37
+ def handle(
38
+ self,
39
+ *,
40
+ command: ObserveCommand,
41
+ ) -> dict[str, object]:
42
+ runtime = self._runtime_kernel.ensure_runtime()
43
+ source_basis = get_authoritative_current_basis(runtime)
44
+ if runtime.connection is None or runtime.device_token is None:
45
+ return build_semantic_failure_result(
46
+ command="observe",
47
+ category="observe",
48
+ code=SemanticResultCode.DEVICE_UNAVAILABLE,
49
+ message="No current device observation is available.",
50
+ source_screen_id=None,
51
+ current_screen=(
52
+ None if source_basis is None else source_basis.public_screen
53
+ ),
54
+ artifacts=None if source_basis is None else source_basis.artifacts,
55
+ ).model_dump(by_alias=True, mode="json")
56
+ lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
57
+ force_refresh = source_basis is None
58
+ self._runtime_kernel.acquire_progress_lane(
59
+ runtime,
60
+ occupant_kind=command.kind.value,
61
+ )
62
+ try:
63
+ snapshot = self._snapshot_service.fetch(
64
+ runtime,
65
+ force_refresh=force_refresh,
66
+ lifecycle_lease=lifecycle_lease,
67
+ )
68
+ previous_screen = (
69
+ None if source_basis is None else source_basis.public_screen
70
+ )
71
+ previous_compiled = (
72
+ None if source_basis is None else source_basis.compiled_screen
73
+ )
74
+ snapshot, public_screen, artifacts = self._screen_refresh.refresh(
75
+ runtime,
76
+ snapshot,
77
+ lifecycle_lease=lifecycle_lease,
78
+ command_kind=command.kind,
79
+ )
80
+ source_screen_id = (
81
+ None if previous_screen is None else previous_screen.screen_id
82
+ )
83
+ continuity = resolve_screen_continuity(
84
+ source_screen_id=source_screen_id,
85
+ source_compiled_screen=previous_compiled,
86
+ current_screen=public_screen,
87
+ candidate_compiled_screen=current_compiled_screen(
88
+ runtime,
89
+ copy_value=False,
90
+ ),
91
+ )
92
+ return build_semantic_success_result(
93
+ command="observe",
94
+ category="observe",
95
+ source_screen_id=source_screen_id,
96
+ next_screen=public_screen,
97
+ artifacts=artifacts,
98
+ continuity_status=continuity.continuity_status,
99
+ execution_outcome="notApplicable",
100
+ changed=continuity.changed,
101
+ ).model_dump(by_alias=True, mode="json")
102
+ except DaemonError as error:
103
+ mapped = map_daemon_error_to_semantic_failure(
104
+ command_name="observe",
105
+ error=error,
106
+ )
107
+ if mapped is None:
108
+ raise
109
+ return build_semantic_failure_result(
110
+ command="observe",
111
+ category="observe",
112
+ code=mapped.code,
113
+ message=mapped.message,
114
+ source_screen_id=None,
115
+ current_screen=(
116
+ None if source_basis is None else source_basis.public_screen
117
+ ),
118
+ artifacts=None if source_basis is None else source_basis.artifacts,
119
+ ).model_dump(by_alias=True, mode="json")
120
+ finally:
121
+ self._runtime_kernel.release_progress_lane(runtime)
@@ -0,0 +1,105 @@
1
+ """Thin screenshot command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import suppress
6
+
7
+ from androidctld.actions.capabilities import ensure_command_supported
8
+ from androidctld.artifacts.writer import ArtifactWriter
9
+ from androidctld.commands.command_models import ScreenshotCommand
10
+ from androidctld.commands.result_models import (
11
+ build_projected_retained_failure_result_for_error,
12
+ build_retained_success_result,
13
+ )
14
+ from androidctld.device.interfaces import DeviceClientFactory
15
+ from androidctld.device.parsing import (
16
+ decode_screenshot_body_base64,
17
+ validate_screenshot_png_bytes,
18
+ )
19
+ from androidctld.errors import DaemonError
20
+ from androidctld.runtime import RuntimeKernel
21
+ from androidctld.runtime_policy import DEVICE_RPC_REQUEST_ID_SCREENSHOT
22
+
23
+
24
+ class ScreenshotCommandHandler:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ runtime_kernel: RuntimeKernel,
29
+ device_client_factory: DeviceClientFactory,
30
+ artifact_writer: ArtifactWriter,
31
+ ) -> None:
32
+ self._runtime_kernel = runtime_kernel
33
+ self._device_client_factory = device_client_factory
34
+ self._artifact_writer = artifact_writer
35
+
36
+ def handle(
37
+ self,
38
+ *,
39
+ command: ScreenshotCommand,
40
+ ) -> dict[str, object]:
41
+ runtime = self._runtime_kernel.ensure_runtime()
42
+ query_lane_acquired = False
43
+ try:
44
+ lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
45
+ self._runtime_kernel.acquire_query_lane(runtime)
46
+ query_lane_acquired = True
47
+ client = None
48
+ if (
49
+ runtime.connection is not None
50
+ and runtime.device_token is not None
51
+ and runtime.transport is None
52
+ ):
53
+ client = self._device_client_factory(
54
+ runtime,
55
+ lifecycle_lease=lifecycle_lease,
56
+ )
57
+ ensure_command_supported(runtime, command)
58
+ if client is None:
59
+ client = self._device_client_factory(
60
+ runtime,
61
+ lifecycle_lease=lifecycle_lease,
62
+ )
63
+ payload = client.screenshot_capture(
64
+ request_id=DEVICE_RPC_REQUEST_ID_SCREENSHOT,
65
+ )
66
+ with runtime.lock:
67
+ if not lifecycle_lease.is_current(runtime):
68
+ raise RuntimeError("runtime lifecycle changed during screenshot")
69
+ decoded_body = decode_screenshot_body_base64(
70
+ payload.body_base64,
71
+ field_name="result.bodyBase64",
72
+ )
73
+ validate_screenshot_png_bytes(
74
+ decoded_body,
75
+ field_name="result.bodyBase64",
76
+ expected_width_px=payload.width_px,
77
+ expected_height_px=payload.height_px,
78
+ )
79
+ output_path = self._artifact_writer.write_screenshot_png(
80
+ runtime,
81
+ decoded_body,
82
+ )
83
+ attachment = self._runtime_kernel.attach_screenshot_artifact(
84
+ runtime,
85
+ lifecycle_lease,
86
+ screenshot_png=output_path.as_posix(),
87
+ )
88
+ if attachment is None:
89
+ with suppress(OSError):
90
+ output_path.unlink()
91
+ raise RuntimeError("runtime lifecycle changed during screenshot")
92
+ artifacts = attachment.artifacts
93
+ return build_retained_success_result(
94
+ command="screenshot",
95
+ artifacts=artifacts,
96
+ ).model_dump(by_alias=True, mode="json")
97
+ except DaemonError as error:
98
+ return build_projected_retained_failure_result_for_error(
99
+ command="screenshot",
100
+ error=error,
101
+ artifacts=None,
102
+ ).model_dump(by_alias=True, mode="json")
103
+ finally:
104
+ if query_lane_acquired:
105
+ self._runtime_kernel.release_progress_lane(runtime)