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,26 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.commands import run_pipeline
8
+ from androidctl.commands.plumbing import build_and_run_command
9
+ from androidctl_contracts.daemon_api import ObserveCommandPayload
10
+
11
+
12
+ def register(app: typer.Typer) -> None:
13
+ @app.command("observe")
14
+ def observe(
15
+ ctx: typer.Context,
16
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
17
+ ) -> None:
18
+ build_and_run_command(
19
+ ctx=ctx,
20
+ workspace_root=workspace_root,
21
+ build_request=lambda options: run_pipeline.CliCommandRequest(
22
+ public_command="observe",
23
+ command=ObserveCommandPayload(kind="observe"),
24
+ workspace_root=options.workspace_root,
25
+ ),
26
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.cli_options import CliOptions
8
+ from androidctl.commands import run_pipeline
9
+ from androidctl.commands.execute import emit_usage_error
10
+ from androidctl.commands.plumbing import build_and_run_command
11
+ from androidctl.parsing.open_target import parse_open_target
12
+ from androidctl_contracts.daemon_api import OpenCommandPayload
13
+
14
+
15
+ def register(app: typer.Typer) -> None:
16
+ @app.command("open")
17
+ def open_target(
18
+ ctx: typer.Context,
19
+ target: str = typer.Argument(
20
+ ...,
21
+ help="Target app:..., url:<target>, or bare http(s)://...",
22
+ ),
23
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
24
+ ) -> None:
25
+ def build_request(options: CliOptions) -> run_pipeline.CliCommandRequest:
26
+ try:
27
+ parsed_target = parse_open_target(target)
28
+ except ValueError as error:
29
+ emit_usage_error(str(error))
30
+
31
+ return run_pipeline.CliCommandRequest(
32
+ public_command="open",
33
+ command=OpenCommandPayload(kind="open", target=parsed_target),
34
+ workspace_root=options.workspace_root,
35
+ )
36
+
37
+ build_and_run_command(
38
+ ctx=ctx,
39
+ workspace_root=workspace_root,
40
+ build_request=build_request,
41
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from pydantic import ValidationError
8
+
9
+ from androidctl.cli_options import CliOptions, command_cli_options
10
+ from androidctl.commands import run_pipeline
11
+ from androidctl.commands.execute import emit_usage_error, run_and_render
12
+
13
+ RequestBuilder = Callable[[CliOptions], run_pipeline.CliCommandRequest]
14
+
15
+
16
+ def build_and_run_command(
17
+ *,
18
+ ctx: typer.Context,
19
+ workspace_root: Path | None,
20
+ build_request: RequestBuilder,
21
+ public_command: str | None = None,
22
+ ) -> None:
23
+ options = command_cli_options(
24
+ ctx,
25
+ workspace_root=workspace_root,
26
+ )
27
+ try:
28
+ request = build_request(options)
29
+ except ValidationError as error:
30
+ emit_usage_error(
31
+ _validation_error_message(error),
32
+ command=public_command or _context_command_name(ctx),
33
+ )
34
+ run_and_render(
35
+ request,
36
+ public_command=public_command or _context_command_name(ctx),
37
+ )
38
+
39
+
40
+ def _context_command_name(ctx: typer.Context) -> str | None:
41
+ command_name = ctx.info_name
42
+ if isinstance(command_name, str) and command_name:
43
+ return command_name
44
+ return None
45
+
46
+
47
+ def _validation_error_message(error: ValidationError) -> str:
48
+ details = error.errors(include_url=False)
49
+ if not details:
50
+ return str(error)
51
+ first = details[0]
52
+ location = ".".join(
53
+ str(part) for part in first.get("loc", ()) if part != "__root__"
54
+ ).strip(".")
55
+ message = str(first.get("msg", "invalid input"))
56
+ if not location:
57
+ return message
58
+ return f"{location}: {message}"
@@ -0,0 +1,307 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import NoReturn, Protocol
8
+
9
+ import click
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from androidctl.command_payloads import (
14
+ CliCommandPayload,
15
+ LateBoundActionCommand,
16
+ LateBoundGlobalActionCommand,
17
+ LateBoundWaitCommand,
18
+ )
19
+ from androidctl.daemon.client import (
20
+ DaemonApiError,
21
+ DaemonProtocolError,
22
+ IncompatibleDaemonError,
23
+ )
24
+ from androidctl.daemon.discovery import (
25
+ discover_existing_daemon_client,
26
+ resolve_daemon_client,
27
+ )
28
+ from androidctl.errors.models import ErrorTier
29
+ from androidctl.workspace.resolve import resolve_workspace_root
30
+ from androidctl_contracts.command_catalog import runtime_close_entry
31
+ from androidctl_contracts.command_results import (
32
+ CommandResultCore,
33
+ ListAppsResult,
34
+ RetainedResultEnvelope,
35
+ dump_canonical_command_result,
36
+ )
37
+ from androidctl_contracts.daemon_api import (
38
+ CommandRunRequest,
39
+ RuntimePayload,
40
+ )
41
+
42
+ CommandResultPayload = CommandResultCore | RetainedResultEnvelope | ListAppsResult
43
+
44
+
45
+ class RuntimeCommandClient(Protocol):
46
+ def get_runtime(self) -> RuntimePayload: ...
47
+
48
+ def run_command(
49
+ self,
50
+ *,
51
+ request: CommandRunRequest,
52
+ ) -> CommandResultPayload: ...
53
+
54
+ def close_runtime(self) -> RetainedResultEnvelope: ...
55
+
56
+
57
+ class PreDispatchCommandError(Exception):
58
+ def __init__(
59
+ self,
60
+ cause: Exception,
61
+ *,
62
+ execution_outcome: str | None = None,
63
+ error_tier: ErrorTier | None = None,
64
+ ) -> None:
65
+ super().__init__(str(cause))
66
+ self.cause = cause
67
+ self.execution_outcome = execution_outcome
68
+ self.error_tier = error_tier
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class CliCommandRequest:
73
+ public_command: str
74
+ command: CliCommandPayload
75
+ workspace_root: Path | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class CommandOutcome:
80
+ payload: dict[str, object]
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class AppContext:
85
+ daemon: RuntimeCommandClient | None
86
+ cwd: Path
87
+ env: Mapping[str, str]
88
+ daemon_discovery: Callable[[Path], RuntimeCommandClient] | None = None
89
+
90
+
91
+ def build_context() -> AppContext:
92
+ return AppContext(
93
+ daemon=None,
94
+ cwd=Path.cwd(),
95
+ env=os.environ,
96
+ daemon_discovery=lambda workspace_root: resolve_daemon_client(
97
+ workspace_root=workspace_root,
98
+ cwd=Path.cwd(),
99
+ env=os.environ,
100
+ ),
101
+ )
102
+
103
+
104
+ def run_command(cli_request: CliCommandRequest, ctx: AppContext) -> CommandOutcome:
105
+ try:
106
+ workspace_root = resolve_runtime_paths(cli_request.workspace_root, ctx)
107
+ except (OSError, ValueError) as error:
108
+ _raise_pre_dispatch_error(error, cli_request.command)
109
+
110
+ try:
111
+ daemon = _resolve_command_daemon(ctx, workspace_root)
112
+ except (DaemonApiError, click.ClickException, OSError, ValidationError) as error:
113
+ _raise_pre_dispatch_error(error, cli_request.command)
114
+
115
+ try:
116
+ runtime_payload = daemon.get_runtime()
117
+ except (
118
+ DaemonApiError,
119
+ DaemonProtocolError,
120
+ OSError,
121
+ ValidationError,
122
+ httpx.HTTPStatusError,
123
+ httpx.RequestError,
124
+ ) as error:
125
+ _raise_pre_dispatch_error(error, cli_request.command)
126
+
127
+ try:
128
+ prepared_request = _prepare_ref_bound_request(cli_request, runtime_payload)
129
+ except DaemonApiError as error:
130
+ _raise_pre_dispatch_error(
131
+ error,
132
+ cli_request.command,
133
+ error_tier=_late_bind_error_tier(error),
134
+ )
135
+
136
+ try:
137
+ command_request = _build_command_run_request(prepared_request.command)
138
+ except ValidationError as error:
139
+ _raise_pre_dispatch_error(error, cli_request.command)
140
+
141
+ result = daemon.run_command(request=command_request)
142
+ return CommandOutcome(payload=_dump_command_result(result))
143
+
144
+
145
+ def run_close_command(
146
+ ctx: AppContext,
147
+ workspace_root_override: Path | None,
148
+ ) -> CommandOutcome:
149
+ workspace_root = resolve_runtime_paths(workspace_root_override, ctx)
150
+ daemon = ctx.daemon
151
+ if daemon is None:
152
+ daemon = discover_existing_daemon_client(
153
+ workspace_root=workspace_root,
154
+ env=ctx.env,
155
+ )
156
+ result = _close_result(daemon)
157
+ return CommandOutcome(payload=_dump_command_result(result))
158
+
159
+
160
+ def resolve_runtime_paths(
161
+ workspace_root_override: Path | None,
162
+ ctx: AppContext,
163
+ ) -> Path:
164
+ return resolve_workspace_root(
165
+ flag_value=workspace_root_override,
166
+ env_value=ctx.env.get("ANDROIDCTL_WORKSPACE_ROOT"),
167
+ cwd=ctx.cwd,
168
+ )
169
+
170
+
171
+ def _prepare_ref_bound_request(
172
+ cli_request: CliCommandRequest,
173
+ runtime_payload: RuntimePayload,
174
+ ) -> CliCommandRequest:
175
+ bound_command = bind_screen_relative_command(cli_request.command, runtime_payload)
176
+ if bound_command is cli_request.command:
177
+ return cli_request
178
+ return CliCommandRequest(
179
+ public_command=cli_request.public_command,
180
+ command=bound_command,
181
+ workspace_root=cli_request.workspace_root,
182
+ )
183
+
184
+
185
+ def bind_screen_relative_command(
186
+ command: CliCommandPayload,
187
+ runtime_payload: RuntimePayload,
188
+ ) -> CliCommandPayload:
189
+ if not isinstance(
190
+ command,
191
+ (
192
+ LateBoundActionCommand,
193
+ LateBoundGlobalActionCommand,
194
+ LateBoundWaitCommand,
195
+ ),
196
+ ):
197
+ return command
198
+
199
+ if isinstance(command, LateBoundGlobalActionCommand):
200
+ return command.bind(_live_screen_id(runtime_payload))
201
+
202
+ return command.bind(_required_live_screen_id(runtime_payload))
203
+
204
+
205
+ def _required_live_screen_id(runtime_payload: RuntimePayload) -> str:
206
+ live_screen_id = _live_screen_id(runtime_payload)
207
+ if live_screen_id is not None:
208
+ return live_screen_id
209
+ status = runtime_payload.status.strip().lower()
210
+ if status in {"ready", "connected"}:
211
+ raise DaemonApiError(
212
+ code="SCREEN_NOT_READY",
213
+ message="screen is not ready yet",
214
+ details={},
215
+ )
216
+ raise DaemonApiError(
217
+ code="RUNTIME_NOT_CONNECTED",
218
+ message="runtime is not connected to a device",
219
+ details={},
220
+ )
221
+
222
+
223
+ def _live_screen_id(runtime_payload: RuntimePayload) -> str | None:
224
+ status = runtime_payload.status.strip().lower()
225
+ current_screen_id = runtime_payload.current_screen_id
226
+ if status != "ready" or not isinstance(current_screen_id, str):
227
+ return None
228
+ normalized_screen_id = current_screen_id.strip()
229
+ return normalized_screen_id or None
230
+
231
+
232
+ def _resolve_command_daemon(
233
+ ctx: AppContext,
234
+ workspace_root: Path,
235
+ ) -> RuntimeCommandClient:
236
+ if ctx.daemon is not None:
237
+ return ctx.daemon
238
+ if ctx.daemon_discovery is None:
239
+ raise click.ClickException("unable to start or discover androidctld daemon")
240
+ try:
241
+ return ctx.daemon_discovery(workspace_root)
242
+ except (DaemonApiError, IncompatibleDaemonError):
243
+ raise
244
+ except (FileNotFoundError, OSError, RuntimeError, ValidationError) as error:
245
+ raise click.ClickException(
246
+ f"unable to start or discover androidctld daemon: {error}"
247
+ ) from error
248
+
249
+
250
+ def _build_command_run_request(command: CliCommandPayload) -> CommandRunRequest:
251
+ if isinstance(
252
+ command,
253
+ (
254
+ LateBoundActionCommand,
255
+ LateBoundGlobalActionCommand,
256
+ LateBoundWaitCommand,
257
+ ),
258
+ ):
259
+ raise RuntimeError("prepared command was not bound to a live screen")
260
+ return CommandRunRequest.model_validate(
261
+ {"command": command.model_dump(exclude_none=True, exclude_defaults=True)}
262
+ )
263
+
264
+
265
+ def _raise_pre_dispatch_error(
266
+ error: Exception,
267
+ command: CliCommandPayload,
268
+ *,
269
+ error_tier: ErrorTier | None = None,
270
+ ) -> NoReturn:
271
+ del command
272
+ raise PreDispatchCommandError(
273
+ error,
274
+ execution_outcome=None,
275
+ error_tier=error_tier,
276
+ ) from error
277
+
278
+
279
+ def _late_bind_error_tier(error: DaemonApiError) -> ErrorTier | None:
280
+ if error.code in {"RUNTIME_NOT_CONNECTED", "SCREEN_NOT_READY"}:
281
+ return "preDispatch"
282
+ return None
283
+
284
+
285
+ def _dump_command_result(result: CommandResultPayload) -> dict[str, object]:
286
+ if isinstance(result, CommandResultCore):
287
+ return dump_canonical_command_result(result)
288
+ return result.model_dump(by_alias=True, mode="json", exclude_none=True)
289
+
290
+
291
+ def _close_result(
292
+ daemon: RuntimeCommandClient | None,
293
+ ) -> RetainedResultEnvelope:
294
+ if daemon is not None:
295
+ return daemon.close_runtime()
296
+ close_entry = runtime_close_entry()
297
+ if close_entry.retained_envelope_kind is None:
298
+ raise RuntimeError("runtime close command must use a retained envelope")
299
+ return RetainedResultEnvelope.model_validate(
300
+ {
301
+ "ok": True,
302
+ "command": close_entry.result_command,
303
+ "envelope": close_entry.retained_envelope_kind.value,
304
+ "artifacts": {},
305
+ "details": {},
306
+ }
307
+ )
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.commands import run_pipeline
8
+ from androidctl.commands.plumbing import build_and_run_command
9
+ from androidctl_contracts.daemon_api import ScreenshotCommandPayload
10
+
11
+
12
+ def register(app: typer.Typer) -> None:
13
+ @app.command(
14
+ "screenshot",
15
+ help="Retained support route that captures an explicit screenshot artifact.",
16
+ )
17
+ def screenshot(
18
+ ctx: typer.Context,
19
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
20
+ ) -> None:
21
+ build_and_run_command(
22
+ ctx=ctx,
23
+ workspace_root=workspace_root,
24
+ build_request=lambda options: run_pipeline.CliCommandRequest(
25
+ public_command="screenshot",
26
+ command=ScreenshotCommandPayload(kind="screenshot"),
27
+ workspace_root=options.workspace_root,
28
+ ),
29
+ )