mobster-cli 0.4.3__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 (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,891 @@
1
+ """A finished task as a short captioned clip: an animated GIF, and an MP4 when ffmpeg is installed.
2
+
3
+ Each step's screen (FrameStore, at most 480 px) sits in a rounded phone frame on Mobster's Paper or Night ground, with
4
+ the step's plain-words caption under it and a small mark with "mobster.dev" in a corner. The clip opens on the task
5
+ typed into a prompt, shows each step for 1.2 to 2.5 s by the caption's length, shows an approval as the card Mobster
6
+ asked with, and holds the result 1.5 s longer. It stays under X's 15 MB GIF limit: one palette for the whole clip,
7
+ then fewer colours, no cross-fades, a smaller canvas, in that order (a run's step count is capped in ``plan_beats``).
8
+
9
+ Everything here is pure: ``build_clip(run, read_frame)`` takes a run (``Run.public()`` or a journal record, with its
10
+ events) and a function that returns a frame's JPEG bytes. Nothing is uploaded or saved here.
11
+
12
+ Privacy. A sign-in code (the USE_CODE skill) ends the screens: every step from it on is a plain card. ``redact``
13
+ blurs typed text fields and message bodies with the element frames each step journaled (``redact`` on a Smart step,
14
+ the Fast engine's ``observation`` elements), blurs the whole screen of a step with no known frames, masks quoted
15
+ text in captions, and leaves out the answer and the approval's wording.
16
+ """
17
+
18
+ import io
19
+ import math
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import tempfile
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from . import brand
29
+
30
+ GIF_MAX_BYTES = 14_500_000 # X takes GIFs up to 15 MB; this leaves room for the container's rounding
31
+ MAX_STEPS = 60 # step screens in one clip; a longer task keeps its first, last and asked steps
32
+ MAX_GOAL_CHARS = 80
33
+ MAX_CAPTION_LINES = 2
34
+
35
+ STEP_MIN_MS, STEP_MAX_MS, STEP_BASE_MS, STEP_PER_CHAR_MS = 1200, 2500, 800, 40
36
+ END_HOLD_MS = 1500
37
+ TYPE_FRAME_MS, TITLE_HOLD_MS = 60, 1100
38
+ FADE_STEPS, FADE_FRAME_MS = 3, 50
39
+ ASK_MS, ANSWERED_MS, SECRET_MS = 1700, 1100, 2000
40
+
41
+ SECRET_CAPTION = "Sign-in code entered. Screens after it are left out."
42
+ NO_SCREENS = "This task has no screens to save."
43
+ TOO_LONG = "This task is too long for a GIF. Save it as an MP4 instead."
44
+
45
+
46
+ def _theme(tokens, hardware, mark):
47
+ return {"ground": tokens["paper"], "surface": tokens["surface"], "sunken": tokens["sunken"], "ink": tokens["ink"],
48
+ "ink2": tokens["ink-2"], "ink3": tokens["ink-3"], "line": tokens["line"],
49
+ "line_strong": tokens["line-strong"], "violet": tokens["violet-solid"], "primary": tokens["ink"],
50
+ "primary_hover": tokens["primary-hover"], "on_primary": tokens["paper"],
51
+ "green": tokens["green"], "pixel_mark": mark, **hardware}
52
+
53
+
54
+ THEMES = {
55
+ "paper": _theme(brand.LIGHT, {"bezel": "#181818", "rim": "#2E2D33", "shadow": (24, 24, 24, 46)},
56
+ brand.PIXEL_MARK["light"]),
57
+ "night": _theme(brand.DARK, {"bezel": "#24232A", "rim": "#45434D", "shadow": (0, 0, 0, 120)},
58
+ brand.PIXEL_MARK["dark"]),
59
+ }
60
+
61
+ # Messaging and mail apps: with ``redact`` every line of text in them is a message body.
62
+ MESSAGE_APPS = frozenset({
63
+ "com.apple.MobileSMS", "com.apple.mobilemail", "net.whatsapp.WhatsApp", "com.facebook.Messenger",
64
+ "ph.telegra.Telegraph", "org.whispersystems.signal", "com.tinyspeck.chatlyio", "com.hammerandchisel.discord",
65
+ "com.google.Gmail", "com.microsoft.Office.Outlook", "com.burbn.instagram", "com.atebits.Tweetie2",
66
+ "com.linkedin.LinkedIn", "com.toyopagroup.picaboo", "jp.naver.line", "com.tencent.xin",
67
+ })
68
+ FIELD_ROLES = frozenset({"TextField", "SecureTextField", "SearchField", "TextView"})
69
+ TEXT_ROLES = frozenset({"StaticText", "TextView", "Cell", "Other", "Link", "Image"})
70
+ MAX_RECTS = 24
71
+ STATUS_BAR = (0.0, 0.0, 1.0, 0.06) # never blurred: the time and battery
72
+
73
+ ACTS = {
74
+ "send": ("sending", "Send", "Don’t send"), "post": ("posting", "Post", "Don’t post"),
75
+ "pay": ("paying", "Pay", "Don’t pay"), "buy": ("buying", "Buy", "Don’t buy"),
76
+ "order": ("ordering", "Place order", "Don’t order"), "book": ("booking", "Book", "Don’t book"),
77
+ "delete": ("deleting", "Delete", "Don’t delete"),
78
+ }
79
+ ACT_FAMILIES = {
80
+ "send_message": "send", "send": "send", "reply": "send", "request": "send", "send message": "send",
81
+ "post": "post", "comment": "post", "publish": "post", "tweet": "post", "share": "post",
82
+ "pay": "pay", "transfer": "pay", "request_money": "pay", "donate": "pay", "tip": "pay",
83
+ "buy": "buy", "purchase": "buy", "checkout": "buy", "order": "order", "place order": "order",
84
+ "book": "book", "reserve": "book", "delete": "delete", "erase": "delete", "remove": "delete",
85
+ "trash": "delete", "clear": "delete",
86
+ }
87
+ ENDINGS = {"completed": "Done", "completed_unverified": "Done", "expected_text_visible": "Done",
88
+ "approval_denied": "You declined", "stopped": "Stopped", "user_condition_met": "Stopped",
89
+ "approval_timeout": "Stopped safely", "spend_cap": "Stopped at the cost limit"}
90
+
91
+
92
+ @dataclass
93
+ class Beat:
94
+ """One moment of the clip."""
95
+ kind: str
96
+ caption: str
97
+ frame_id: str | None = None
98
+ rects: list | None = None
99
+ n: int = 0
100
+ total: int = 0
101
+ card: dict | None = None
102
+ note: str = ""
103
+
104
+
105
+ @dataclass
106
+ class Clip:
107
+ gif: bytes
108
+ frames: int
109
+ durations: list
110
+ beats: list
111
+ colors: int = 256
112
+ crossfade: bool = True
113
+
114
+ @property
115
+ def captions(self):
116
+ return [beat.caption for beat in self.beats]
117
+
118
+ @property
119
+ def seconds(self):
120
+ return sum(self.durations) / 1000
121
+
122
+
123
+ def private_rects(elements, bundle_id=None, limit=MAX_RECTS):
124
+ """The frames ``redact`` blurs: every text field (empty ones too), a message app's lines and body-length text."""
125
+ messages = bundle_id in MESSAGE_APPS
126
+ found = []
127
+ for element in elements or ():
128
+ if isinstance(element, dict):
129
+ get = element.get
130
+ else:
131
+ def get(name, default=None, element=element):
132
+ return getattr(element, name, default)
133
+ role, rect = str(get("role", "") or ""), get("rect")
134
+ try:
135
+ x, y, w, h = (float(value) for value in rect)
136
+ except (TypeError, ValueError):
137
+ continue
138
+ if not (w > 0 and h > 0) or w * h > 0.6:
139
+ continue # nothing, or a container: its rows are listed on their own
140
+ text = " ".join(str(get("value", "") or get("label", "") or "").split())
141
+ field_like = bool(get("editable", False)) or role in FIELD_ROLES
142
+ body = role in TEXT_ROLES and text and (messages or len(text.split()) >= 4 or len(text) >= 28)
143
+ if field_like or body:
144
+ found.append([round(x, 3), round(y, 3), round(w, 3), round(h, 3)])
145
+ if len(found) >= limit:
146
+ break
147
+ return found
148
+
149
+
150
+ def _act(act):
151
+ raw = str(act or "").strip().lower()
152
+ family = ACT_FAMILIES.get(raw) or ACT_FAMILIES.get(raw.replace("_", " ").replace("-", " "))
153
+ return ACTS.get(family)
154
+
155
+
156
+ def mask_quotes(text):
157
+ """A caption with ``redact``: quoted text, values read, phone numbers and email addresses hidden."""
158
+ text = re.sub(r"“[^”]*”", "“•••”", text)
159
+ text = re.sub(r"\"[^\"]*\"", "“•••”", text)
160
+ text = re.sub(r"[\w.+-]+@[\w-]+(\.[\w-]+)+", "•••", text)
161
+ text = re.sub(r"\+?\(?\d[\d ().-]{5,}\d", "•••", text)
162
+ return re.sub(r"^(Read [^:]{1,60}):\s.*$", r"\1: •••", text)
163
+
164
+
165
+ def step_ms(caption):
166
+ return max(STEP_MIN_MS, min(STEP_MAX_MS, STEP_BASE_MS + STEP_PER_CHAR_MS * len(caption)))
167
+
168
+
169
+ def _elapsed(run):
170
+ try:
171
+ ms = float(run.get("finishedAt")) - float(run.get("createdAt"))
172
+ except (TypeError, ValueError):
173
+ summary = run.get("summary") or {}
174
+ ms = summary.get("elapsed_ms") if isinstance(summary.get("elapsed_ms"), (int, float)) else None
175
+ if ms is None or not math.isfinite(ms) or ms < 0:
176
+ return None
177
+ seconds = round(ms / 1000)
178
+ return f"{seconds} s" if seconds < 60 else f"{seconds // 60} min {seconds % 60} s" if seconds % 60 else \
179
+ f"{seconds // 60} min"
180
+
181
+
182
+ def plan_beats(run, redact=False):
183
+ """The clip's beats, from a finished run's events. Pure: tests read the captions here, not from pixels."""
184
+ events = [event for event in run.get("events") or () if isinstance(event, dict)]
185
+ goal = " ".join(str(run.get("goal") or "").split())
186
+ goal = goal if len(goal) <= MAX_GOAL_CHARS else goal[:MAX_GOAL_CHARS - 1].rstrip() + "…"
187
+ beats = [Beat("title", goal)]
188
+ observations = [(index, event) for index, event in enumerate(events)
189
+ if event.get("event") == "observation" and isinstance(event.get("elements"), list)]
190
+ asked = {}
191
+ secret = False
192
+ last_frame = None
193
+ for index, event in enumerate(events):
194
+ kind = event.get("event")
195
+ if kind in ("skill_started", "skill_finished") and event.get("op") == "USE_CODE":
196
+ if not secret:
197
+ beats.append(Beat("secret", SECRET_CAPTION))
198
+ secret = True
199
+ elif kind == "step" and isinstance(event.get("text"), str) and event["text"].strip():
200
+ if secret:
201
+ continue
202
+ caption = " ".join(event["text"].split())
203
+ frame_id = event.get("frameId") if isinstance(event.get("frameId"), str) else None
204
+ rects = _step_rects(event, index, observations)
205
+ beats.append(Beat("step", mask_quotes(caption) if redact else caption, frame_id or last_frame, rects))
206
+ last_frame = frame_id or last_frame
207
+ elif kind == "approval_requested" and isinstance(event.get("approval_id"), str):
208
+ asked[event["approval_id"]] = event
209
+ elif kind == "approval_resolved" and event.get("approval_id") in asked and not secret:
210
+ beats.append(_ask_beat(asked[event["approval_id"]], event.get("decision"), last_frame, beats, redact))
211
+ steps = [beat for beat in beats if beat.kind == "step"]
212
+ if not any(beat.frame_id for beat in steps):
213
+ raise ValueError(NO_SCREENS)
214
+ beats = _keep_steps(beats)
215
+ steps = [beat for beat in beats if beat.kind == "step"]
216
+ for number, beat in enumerate(steps, 1):
217
+ beat.n, beat.total = number, len(steps)
218
+ beats.append(_end_beat(run, steps, last_frame if not secret else None, redact))
219
+ return beats
220
+
221
+
222
+ def _step_rects(event, index, observations):
223
+ if isinstance(event.get("redact"), list):
224
+ return [list(rect) for rect in event["redact"] if isinstance(rect, (list, tuple)) and len(rect) == 4]
225
+ before = [obs for at, obs in observations if at < index][-1:]
226
+ after = [obs for at, obs in observations if at > index][:1]
227
+ if not before and not after:
228
+ return None
229
+ rects = []
230
+ for obs in before + after:
231
+ rects += private_rects(obs.get("elements"), obs.get("bundle_id"))
232
+ return rects
233
+
234
+
235
+ def _ask_beat(request, decision, frame_id, beats, redact):
236
+ act = _act(request.get("act")) or _act(str(request.get("label") or "").split(" ")[0])
237
+ gerund, yes, no = act or (None, "Approve", "Decline")
238
+ if request.get("kind") == "clarify":
239
+ caption, title = "Mobster asked you a question", None if redact else request.get("label")
240
+ else:
241
+ caption = f"Asked before {gerund}" if gerund else "Asked before going ahead"
242
+ title = request.get("title") if not redact else None
243
+ if not title:
244
+ noun = {"Send": "this message", "Post": "this", "Pay": "this payment", "Delete": "this"}.get(yes, "this")
245
+ title = f"{yes} {noun}?" if act else "Go ahead with this step?"
246
+ answer = {"approved": f"You approved “{yes}”", "denied": f"You chose “{no}”",
247
+ "redirected": "You declined and said what to do instead",
248
+ "answered": "You answered", "timeout": "Nobody answered, so Mobster didn’t do it"}.get(
249
+ str(decision or ""), "You answered")
250
+ rects = next((beat.rects for beat in reversed(beats) if beat.kind == "step"), None)
251
+ return Beat("ask", caption, frame_id, rects,
252
+ card={"title": " ".join(str(title).split())[:120], "yes": yes, "no": no,
253
+ "decision": decision, "answer": answer})
254
+
255
+
256
+ def _keep_steps(beats):
257
+ """At most MAX_STEPS step screens: the first, the last, those either side of an approval, then evenly."""
258
+ steps = [i for i, beat in enumerate(beats) if beat.kind == "step"]
259
+ if len(steps) <= MAX_STEPS:
260
+ return beats
261
+ keep = {steps[0], steps[-1]}
262
+ for i, beat in enumerate(beats):
263
+ if beat.kind == "ask":
264
+ keep |= {j for j in (i - 1, i + 1) if j in steps}
265
+ rest = [i for i in steps if i not in keep]
266
+ room = max(0, MAX_STEPS - len(keep))
267
+ if room and rest:
268
+ stride = len(rest) / room
269
+ keep |= {rest[int(k * stride)] for k in range(room)}
270
+ return [beat for i, beat in enumerate(beats) if beat.kind != "step" or i in keep]
271
+
272
+
273
+ def _end_beat(run, steps, frame_id, redact):
274
+ status = str(run.get("status") or "")
275
+ summary = run.get("summary") if isinstance(run.get("summary"), dict) else {}
276
+ word = ENDINGS.get(status, "Couldn’t finish")
277
+ elapsed = _elapsed(run)
278
+ count = f"{len(steps)} {'step' if len(steps) == 1 else 'steps'}"
279
+ note = f"{word} in {elapsed}, {count}" if elapsed else f"{word}, {count}"
280
+ answer = summary.get("answer") if isinstance(summary.get("answer"), str) else None
281
+ if redact or not answer or word != "Done":
282
+ answer = "Done on your iPhone." if word == "Done" else None
283
+ rects = next((beat.rects for beat in reversed(steps)), None)
284
+ beat = Beat("end", " ".join(answer.split()) if answer else word, frame_id, rects, note=note)
285
+ beat.card = {"done": word == "Done"}
286
+ return beat
287
+
288
+
289
+ FONT_PATHS = ("/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc",
290
+ "/System/Library/Fonts/HelveticaNeue.ttc")
291
+ HANGUL = re.compile("[\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]")
292
+ WIDE_SCRIPTS = re.compile("[\u1100-\u11ff\u2e80-\ua4cf\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]")
293
+ WIDE_FONTS = ("/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/AppleSDGothicNeo.ttc",
294
+ "/Library/Fonts/Arial Unicode.ttf")
295
+ KOREAN_FONTS = ("/System/Library/Fonts/AppleSDGothicNeo.ttc", "/Library/Fonts/Arial Unicode.ttf")
296
+ # What the brand face draws: Google Fonts' "latin" subset and the command key (site/tools/make_fonts.py). Text with any
297
+ # other character is set in SF Pro, so a caption never shows a missing glyph.
298
+ BRAND_TEXT = re.compile("^[\u0000-\u00ff\u0131\u0152\u0153\u02bb\u02bc\u02c6\u02da\u02dc\u0304\u0308\u0329"
299
+ "\u2000-\u206f\u20ac\u2122\u2191\u2193\u2212\u2215\u2318]*$")
300
+ _BRAND_FACE = []
301
+
302
+
303
+ def brand_face():
304
+ """Mobster Sans's bytes (Google Sans Flex, weights 400 to 620), from the face the reports embed."""
305
+ if not _BRAND_FACE:
306
+ import base64
307
+
308
+ from .brand_fonts import FONT_FACES
309
+ found = re.search(r'"Mobster Sans"; src: url\(data:font/woff2;base64,([^)]+)\)', FONT_FACES)
310
+ _BRAND_FACE.append(base64.b64decode(found.group(1)) if found else b"")
311
+ return _BRAND_FACE[0]
312
+
313
+
314
+ class Fonts:
315
+ """Mobster Sans at a weight for text it draws, else SF Pro (macOS), else Helvetica, else Pillow's own."""
316
+
317
+ def __init__(self, path=None):
318
+ self.path = path
319
+ self.cache = {}
320
+
321
+ def get(self, size, weight=400, text=""):
322
+ from PIL import ImageFont
323
+ wide = "korean" if text and HANGUL.search(text) else "cjk" if text and WIDE_SCRIPTS.search(text) else ""
324
+ wide = wide if self.path is None else ""
325
+ branded = self.path is None and not wide and bool(BRAND_TEXT.match(text or "")) and bool(brand_face())
326
+ key = (round(size), weight, wide or ("brand" if branded else ""))
327
+ if key in self.cache:
328
+ return self.cache[key]
329
+ font = None
330
+ if branded:
331
+ try:
332
+ font = _weighted(ImageFont.truetype(io.BytesIO(brand_face()), round(size)), weight, size)
333
+ except OSError:
334
+ font = None
335
+ first = KOREAN_FONTS if wide == "korean" else WIDE_FONTS if wide else ()
336
+ paths = () if font else (self.path,) if self.path else () if self.path == "" else first + FONT_PATHS
337
+ for path in paths:
338
+ if not path or not os.path.exists(path):
339
+ continue
340
+ try:
341
+ bold = weight >= 560 and path.endswith(("Helvetica.ttc", "Hiragino Sans GB.ttc"))
342
+ font = ImageFont.truetype(path, round(size), index=1 if bold else 0)
343
+ except OSError:
344
+ continue
345
+ font = _weighted(font, weight, size)
346
+ break
347
+ if font is None:
348
+ try:
349
+ font = ImageFont.load_default(size=round(size))
350
+ except TypeError:
351
+ font = ImageFont.load_default()
352
+ self.cache[key] = font
353
+ return font
354
+
355
+
356
+ def _weighted(font, weight, size):
357
+ """``font`` at ``weight`` on its weight axis, its optical size at ``size``, its other axes at their defaults."""
358
+ try:
359
+ axes = {axis["name"]: axis for axis in font.get_variation_axes()}
360
+ if b"Weight" in axes or "Weight" in axes:
361
+ values = []
362
+ for axis in font.get_variation_axes():
363
+ name = axis["name"].decode() if isinstance(axis["name"], bytes) else axis["name"]
364
+ values.append(max(axis["minimum"], min(axis["maximum"], weight)) if name == "Weight" else
365
+ max(axis["minimum"], min(axis["maximum"], size)) if name == "Optical Size"
366
+ else axis["default"])
367
+ font.set_variation_by_axes(values)
368
+ except (OSError, AttributeError, KeyError):
369
+ pass
370
+ return font
371
+
372
+
373
+ def _greedy(text, font, width, lines):
374
+ """(lines, cut): ``text`` filled greedily into at most ``lines`` lines of ``width`` px."""
375
+ words, out = text.split(), []
376
+ while words and len(out) < lines:
377
+ line = words.pop(0)
378
+ while font.getlength(line) > width and len(line) > 1:
379
+ cut = len(line) - 1
380
+ while cut > 1 and font.getlength(line[:cut]) > width:
381
+ cut -= 1
382
+ words.insert(0, line[cut:])
383
+ line = line[:cut]
384
+ while words and font.getlength(f"{line} {words[0]}") <= width:
385
+ line = f"{line} {words.pop(0)}"
386
+ out.append(line)
387
+ return out, bool(words)
388
+
389
+
390
+ def _wrap(text, font, width, lines):
391
+ out, cut = _greedy(text, font, width, lines)
392
+ if cut and out:
393
+ last = out[-1]
394
+ while last and font.getlength(last + "…") > width:
395
+ last = last[:-1].rstrip()
396
+ out[-1] = last + "…"
397
+ return out
398
+ if len(out) > 1:
399
+ low, high = width * 0.4, width
400
+ while high - low > 2:
401
+ middle = (low + high) / 2
402
+ trial, trial_cut = _greedy(text, font, middle, lines)
403
+ if trial_cut or len(trial) > len(out):
404
+ low = middle
405
+ else:
406
+ high = middle
407
+ out = _greedy(text, font, high, lines)[0]
408
+ return out
409
+
410
+
411
+ class Painter:
412
+ """Lays out one frame of the clip. Sizes are for ``scale`` 1 (a 624 × 780 canvas, 4:5)."""
413
+
414
+ W, H = 624, 780
415
+ SCREEN_H, SCREEN_MAX_W, BEZEL, TOP = 520, 320, 8, 36
416
+ SS = 3
417
+
418
+ def __init__(self, theme="paper", scale=1.0, fonts=None, aspect=222 / 480):
419
+ from PIL import Image
420
+ self.theme = THEMES[theme if theme in THEMES else "paper"]
421
+ self.scale = scale
422
+ self.fonts = fonts or Fonts()
423
+ s = self.s
424
+ self.size = (s(self.W) // 2 * 2, s(self.H) // 2 * 2)
425
+ sh = s(self.SCREEN_H)
426
+ sw = min(s(self.SCREEN_MAX_W), round(sh * aspect))
427
+ self.screen_size = (sw, sh)
428
+ self.radius = round(sw * 0.13)
429
+ bezel = s(self.BEZEL)
430
+ x = (self.size[0] - sw) // 2
431
+ self.screen_box = (x, s(self.TOP) + bezel, x + sw, s(self.TOP) + bezel + sh)
432
+ self.phone_box = (x - bezel, s(self.TOP), x + sw + bezel, s(self.TOP) + sh + 2 * bezel)
433
+ self.caption_top = self.phone_box[3] + s(26)
434
+ self.screen_mask = self._rounded_mask(self.screen_size, self.radius)
435
+ self.ground = Image.new("RGB", self.size, brand.rgb(self.theme["ground"]))
436
+ self.base = self._base()
437
+
438
+ def s(self, value):
439
+ return int(round(value * self.scale))
440
+
441
+ def _rounded_mask(self, size, radius):
442
+ from PIL import Image, ImageDraw
443
+ k = self.SS
444
+ mask = Image.new("L", (size[0] * k, size[1] * k), 0)
445
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] * k - 1, size[1] * k - 1), radius * k, fill=255)
446
+ return mask.resize(size, Image.LANCZOS)
447
+
448
+ def _base(self):
449
+ """The ground, the phone's shadow and its frame, and the corner mark: the same in every frame."""
450
+ from PIL import Image, ImageDraw, ImageFilter
451
+ t, s = self.theme, self.s
452
+ image = self.ground.copy()
453
+ x0, y0, x1, y1 = self.phone_box
454
+ outer = self.radius + s(self.BEZEL)
455
+ shadow = Image.new("L", self.size, 0)
456
+ ImageDraw.Draw(shadow).rounded_rectangle((x0 + s(4), y0 + s(14), x1 - s(4), y1 + s(10)), outer, fill=255)
457
+ shadow = shadow.filter(ImageFilter.GaussianBlur(s(18)))
458
+ r, g, b, a = t["shadow"]
459
+ image.paste(Image.new("RGB", self.size, (r, g, b)), (0, 0), shadow.point(lambda v: v * a // 255))
460
+ rim = self._rounded_mask((x1 - x0, y1 - y0), outer)
461
+ image.paste(Image.new("RGB", rim.size, brand.rgb(t["rim"])), (x0, y0), rim)
462
+ inset = max(1, s(1.5))
463
+ body = self._rounded_mask((x1 - x0 - 2 * inset, y1 - y0 - 2 * inset), outer - inset)
464
+ image.paste(Image.new("RGB", body.size, brand.rgb(t["bezel"])), (x0 + inset, y0 + inset), body)
465
+ self._footer(image)
466
+ return image
467
+
468
+ def _mark(self, height):
469
+ from PIL import Image, ImageDraw
470
+ t, k = self.theme, self.SS * 4
471
+ unit = height * k / 16
472
+ image = Image.new("RGBA", (round(16 * unit), round(16 * unit)), (0, 0, 0, 0))
473
+ draw = ImageDraw.Draw(image)
474
+ for x, y, width, height, colour in t["pixel_mark"]:
475
+ draw.rectangle((round(x * unit), round(y * unit), round((x + width) * unit) - 1,
476
+ round((y + height) * unit) - 1), fill=brand.rgb(colour) + (255,))
477
+ return image.resize((max(1, round(image.width / k)), max(1, round(image.height / k))), Image.LANCZOS)
478
+
479
+ def _footer(self, image):
480
+ from PIL import ImageDraw
481
+ s, t = self.s, self.theme
482
+ font = self.fonts.get(s(14), 500)
483
+ text = "mobster.dev"
484
+ width = font.getlength(text)
485
+ mark = self._mark(s(16))
486
+ right, baseline = self.size[0] - s(22), self.size[1] - s(20)
487
+ x = right - width
488
+ ImageDraw.Draw(image).text((x, baseline), text, font=font, fill=brand.rgb(t["ink3"]), anchor="ls")
489
+ image.paste(mark, (round(x - s(7) - mark.width), baseline - mark.height + s(2)), mark)
490
+
491
+ def screen(self, data, rects=None, redact=False, blur=0):
492
+ """A frame's JPEG bytes as the phone's screen: fitted, blurred where ``redact`` says, or all over (``blur``)."""
493
+ from PIL import Image, ImageFilter, ImageOps
494
+ try:
495
+ image = Image.open(io.BytesIO(data)).convert("RGB")
496
+ except Exception:
497
+ return None
498
+ if redact:
499
+ image = blur_regions(image, rects)
500
+ image = ImageOps.pad(image, self.screen_size, Image.LANCZOS, color=(0, 0, 0))
501
+ if blur:
502
+ image = image.filter(ImageFilter.GaussianBlur(self.s(blur)))
503
+ return image
504
+
505
+ def blank_screen(self, lock=False):
506
+ from PIL import Image, ImageDraw
507
+ t, s = self.theme, self.s
508
+ image = Image.new("RGB", self.screen_size, brand.rgb(t["sunken"]))
509
+ if lock:
510
+ draw = ImageDraw.Draw(image)
511
+ cx, cy = self.screen_size[0] // 2, self.screen_size[1] // 2
512
+ draw.rounded_rectangle((cx - s(22), cy - s(6), cx + s(22), cy + s(30)), s(7), fill=brand.rgb(t["ink3"]))
513
+ draw.arc((cx - s(14), cy - s(30), cx + s(14), cy + s(6)), 180, 360, fill=brand.rgb(t["ink3"]), width=s(6))
514
+ draw.line((cx - s(14), cy - s(12), cx - s(14), cy - s(4)), fill=brand.rgb(t["ink3"]), width=s(6))
515
+ draw.line((cx + s(14), cy - s(12), cx + s(14), cy - s(4)), fill=brand.rgb(t["ink3"]), width=s(6))
516
+ return image
517
+
518
+ def frame(self, beat, screen, typed=None, pressed=False):
519
+ """The canvas for ``beat`` with ``screen`` (an image of screen_size, or None) on the phone."""
520
+ from PIL import Image, ImageDraw
521
+ image = self.base.copy()
522
+ x0, y0 = self.screen_box[:2]
523
+ if screen is None:
524
+ screen = self.blank_screen(lock=beat.kind == "secret")
525
+ if beat.kind == "ask":
526
+ screen = Image.blend(screen, Image.new("RGB", screen.size, (0, 0, 0)), 0.32)
527
+ image.paste(screen, (x0, y0), self.screen_mask)
528
+ draw = ImageDraw.Draw(image)
529
+ if beat.kind == "title":
530
+ self._prompt(image, draw, beat.caption, typed)
531
+ else:
532
+ self._caption(draw, beat)
533
+ if beat.kind == "ask":
534
+ self._card(image, beat.card, pressed)
535
+ return image
536
+
537
+ def _centered(self, draw, lines, font, top, color, line_height):
538
+ for index, line in enumerate(lines):
539
+ draw.text((self.size[0] / 2, top + index * line_height), line, font=font, fill=color, anchor="ma")
540
+
541
+ def _caption(self, draw, beat):
542
+ s, t = self.s, self.theme
543
+ top = self.caption_top
544
+ small = self.fonts.get(s(15), 500)
545
+ if beat.kind == "step":
546
+ label = f"{beat.n} of {beat.total}"
547
+ draw.text((self.size[0] / 2, top), label, font=small, fill=brand.rgb(t["ink3"]), anchor="ma")
548
+ elif beat.kind == "end":
549
+ done = bool(beat.card and beat.card.get("done"))
550
+ width = small.getlength(beat.note)
551
+ dot = s(16) if done else 0
552
+ gap = s(7) if done else 0
553
+ x = (self.size[0] - width - dot - gap) / 2
554
+ if done:
555
+ cy = top + s(9)
556
+ draw.ellipse((x, cy - dot / 2, x + dot, cy + dot / 2), fill=brand.rgb(t["green"]))
557
+ draw.line((x + dot * .28, cy + dot * .02, x + dot * .45, cy + dot * .2, x + dot * .74, cy - dot * .2),
558
+ fill=brand.rgb(t["ground"]), width=max(2, s(2)), joint="curve")
559
+ draw.text((x + dot + gap, top), beat.note, font=small, fill=brand.rgb(t["ink3"]), anchor="la")
560
+ size = 27 if beat.kind != "end" or len(beat.caption) <= 70 else 23
561
+ font = self.fonts.get(s(size), 580, beat.caption)
562
+ lines = _wrap(beat.caption, font, s(548), 3 if beat.kind == "end" else MAX_CAPTION_LINES)
563
+ self._centered(draw, lines, font, top + s(28), brand.rgb(t["ink"]), s(size * 1.26))
564
+
565
+ def _prompt(self, image, draw, goal, typed):
566
+ from PIL import Image
567
+ s, t = self.s, self.theme
568
+ typing = typed is not None
569
+ font = self.fonts.get(s(21), 400, goal)
570
+ width = s(560)
571
+ inner = width - s(22) - s(58)
572
+ full = _greedy(goal, font, inner, 2)[0] or [""]
573
+ if typing: # the final lines, cut to what is typed so far, so words never jump between lines
574
+ left, lines = typed, []
575
+ for line in full:
576
+ lines.append(line[:max(0, left)])
577
+ left -= len(line) + 1
578
+ if left <= 0:
579
+ break
580
+ else:
581
+ lines = full
582
+ height = s(60) if len(full) == 1 else s(88)
583
+ x0 = (self.size[0] - width) // 2
584
+ y0 = self.caption_top + s(6)
585
+ pill = self._rounded_mask((width, height), min(height // 2, s(28)))
586
+ image.paste(Image.new("RGB", pill.size, brand.rgb(t["line_strong"])), (x0, y0), pill)
587
+ inner_pill = self._rounded_mask((width - 2 * max(1, s(1)), height - 2 * max(1, s(1))),
588
+ min(height // 2, s(28)) - max(1, s(1)))
589
+ image.paste(Image.new("RGB", inner_pill.size, brand.rgb(t["surface"])), (x0 + max(1, s(1)), y0 + max(1, s(1))),
590
+ inner_pill)
591
+ line_height = s(28)
592
+ ty = y0 + (height - line_height * len(full)) / 2 + s(3)
593
+ for index, line in enumerate(lines):
594
+ draw.text((x0 + s(22), ty + index * line_height), line, font=font, fill=brand.rgb(t["ink"]), anchor="la")
595
+ if typing:
596
+ end = x0 + s(22) + font.getlength(lines[-1]) + s(2)
597
+ cy = ty + (len(lines) - 1) * line_height
598
+ draw.rectangle((end, cy - s(1), end + max(2, s(2)), cy + s(23)), fill=brand.rgb(t["violet"]))
599
+ cx, cy, r = x0 + width - s(32), y0 + height / 2, s(19)
600
+ ready = not typing
601
+ draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=brand.rgb(t["primary"] if ready else t["line_strong"]))
602
+ arrow = brand.rgb(t["on_primary"]) if ready else (255, 255, 255)
603
+ draw.line((cx, cy + s(9), cx, cy - s(8)), fill=arrow, width=max(2, s(3)))
604
+ draw.line((cx - s(7), cy - s(1), cx, cy - s(8), cx + s(7), cy - s(1)), fill=arrow, width=max(2, s(3)),
605
+ joint="curve")
606
+
607
+ def _card(self, image, card, pressed):
608
+ """The approval as Mobster asked it: the act's question and its two answers, over the phone."""
609
+ from PIL import Image, ImageDraw, ImageFilter
610
+ s, t = self.s, self.theme
611
+ width = s(384)
612
+ title_font = self.fonts.get(s(19), 580, card["title"])
613
+ lines = _wrap(card["title"], title_font, width - s(40), 2)
614
+ height = s(66) + len(lines) * s(25) + s(52)
615
+ x0 = (self.size[0] - width) // 2
616
+ y0 = self.screen_box[1] + round(self.screen_size[1] * 0.50)
617
+ radius = s(20)
618
+ shadow = Image.new("L", image.size, 0)
619
+ ImageDraw.Draw(shadow).rounded_rectangle((x0, y0 + s(10), x0 + width, y0 + height + s(10)), radius, fill=255)
620
+ shadow = shadow.filter(ImageFilter.GaussianBlur(s(20)))
621
+ r, g, b, a = t["shadow"]
622
+ image.paste(Image.new("RGB", image.size, (r, g, b)), (0, 0), shadow.point(lambda v: min(255, v * a * 2 // 255)))
623
+ outline = self._rounded_mask((width, height), radius)
624
+ image.paste(Image.new("RGB", outline.size, brand.rgb(t["line"])), (x0, y0), outline)
625
+ body = self._rounded_mask((width - 2, height - 2), radius - 1)
626
+ image.paste(Image.new("RGB", body.size, brand.rgb(t["surface"])), (x0 + 1, y0 + 1), body)
627
+ draw = ImageDraw.Draw(image)
628
+ mark = self._mark(s(15))
629
+ image.paste(mark, (x0 + s(20), y0 + s(18)), mark)
630
+ small = self.fonts.get(s(14), 500)
631
+ draw.text((x0 + s(20) + mark.width + s(8), y0 + s(18) + mark.height / 2), "Mobster",
632
+ font=small, fill=brand.rgb(t["ink3"]), anchor="lm")
633
+ for index, line in enumerate(lines):
634
+ draw.text((x0 + s(20), y0 + s(46) + index * s(25)), line, font=title_font, fill=brand.rgb(t["ink"]),
635
+ anchor="la")
636
+ button = self.fonts.get(s(15), 500, card["yes"] + card["no"])
637
+ by = y0 + height - s(20) - s(36)
638
+ yes_w = max(s(78), round(button.getlength(card["yes"])) + s(36) + (s(20) if pressed else 0))
639
+ no_w = max(s(78), round(button.getlength(card["no"])) + s(36))
640
+ yes_x = x0 + width - s(20) - yes_w
641
+ no_x = yes_x - s(10) - no_w
642
+ approved = pressed and card.get("decision") == "approved"
643
+ declined = pressed and card.get("decision") in ("denied", "redirected", "timeout")
644
+ no_mask = self._rounded_mask((no_w, s(36)), s(18))
645
+ image.paste(Image.new("RGB", no_mask.size, brand.rgb(t["ink"] if declined else t["line_strong"])),
646
+ (no_x, by), no_mask)
647
+ no_in = self._rounded_mask((no_w - 2, s(36) - 2), s(18) - 1)
648
+ if not declined:
649
+ image.paste(Image.new("RGB", no_in.size, brand.rgb(t["surface"])), (no_x + 1, by + 1), no_in)
650
+ draw.text((no_x + no_w / 2, by + s(18)), card["no"], font=button,
651
+ fill=brand.rgb(t["surface"] if declined else t["ink"]), anchor="mm")
652
+ yes_mask = self._rounded_mask((yes_w, s(36)), s(18))
653
+ fill = t["primary_hover"] if approved else t["primary"]
654
+ image.paste(Image.new("RGB", yes_mask.size, brand.rgb(fill)), (yes_x, by), yes_mask)
655
+ if declined:
656
+ image.paste(Image.blend(image.crop((yes_x, by, yes_x + yes_w, by + s(36))),
657
+ Image.new("RGB", (yes_w, s(36)), brand.rgb(t["surface"])), 0.55), (yes_x, by),
658
+ yes_mask)
659
+ label_x = yes_x + yes_w / 2 + (s(10) if approved else 0)
660
+ draw.text((label_x, by + s(18)), card["yes"], font=button, fill=brand.rgb(t["on_primary"]), anchor="mm")
661
+ if approved:
662
+ cx = label_x - button.getlength(card["yes"]) / 2 - s(12)
663
+ cy = by + s(18)
664
+ draw.line((cx - s(6), cy, cx - s(2), cy + s(4), cx + s(6), cy - s(5)), fill=brand.rgb(t["on_primary"]),
665
+ width=max(2, s(2)), joint="curve")
666
+
667
+
668
+ def blur_regions(image, rects):
669
+ """``image`` with each normalized rect blurred past reading; ``rects`` None blurs all below the status bar."""
670
+ from PIL import Image, ImageFilter
671
+ width, height = image.size
672
+ if rects is None:
673
+ rects = [[0, STATUS_BAR[3], 1, 1 - STATUS_BAR[3]]]
674
+ out = image.copy()
675
+ for rect in rects:
676
+ try:
677
+ x, y, w, h = (float(value) for value in rect)
678
+ except (TypeError, ValueError):
679
+ continue
680
+ pad = 3
681
+ box = (max(0, int(x * width) - pad), max(0, int(y * height) - pad),
682
+ min(width, int(math.ceil((x + w) * width)) + pad), min(height, int(math.ceil((y + h) * height)) + pad))
683
+ if box[2] - box[0] < 2 or box[3] - box[1] < 2:
684
+ continue
685
+ region = out.crop(box)
686
+ small = region.resize((max(1, region.width // 10), max(1, region.height // 10)), Image.BOX)
687
+ region = small.resize(region.size, Image.BICUBIC).filter(ImageFilter.GaussianBlur(3))
688
+ out.paste(region, box[:2])
689
+ return out
690
+
691
+
692
+ def _frames(beats, read_frame, painter, redact, crossfade):
693
+ """[(RGB image, ms)] for every beat, with the title typed out and a short cross-fade between beats."""
694
+ from PIL import Image
695
+ cache = {}
696
+
697
+ def screen(beat, blur=0):
698
+ if not beat.frame_id:
699
+ return None
700
+ key = (beat.frame_id, blur, id(beat) if redact else None)
701
+ if key not in cache:
702
+ data = read_frame(beat.frame_id)
703
+ cache[key] = painter.screen(data, beat.rects, redact, blur) if data else None
704
+ return cache[key]
705
+
706
+ out = []
707
+
708
+ def add(image, ms):
709
+ if crossfade and out and ms > 0:
710
+ previous = out[-1][0]
711
+ for k in range(1, FADE_STEPS + 1):
712
+ t = k / (FADE_STEPS + 1)
713
+ eased = t * t * (3 - 2 * t)
714
+ out.append((Image.blend(previous, image, eased), FADE_FRAME_MS))
715
+ out.append((image, ms))
716
+
717
+ first = next((beat for beat in beats if beat.kind == "step" and beat.frame_id), None)
718
+ for beat in beats:
719
+ if beat.kind == "title":
720
+ backdrop = screen(first, blur=6) if first is not None else None
721
+ text = beat.caption
722
+ cuts = sorted({max(1, round(len(text) * k / 10)) for k in range(1, 11)}) if text else [0]
723
+ for cut in cuts[:-1]:
724
+ out.append((painter.frame(beat, backdrop, typed=cut), TYPE_FRAME_MS))
725
+ out.append((painter.frame(beat, backdrop), TITLE_HOLD_MS))
726
+ elif beat.kind == "step":
727
+ add(painter.frame(beat, screen(beat)), step_ms(beat.caption))
728
+ elif beat.kind == "ask":
729
+ shown = screen(beat)
730
+ add(painter.frame(beat, shown), ASK_MS)
731
+ answered = Beat("ask", beat.card["answer"], beat.frame_id, beat.rects, card=beat.card)
732
+ out.append((painter.frame(answered, shown, pressed=True), ANSWERED_MS))
733
+ elif beat.kind == "secret":
734
+ add(painter.frame(beat, None), SECRET_MS)
735
+ elif beat.kind == "end":
736
+ add(painter.frame(beat, screen(beat)), step_ms(beat.caption) + END_HOLD_MS)
737
+ return out
738
+
739
+
740
+ def _fixed_colors(theme):
741
+ names = ("ground", "surface", "sunken", "ink", "ink2", "ink3", "line", "line_strong", "violet", "primary_hover",
742
+ "green", "bezel", "rim")
743
+ mark = [rect[4] for rect in THEMES[theme]["pixel_mark"]]
744
+ return list(dict.fromkeys([brand.rgb(THEMES[theme][name]) for name in names]
745
+ + [brand.rgb(colour) for colour in mark]
746
+ + [(0, 0, 0), (255, 255, 255)]))
747
+
748
+
749
+ def _palette(image, colors, fixed):
750
+ """A palette for ``image``: the fixed colours, then the rest learned from it (median cut, at half size)."""
751
+ from PIL import Image
752
+ learned = image.reduce(2).quantize(colors=max(2, colors - len(fixed)), method=Image.Quantize.MEDIANCUT,
753
+ dither=Image.Dither.NONE)
754
+ values = learned.getpalette()[:3 * (colors - len(fixed))]
755
+ palette = [channel for color in fixed for channel in color] + values
756
+ holder = Image.new("P", (1, 1))
757
+ holder.putpalette(palette + [0] * (768 - len(palette)))
758
+ return holder
759
+
760
+
761
+ def _pin_fixed(frame, image, fixed):
762
+ """``frame`` with each exact fixed colour on its index: Pillow's coarse lookup would merge Night's greys."""
763
+ from PIL import Image, ImageChops
764
+ bands = image.split()
765
+ for index, colour in enumerate(fixed):
766
+ off = ImageChops.lighter(ImageChops.lighter(*[ImageChops.difference(band, Image.new("L", image.size, value))
767
+ for band, value in zip(bands[:2], colour)]),
768
+ ImageChops.difference(bands[2], Image.new("L", image.size, colour[2])))
769
+ frame.paste(index, mask=off.point(lambda v: 255 if v == 0 else 0))
770
+ return frame
771
+
772
+
773
+ def encode_gif(images, colors=256, theme="paper", dither=True):
774
+ """GIF bytes for [(RGB image, ms)], looping forever."""
775
+ from PIL import Image
776
+ fixed = _fixed_colors(theme)
777
+ mode = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
778
+ palettes, upcoming = [None] * len(images), None
779
+ for index in range(len(images) - 1, -1, -1):
780
+ image, ms = images[index]
781
+ if ms >= 200 or upcoming is None:
782
+ upcoming = _palette(image, colors, fixed)
783
+ palettes[index] = upcoming
784
+ # A passing frame is on screen for a moment: no dithering (a third of the time), the held ones dithered.
785
+ frames = [_pin_fixed(image.quantize(palette=palette, dither=mode if ms >= 200 else Image.Dither.NONE), image, fixed)
786
+ for (image, ms), palette in zip(images, palettes)]
787
+ out = io.BytesIO()
788
+ frames[0].save(out, "GIF", save_all=True, append_images=frames[1:], duration=[ms for _, ms in images],
789
+ loop=0, optimize=False, disposal=1)
790
+ return out.getvalue()
791
+
792
+
793
+ def build_clip(run, read_frame, *, theme="paper", redact=False, crossfade=True, fonts=None,
794
+ max_bytes=GIF_MAX_BYTES, scale=1.0):
795
+ """The run as a GIF under ``max_bytes``."""
796
+ attempts = [(scale, 256, crossfade), (scale, 128, crossfade), (scale, 128, False), (scale, 64, False),
797
+ (scale * .8, 64, False)]
798
+ return render_clip(plan_beats(run, redact=redact), read_frame, theme=theme, fonts=fonts, attempts=attempts,
799
+ max_bytes=max_bytes, redact=redact)
800
+
801
+
802
+ def render_clip(beats, read_frame, *, theme, fonts, attempts, max_bytes, redact=False):
803
+ aspect = _aspect(beats, read_frame)
804
+ if aspect is None:
805
+ raise ValueError(NO_SCREENS)
806
+ fonts = fonts if fonts is not None else Fonts()
807
+ for size, colors, fade in attempts:
808
+ images = _frames(beats, read_frame, Painter(theme, size, fonts, aspect), redact, fade)
809
+ data = encode_gif(images, colors, theme)
810
+ if len(data) <= max_bytes:
811
+ return Clip(data, len(images), [ms for _, ms in images], beats, colors, fade)
812
+ raise ValueError(TOO_LONG)
813
+
814
+
815
+ def _aspect(beats, read_frame):
816
+ from PIL import Image
817
+ for beat in beats:
818
+ if beat.frame_id:
819
+ data = read_frame(beat.frame_id)
820
+ if data:
821
+ try:
822
+ width, height = Image.open(io.BytesIO(data)).size
823
+ if width and height:
824
+ return max(0.3, min(1.0, width / height))
825
+ except Exception:
826
+ continue
827
+ return None
828
+
829
+
830
+ def ffmpeg_path():
831
+ found = shutil.which("ffmpeg")
832
+ if found:
833
+ return found
834
+ for candidate in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"):
835
+ if os.access(candidate, os.X_OK):
836
+ return candidate
837
+ return None
838
+
839
+
840
+ def build_mp4(run, read_frame, *, theme="paper", redact=False, fonts=None, scale=1.5, ffmpeg=None):
841
+ """The same clip as an H.264 MP4 (30 fps, sharper text: 1.5 times the GIF's size). None without ffmpeg."""
842
+ ffmpeg = ffmpeg or ffmpeg_path()
843
+ if ffmpeg is None:
844
+ return None
845
+ beats = plan_beats(run, redact=redact)
846
+ aspect = _aspect(beats, read_frame)
847
+ if aspect is None:
848
+ raise ValueError(NO_SCREENS)
849
+ painter = Painter(theme, scale, fonts if fonts is not None else Fonts(), aspect)
850
+ images = _frames(beats, read_frame, painter, redact, True)
851
+ return encode_mp4(images, ffmpeg)
852
+
853
+
854
+ MP4_FPS = 30
855
+
856
+
857
+ def encode_mp4(images, ffmpeg):
858
+ width, height = images[0][0].size
859
+ with tempfile.TemporaryDirectory(prefix="mobster-clip-") as folder:
860
+ target = Path(folder) / "clip.mp4"
861
+ command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
862
+ "-s", f"{width}x{height}", "-r", str(MP4_FPS), "-i", "-", "-vf", "format=yuv420p", "-c:v",
863
+ "libx264", "-preset", "medium", "-crf", "18", "-movflags", "+faststart", "-an", str(target)]
864
+ elapsed, shown, error, code = 0, 0, b"", -1
865
+ with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
866
+ stderr=subprocess.PIPE) as process:
867
+ try:
868
+ for image, ms in images:
869
+ elapsed += ms
870
+ count = round(elapsed * MP4_FPS / 1000) - shown
871
+ shown += count
872
+ data = image.convert("RGB").tobytes()
873
+ for _ in range(count):
874
+ process.stdin.write(data)
875
+ process.stdin.close()
876
+ error = process.stderr.read()
877
+ code = process.wait(timeout=300)
878
+ except (BrokenPipeError, subprocess.TimeoutExpired):
879
+ process.kill()
880
+ error = process.stderr.read()
881
+ if code != 0 or not target.is_file():
882
+ raise RuntimeError("ffmpeg couldn't make the MP4: " + error.decode(errors="replace")[-300:])
883
+ return target.read_bytes()
884
+
885
+
886
+ def file_stem(goal):
887
+ """The file's name without its extension: "Mobster – " and the task, at most 60 characters."""
888
+ words = " ".join(str(goal or "").split())
889
+ words = re.sub(r"[^\w .,'’()&+-]+", " ", words).strip(" .-")
890
+ words = " ".join(words.split())[:60].rstrip(" .-")
891
+ return f"Mobster – {words}" if words else "Mobster task"