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,241 @@
1
+ """Read-only source screen artifact lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from androidctld.artifacts.screen_payloads import ScreenArtifactPayload
13
+ from androidctld.runtime.models import WorkspaceRuntime
14
+ from androidctld.runtime.screen_state import current_artifacts
15
+
16
+ ArtifactScanStatus = Literal["valid", "malformed", "unreadable"]
17
+ SourceScreenArtifactStatus = Literal["found", "not_found", "invalid_artifact"]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ScannedArtifactPath:
22
+ path: Path
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ScreenArtifactScan:
27
+ path: Path
28
+ status: ArtifactScanStatus
29
+ screen_id: str | None
30
+ sequence: int | None
31
+ payload: ScreenArtifactPayload | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ScannedScreenArtifact:
36
+ candidate: ScannedArtifactPath
37
+ artifact: ScreenArtifactScan
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SourceScreenArtifactLookup:
42
+ status: SourceScreenArtifactStatus
43
+ source_screen_id: str
44
+ path: Path | None = None
45
+ payload: ScreenArtifactPayload | None = None
46
+ scanned: tuple[ScannedScreenArtifact, ...] = ()
47
+
48
+
49
+ def lookup_source_screen_artifact(
50
+ session: WorkspaceRuntime,
51
+ source_screen_id: str,
52
+ ) -> SourceScreenArtifactLookup:
53
+ """Find the newest source artifact without mutating artifact storage."""
54
+
55
+ matching_candidates: list[ScreenArtifactScan] = []
56
+ unknown_screen_candidates: list[ScreenArtifactScan] = []
57
+ scanned_candidates: list[ScannedScreenArtifact] = []
58
+ for candidate in screen_artifact_candidates(session):
59
+ if not candidate.path.exists():
60
+ continue
61
+ artifact = scan_screen_artifact(candidate.path)
62
+ scanned_candidates.append(
63
+ ScannedScreenArtifact(candidate=candidate, artifact=artifact)
64
+ )
65
+ if artifact.screen_id == source_screen_id:
66
+ matching_candidates.append(artifact)
67
+ continue
68
+ if artifact.screen_id is None and artifact.status != "valid":
69
+ unknown_screen_candidates.append(artifact)
70
+ continue
71
+
72
+ scanned = tuple(scanned_candidates)
73
+ if not matching_candidates:
74
+ return SourceScreenArtifactLookup(
75
+ status="not_found",
76
+ source_screen_id=source_screen_id,
77
+ scanned=scanned,
78
+ )
79
+
80
+ unknown_sequence_invalid = [
81
+ artifact for artifact in unknown_screen_candidates if artifact.sequence is None
82
+ ]
83
+ if unknown_sequence_invalid:
84
+ selected = sorted(
85
+ unknown_sequence_invalid,
86
+ key=lambda artifact: artifact.path.name,
87
+ reverse=True,
88
+ )[0]
89
+ return SourceScreenArtifactLookup(
90
+ status="invalid_artifact",
91
+ source_screen_id=source_screen_id,
92
+ path=selected.path,
93
+ scanned=scanned,
94
+ )
95
+
96
+ matching_candidates.sort(
97
+ key=lambda artifact: (
98
+ -1 if artifact.sequence is None else artifact.sequence,
99
+ artifact.path.name,
100
+ ),
101
+ reverse=True,
102
+ )
103
+ selected_match = matching_candidates[0]
104
+
105
+ if unknown_screen_candidates:
106
+ unknown_screen_candidates.sort(
107
+ key=lambda artifact: (
108
+ -1 if artifact.sequence is None else artifact.sequence,
109
+ artifact.path.name,
110
+ ),
111
+ reverse=True,
112
+ )
113
+ selected_unknown = unknown_screen_candidates[0]
114
+ else:
115
+ selected_unknown = None
116
+
117
+ if selected_unknown is not None:
118
+ unknown_sequence = selected_unknown.sequence
119
+ match_sequence = selected_match.sequence
120
+ if (
121
+ unknown_sequence is None
122
+ or match_sequence is None
123
+ or unknown_sequence >= match_sequence
124
+ ):
125
+ return SourceScreenArtifactLookup(
126
+ status="invalid_artifact",
127
+ source_screen_id=source_screen_id,
128
+ path=selected_unknown.path,
129
+ scanned=scanned,
130
+ )
131
+
132
+ selected = selected_match
133
+ if selected.status != "valid":
134
+ return SourceScreenArtifactLookup(
135
+ status="invalid_artifact",
136
+ source_screen_id=source_screen_id,
137
+ path=selected.path,
138
+ scanned=scanned,
139
+ )
140
+ if selected.payload is None:
141
+ raise RuntimeError("valid source screen artifact is missing payload")
142
+ return SourceScreenArtifactLookup(
143
+ status="found",
144
+ source_screen_id=source_screen_id,
145
+ path=selected.path,
146
+ payload=selected.payload,
147
+ scanned=scanned,
148
+ )
149
+
150
+
151
+ def screen_artifact_candidates(
152
+ session: WorkspaceRuntime,
153
+ ) -> list[ScannedArtifactPath]:
154
+ candidates: dict[Path, ScannedArtifactPath] = {}
155
+
156
+ def add_candidate(path: Path) -> None:
157
+ resolved = path.resolve()
158
+ candidates.setdefault(resolved, ScannedArtifactPath(path=path))
159
+
160
+ current_artifact_bag = current_artifacts(session)
161
+ current_artifact = (
162
+ None if current_artifact_bag is None else current_artifact_bag.screen_json
163
+ )
164
+ if current_artifact is not None:
165
+ current_artifact_path = Path(current_artifact)
166
+ if is_internal_screen_artifact_path(current_artifact_path):
167
+ add_candidate(current_artifact_path)
168
+
169
+ screens_dir = session.artifact_root / "screens"
170
+ if screens_dir.exists():
171
+ for path in sorted(screens_dir.glob("obs-*.json")):
172
+ add_candidate(path)
173
+
174
+ return list(candidates.values())
175
+
176
+
177
+ def scan_screen_artifact(path: Path) -> ScreenArtifactScan:
178
+ try:
179
+ raw_payload = json.loads(path.read_text(encoding="utf-8"))
180
+ except OSError:
181
+ return ScreenArtifactScan(
182
+ path=path,
183
+ status="unreadable",
184
+ screen_id=None,
185
+ sequence=sequence_from_artifact_path(path),
186
+ )
187
+ except ValueError:
188
+ return ScreenArtifactScan(
189
+ path=path,
190
+ status="malformed",
191
+ screen_id=None,
192
+ sequence=sequence_from_artifact_path(path),
193
+ )
194
+
195
+ if not isinstance(raw_payload, dict):
196
+ return ScreenArtifactScan(
197
+ path=path,
198
+ status="malformed",
199
+ screen_id=None,
200
+ sequence=sequence_from_artifact_path(path),
201
+ )
202
+
203
+ screen_id = raw_payload.get("screenId")
204
+ screen_id_hint = screen_id if isinstance(screen_id, str) else None
205
+ sequence = raw_payload.get("sequence")
206
+ sequence_hint = (
207
+ sequence
208
+ if isinstance(sequence, int) and not isinstance(sequence, bool)
209
+ else sequence_from_artifact_path(path)
210
+ )
211
+ try:
212
+ payload = ScreenArtifactPayload.model_validate_json(json.dumps(raw_payload))
213
+ except ValidationError:
214
+ return ScreenArtifactScan(
215
+ path=path,
216
+ status="malformed",
217
+ screen_id=screen_id_hint,
218
+ sequence=sequence_hint,
219
+ )
220
+
221
+ return ScreenArtifactScan(
222
+ path=path,
223
+ status="valid",
224
+ screen_id=payload.screen_id,
225
+ sequence=payload.sequence,
226
+ payload=payload,
227
+ )
228
+
229
+
230
+ def sequence_from_artifact_path(path: Path) -> int | None:
231
+ name = path.name
232
+ if not name.startswith("obs-") or not name.endswith(".json"):
233
+ return None
234
+ digits = name[4:-5]
235
+ if not digits.isdigit():
236
+ return None
237
+ return int(digits)
238
+
239
+
240
+ def is_internal_screen_artifact_path(path: Path) -> bool:
241
+ return sequence_from_artifact_path(path) is not None
@@ -0,0 +1,109 @@
1
+ """Payload builders for persisted screen artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import ConfigDict, Field
8
+
9
+ from androidctld.refs.models import (
10
+ RefBinding,
11
+ RefFingerprint,
12
+ RefRegistry,
13
+ SemanticProfile,
14
+ )
15
+ from androidctld.schema import ApiModel
16
+ from androidctld.semantics.public_models import PublicGroup, PublicScreen
17
+
18
+
19
+ class RepairFingerprintPayload(ApiModel):
20
+ role: str
21
+ normalized_label: str
22
+ resource_id: str
23
+ class_name: str
24
+ parent_role: str
25
+ parent_label: str
26
+ sibling_labels: tuple[str, ...] = Field(default_factory=tuple)
27
+ relative_bounds: tuple[int, int, int, int]
28
+
29
+
30
+ class RepairSemanticProfilePayload(ApiModel):
31
+ state: tuple[str, ...] = Field(default_factory=tuple)
32
+ actions: tuple[str, ...] = Field(default_factory=tuple)
33
+
34
+
35
+ class RepairBindingPayload(ApiModel):
36
+ fingerprint: RepairFingerprintPayload
37
+ semantic_profile: RepairSemanticProfilePayload
38
+
39
+
40
+ class ScreenArtifactPayload(ApiModel):
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+ screen_id: str
44
+ sequence: int
45
+ source_snapshot_id: int
46
+ captured_at: str
47
+ package_name: str | None
48
+ activity_name: str | None
49
+ keyboard_visible: bool
50
+ groups: tuple[PublicGroup, ...]
51
+ repair_bindings: dict[str, RepairBindingPayload] = Field(default_factory=dict)
52
+
53
+
54
+ def build_repair_fingerprint_payload(
55
+ fingerprint: RefFingerprint,
56
+ ) -> RepairFingerprintPayload:
57
+ return RepairFingerprintPayload(
58
+ role=fingerprint.role,
59
+ normalized_label=fingerprint.normalized_label,
60
+ resource_id=fingerprint.resource_id,
61
+ class_name=fingerprint.class_name,
62
+ parent_role=fingerprint.parent_role,
63
+ parent_label=fingerprint.parent_label,
64
+ sibling_labels=fingerprint.sibling_labels,
65
+ relative_bounds=fingerprint.relative_bounds,
66
+ )
67
+
68
+
69
+ def build_repair_semantic_profile_payload(
70
+ semantic_profile: SemanticProfile,
71
+ ) -> RepairSemanticProfilePayload:
72
+ return RepairSemanticProfilePayload(
73
+ state=semantic_profile.state,
74
+ actions=semantic_profile.actions,
75
+ )
76
+
77
+
78
+ def build_repair_binding_payload(binding: RefBinding) -> RepairBindingPayload:
79
+ return RepairBindingPayload(
80
+ fingerprint=build_repair_fingerprint_payload(binding.fingerprint),
81
+ semantic_profile=build_repair_semantic_profile_payload(
82
+ binding.semantic_profile
83
+ ),
84
+ )
85
+
86
+
87
+ def build_screen_artifact_payload(
88
+ public_screen: PublicScreen,
89
+ ref_registry: RefRegistry,
90
+ *,
91
+ sequence: int,
92
+ source_snapshot_id: int,
93
+ captured_at: str,
94
+ ) -> dict[str, Any]:
95
+ payload = ScreenArtifactPayload(
96
+ screen_id=public_screen.screen_id,
97
+ sequence=sequence,
98
+ source_snapshot_id=source_snapshot_id,
99
+ captured_at=captured_at,
100
+ package_name=public_screen.app.package_name,
101
+ activity_name=public_screen.app.activity_name,
102
+ keyboard_visible=public_screen.surface.keyboard_visible,
103
+ groups=public_screen.groups,
104
+ repair_bindings={
105
+ ref: build_repair_binding_payload(binding)
106
+ for ref, binding in ref_registry.bindings.items()
107
+ },
108
+ )
109
+ return payload.model_dump(by_alias=True, mode="json")
@@ -0,0 +1,286 @@
1
+ """Screen artifact rendering and persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import uuid
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from androidctld.artifacts.models import ScreenArtifacts
13
+ from androidctld.artifacts.screen_payloads import build_screen_artifact_payload
14
+ from androidctld.errors import DaemonError, DaemonErrorCode
15
+ from androidctld.refs.models import RefRegistry
16
+ from androidctld.rendering.screen_xml import render_screen_xml
17
+ from androidctld.runtime.models import WorkspaceRuntime
18
+ from androidctld.runtime_policy import SCREENSHOT_MAX_BINARY_BYTES
19
+ from androidctld.semantics.public_models import PublicScreen
20
+
21
+
22
+ @dataclass
23
+ class StagedArtifactWrite:
24
+ artifacts: ScreenArtifacts
25
+ file_updates: tuple[StagedFileUpdate, ...] = ()
26
+
27
+ def commit(self) -> None:
28
+ for update in self.file_updates:
29
+ update.commit()
30
+
31
+ def discard(self) -> None:
32
+ for update in self.file_updates:
33
+ update.discard()
34
+
35
+ def rollback(self) -> None:
36
+ for update in reversed(self.file_updates):
37
+ update.rollback()
38
+
39
+
40
+ @dataclass
41
+ class StagedFileUpdate:
42
+ staged_path: Path
43
+ final_path: Path
44
+ backup_path: Path | None = None
45
+ original_backed_up: bool = False
46
+ final_replaced: bool = False
47
+
48
+ def commit(self) -> None:
49
+ self.final_path.parent.mkdir(parents=True, exist_ok=True)
50
+ if self.backup_path is not None:
51
+ try:
52
+ self.final_path.replace(self.backup_path)
53
+ except FileNotFoundError:
54
+ pass
55
+ else:
56
+ self.original_backed_up = True
57
+ try:
58
+ self.staged_path.replace(self.final_path)
59
+ except Exception:
60
+ if self.original_backed_up and self.backup_path is not None:
61
+ self.backup_path.replace(self.final_path)
62
+ self.original_backed_up = False
63
+ raise
64
+ self.final_replaced = True
65
+
66
+ def rollback(self) -> None:
67
+ if not self.final_replaced:
68
+ return
69
+ if self.original_backed_up and self.backup_path is not None:
70
+ with suppress(FileNotFoundError):
71
+ self.final_path.unlink()
72
+ self.backup_path.replace(self.final_path)
73
+ self.original_backed_up = False
74
+ self.final_replaced = False
75
+ return
76
+ with suppress(FileNotFoundError):
77
+ self.final_path.unlink()
78
+ self.final_replaced = False
79
+
80
+ def discard(self) -> None:
81
+ for path in (self.staged_path, self.backup_path):
82
+ if path is None:
83
+ continue
84
+ try:
85
+ path.unlink()
86
+ except FileNotFoundError:
87
+ continue
88
+
89
+
90
+ class ArtifactWriter:
91
+ def stage_screen(
92
+ self,
93
+ session: WorkspaceRuntime,
94
+ public_screen: PublicScreen,
95
+ *,
96
+ sequence: int,
97
+ source_snapshot_id: int,
98
+ captured_at: str,
99
+ ref_registry: RefRegistry | None = None,
100
+ ) -> StagedArtifactWrite:
101
+ token = uuid.uuid4().hex
102
+ staged_screen_json = self.stage_screen_json_update(
103
+ session,
104
+ public_screen,
105
+ sequence=sequence,
106
+ source_snapshot_id=source_snapshot_id,
107
+ captured_at=captured_at,
108
+ ref_registry=ref_registry,
109
+ token=token,
110
+ )
111
+ staged_screen_xml = self.stage_screen_xml_update(
112
+ session,
113
+ public_screen,
114
+ sequence=sequence,
115
+ token=token,
116
+ )
117
+
118
+ artifacts = ScreenArtifacts(
119
+ screen_json=staged_screen_json.final_path.as_posix(),
120
+ screen_xml=staged_screen_xml.final_path.as_posix(),
121
+ )
122
+ return StagedArtifactWrite(
123
+ artifacts=artifacts,
124
+ file_updates=(staged_screen_json, staged_screen_xml),
125
+ )
126
+
127
+ def stage_screen_json_update(
128
+ self,
129
+ session: WorkspaceRuntime,
130
+ public_screen: PublicScreen,
131
+ *,
132
+ sequence: int,
133
+ source_snapshot_id: int,
134
+ captured_at: str,
135
+ ref_registry: RefRegistry | None = None,
136
+ token: str | None = None,
137
+ ) -> StagedFileUpdate:
138
+ screens_dir = session.artifact_root / "screens"
139
+ screens_dir.mkdir(parents=True, exist_ok=True)
140
+ write_token = token or uuid.uuid4().hex
141
+ json_path = screens_dir / f"{artifact_stem(sequence)}.json"
142
+ staged_json_path = staged_path_for(json_path, write_token)
143
+ write_json_file(
144
+ staged_json_path,
145
+ build_screen_artifact_payload(
146
+ public_screen,
147
+ ref_registry or RefRegistry(),
148
+ sequence=sequence,
149
+ source_snapshot_id=source_snapshot_id,
150
+ captured_at=captured_at,
151
+ ),
152
+ )
153
+ return StagedFileUpdate(
154
+ staged_path=staged_json_path,
155
+ final_path=json_path,
156
+ backup_path=backup_path_for(json_path, write_token),
157
+ )
158
+
159
+ def stage_screen_xml_update(
160
+ self,
161
+ session: WorkspaceRuntime,
162
+ public_screen: PublicScreen,
163
+ *,
164
+ sequence: int,
165
+ token: str | None = None,
166
+ ) -> StagedFileUpdate:
167
+ screens_dir = session.artifact_root / "artifacts" / "screens"
168
+ screens_dir.mkdir(parents=True, exist_ok=True)
169
+ write_token = token or uuid.uuid4().hex
170
+ xml_path = screens_dir / f"{artifact_stem(sequence)}.xml"
171
+ staged_xml_path = staged_path_for(xml_path, write_token)
172
+ write_text_file(staged_xml_path, render_screen_xml(public_screen))
173
+ return StagedFileUpdate(
174
+ staged_path=staged_xml_path,
175
+ final_path=xml_path,
176
+ backup_path=backup_path_for(xml_path, write_token),
177
+ )
178
+
179
+ def write_screenshot_png(self, session: WorkspaceRuntime, body: bytes) -> Path:
180
+ if len(body) > SCREENSHOT_MAX_BINARY_BYTES:
181
+ raise ValueError(
182
+ f"screenshot PNG exceeds {SCREENSHOT_MAX_BINARY_BYTES} byte budget"
183
+ )
184
+ screenshots_dir = session.artifact_root / "screenshots"
185
+ try:
186
+ screenshots_dir.mkdir(parents=True, exist_ok=True)
187
+ if not screenshots_dir.is_dir():
188
+ raise _artifact_root_unwritable("namespace-not-directory")
189
+ except DaemonError:
190
+ raise
191
+ except Exception as error:
192
+ raise _artifact_root_unwritable("namespace-create-failed") from error
193
+
194
+ try:
195
+ next_index = _next_screenshot_index(screenshots_dir)
196
+ except Exception as error:
197
+ raise _artifact_root_unwritable("namespace-scan-failed") from error
198
+ binary_flag = getattr(os, "O_BINARY", 0)
199
+ while True:
200
+ try:
201
+ candidate = (screenshots_dir / f"shot-{next_index:05d}.png").resolve()
202
+ except Exception as error:
203
+ raise _artifact_write_failed("candidate-resolve-failed") from error
204
+ fd: int | None = None
205
+ handle = None
206
+ try:
207
+ fd = os.open(
208
+ candidate,
209
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | binary_flag,
210
+ )
211
+ except FileExistsError:
212
+ next_index += 1
213
+ continue
214
+ except Exception as error:
215
+ raise _artifact_write_failed("candidate-open-failed") from error
216
+ failure_reason = "candidate-fdopen-failed"
217
+ try:
218
+ handle = os.fdopen(fd, "wb")
219
+ fd = None
220
+ failure_reason = "candidate-write-failed"
221
+ handle.write(body)
222
+ failure_reason = "candidate-flush-failed"
223
+ handle.flush()
224
+ failure_reason = "candidate-close-failed"
225
+ handle.close()
226
+ return candidate
227
+ except Exception as error:
228
+ if handle is not None:
229
+ with suppress(Exception):
230
+ handle.close()
231
+ if fd is not None:
232
+ with suppress(OSError):
233
+ os.close(fd)
234
+ with suppress(OSError):
235
+ candidate.unlink()
236
+ raise _artifact_write_failed(failure_reason) from error
237
+
238
+
239
+ def _next_screenshot_index(screenshots_dir: Path) -> int:
240
+ highest_index = 0
241
+ for path in screenshots_dir.glob("shot-*.png"):
242
+ suffix = path.stem.removeprefix("shot-")
243
+ if not suffix.isdigit():
244
+ continue
245
+ highest_index = max(highest_index, int(suffix))
246
+ return highest_index + 1
247
+
248
+
249
+ def _artifact_root_unwritable(reason: str) -> DaemonError:
250
+ return DaemonError(
251
+ code=DaemonErrorCode.ARTIFACT_ROOT_UNWRITABLE,
252
+ message="artifact root is not writable",
253
+ details={"reason": reason},
254
+ )
255
+
256
+
257
+ def _artifact_write_failed(reason: str) -> DaemonError:
258
+ return DaemonError(
259
+ code=DaemonErrorCode.ARTIFACT_WRITE_FAILED,
260
+ message="artifact write failed",
261
+ details={"reason": reason},
262
+ )
263
+
264
+
265
+ def staged_path_for(final_path: Path, token: str) -> Path:
266
+ return final_path.with_name(f"{final_path.name}.tmp-{token}")
267
+
268
+
269
+ def backup_path_for(final_path: Path, token: str) -> Path:
270
+ return final_path.with_name(f"{final_path.name}.bak-{token}")
271
+
272
+
273
+ def artifact_stem(sequence: int) -> str:
274
+ return f"obs-{sequence:05d}"
275
+
276
+
277
+ def write_json_file(path: Path, payload: dict[str, object]) -> None:
278
+ with path.open("w", encoding="utf-8") as handle:
279
+ json.dump(payload, handle, indent=2, sort_keys=True)
280
+ handle.write("\n")
281
+
282
+
283
+ def write_text_file(path: Path, payload: str) -> None:
284
+ with path.open("w", encoding="utf-8") as handle:
285
+ handle.write(payload)
286
+ handle.write("\n")
@@ -0,0 +1 @@
1
+ """Authentication helpers for androidctld."""