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.
- mobile_agent/__init__.py +4 -0
- mobile_agent/__main__.py +598 -0
- mobile_agent/accounts/__init__.py +1 -0
- mobile_agent/accounts/context.py +80 -0
- mobile_agent/accounts/mcp.py +55 -0
- mobile_agent/accounts/pacing.py +318 -0
- mobile_agent/accounts/register.py +78 -0
- mobile_agent/accounts/routes.py +212 -0
- mobile_agent/accounts/routing.py +69 -0
- mobile_agent/accounts/screen.py +273 -0
- mobile_agent/accounts/stats.py +54 -0
- mobile_agent/accounts/store.py +394 -0
- mobile_agent/agent_hooks.py +123 -0
- mobile_agent/ai_plans/__init__.py +7 -0
- mobile_agent/ai_plans/agents.py +96 -0
- mobile_agent/ai_plans/claude_code.py +208 -0
- mobile_agent/ai_plans/cli.py +400 -0
- mobile_agent/ai_plans/codex.py +258 -0
- mobile_agent/ai_plans/compat_chat.py +307 -0
- mobile_agent/ai_plans/device_login.py +164 -0
- mobile_agent/ai_plans/draft_client.py +68 -0
- mobile_agent/ai_plans/gemini.py +137 -0
- mobile_agent/ai_plans/instructions.py +65 -0
- mobile_agent/ai_plans/mcp_http.py +160 -0
- mobile_agent/ai_plans/phone_tools.py +892 -0
- mobile_agent/ai_plans/providers.py +235 -0
- mobile_agent/ai_plans/routes.py +160 -0
- mobile_agent/ai_plans/runner.py +400 -0
- mobile_agent/ai_plans/vault.py +114 -0
- mobile_agent/alerts.py +337 -0
- mobile_agent/android/__init__.py +18 -0
- mobile_agent/android/adb.py +268 -0
- mobile_agent/android/conditions.py +128 -0
- mobile_agent/android/doctor.py +54 -0
- mobile_agent/android/driver.py +137 -0
- mobile_agent/android/emulator.py +216 -0
- mobile_agent/android/logs.py +85 -0
- mobile_agent/android/manager.py +225 -0
- mobile_agent/android/records.py +31 -0
- mobile_agent/android/tree.py +96 -0
- mobile_agent/answer_shapes.py +230 -0
- mobile_agent/api_devices.py +333 -0
- mobile_agent/api_errors.py +17 -0
- mobile_agent/api_get.py +249 -0
- mobile_agent/api_http.py +467 -0
- mobile_agent/api_post.py +453 -0
- mobile_agent/api_routes.py +173 -0
- mobile_agent/app_api/__init__.py +4 -0
- mobile_agent/app_api/cli.py +151 -0
- mobile_agent/app_api/endpoints.py +193 -0
- mobile_agent/app_api/mcp.py +54 -0
- mobile_agent/app_api/openapi.py +146 -0
- mobile_agent/app_api/reading.py +172 -0
- mobile_agent/app_api/register.py +10 -0
- mobile_agent/app_api/screen.py +216 -0
- mobile_agent/app_api/service.py +382 -0
- mobile_agent/app_api/sessions.py +404 -0
- mobile_agent/app_api/tokens.py +167 -0
- mobile_agent/app_choice.py +131 -0
- mobile_agent/app_icons.py +183 -0
- mobile_agent/app_killer.py +69 -0
- mobile_agent/artifacts.py +606 -0
- mobile_agent/attach/__init__.py +13 -0
- mobile_agent/attach/cli.py +46 -0
- mobile_agent/attach/flutter.py +28 -0
- mobile_agent/attach/score.py +119 -0
- mobile_agent/attach/web.py +13 -0
- mobile_agent/attachments/__init__.py +1 -0
- mobile_agent/attachments/agent.py +418 -0
- mobile_agent/attachments/extract.py +217 -0
- mobile_agent/attachments/mcp.py +104 -0
- mobile_agent/attachments/register.py +73 -0
- mobile_agent/attachments/routes.py +326 -0
- mobile_agent/attachments/service.py +170 -0
- mobile_agent/attachments/sniff.py +152 -0
- mobile_agent/attachments/store.py +441 -0
- mobile_agent/audit_verify.py +436 -0
- mobile_agent/bench/README.md +410 -0
- mobile_agent/bench/__init__.py +0 -0
- mobile_agent/bench/__main__.py +394 -0
- mobile_agent/bench/ab.py +168 -0
- mobile_agent/bench/actuation.py +368 -0
- mobile_agent/bench/agents/__init__.py +9 -0
- mobile_agent/bench/agents/base.py +105 -0
- mobile_agent/bench/agents/plan_clients.py +245 -0
- mobile_agent/bench/agents/smart.py +533 -0
- mobile_agent/bench/checks.py +358 -0
- mobile_agent/bench/decide_gate.py +214 -0
- mobile_agent/bench/diagnose.py +181 -0
- mobile_agent/bench/evals/__init__.py +14 -0
- mobile_agent/bench/evals/build.py +565 -0
- mobile_agent/bench/evals/checkpoint.py +214 -0
- mobile_agent/bench/evals/irt.py +219 -0
- mobile_agent/bench/evals/l0.py +647 -0
- mobile_agent/bench/evals/l1.py +866 -0
- mobile_agent/bench/evals/models.py +124 -0
- mobile_agent/bench/evals/pairwise.py +189 -0
- mobile_agent/bench/evals/proxy.py +90 -0
- mobile_agent/bench/evals/sets.py +149 -0
- mobile_agent/bench/evals/traces.py +446 -0
- mobile_agent/bench/fixtures.py +176 -0
- mobile_agent/bench/guard_replay.py +129 -0
- mobile_agent/bench/h2-manifest.json +20 -0
- mobile_agent/bench/h3-manifest.json +20 -0
- mobile_agent/bench/heldout-manifest.json +32 -0
- mobile_agent/bench/heldout.py +375 -0
- mobile_agent/bench/iosworld.py +901 -0
- mobile_agent/bench/iosworld_stats.py +510 -0
- mobile_agent/bench/llm_units.py +116 -0
- mobile_agent/bench/manifest.json +93 -0
- mobile_agent/bench/metrics.py +66 -0
- mobile_agent/bench/phone.py +44 -0
- mobile_agent/bench/replay.py +172 -0
- mobile_agent/bench/report.py +207 -0
- mobile_agent/bench/runner.py +456 -0
- mobile_agent/bench/safety.py +310 -0
- mobile_agent/bench/stream_timing.py +213 -0
- mobile_agent/bench/suite.py +444 -0
- mobile_agent/bench/sweep.py +176 -0
- mobile_agent/bench/tape.py +798 -0
- mobile_agent/bench/tape_replay.py +88 -0
- mobile_agent/bench/trace_evals.py +128 -0
- mobile_agent/bench/trace_view.py +81 -0
- mobile_agent/bench/truth/iphone15pro.json +138 -0
- mobile_agent/bench/truth.py +340 -0
- mobile_agent/bench/tuning.py +543 -0
- mobile_agent/bench/tuning_analysis.py +656 -0
- mobile_agent/bench/vague-heldout-manifest.json +12 -0
- mobile_agent/bench/vague_rubric.py +135 -0
- mobile_agent/bench/vague_run.py +243 -0
- mobile_agent/brand.py +454 -0
- mobile_agent/brand_fonts.py +756 -0
- mobile_agent/build_identity.py +21 -0
- mobile_agent/cable_bt.py +623 -0
- mobile_agent/cable_bt_runtime.py +128 -0
- mobile_agent/catalog.py +69 -0
- mobile_agent/chart_svg.py +415 -0
- mobile_agent/chat_here.py +189 -0
- mobile_agent/cli_constants.py +5 -0
- mobile_agent/cli_parser.py +611 -0
- mobile_agent/cli_run.py +115 -0
- mobile_agent/compiled_read/__init__.py +21 -0
- mobile_agent/compiled_read/delta.py +89 -0
- mobile_agent/compiled_read/extractors.py +202 -0
- mobile_agent/compiled_read/routes.py +56 -0
- mobile_agent/completion.py +267 -0
- mobile_agent/compose.py +48 -0
- mobile_agent/config.py +114 -0
- mobile_agent/connectors/__init__.py +1 -0
- mobile_agent/connectors/api_session.py +76 -0
- mobile_agent/connectors/callback_page.py +146 -0
- mobile_agent/connectors/client.py +582 -0
- mobile_agent/connectors/github_app.py +55 -0
- mobile_agent/connectors/graph_api.py +396 -0
- mobile_agent/connectors/imports.py +170 -0
- mobile_agent/connectors/issue.py +66 -0
- mobile_agent/connectors/keystore.py +85 -0
- mobile_agent/connectors/marks.py +222 -0
- mobile_agent/connectors/oauth.py +476 -0
- mobile_agent/connectors/picker.py +228 -0
- mobile_agent/connectors/presets.py +591 -0
- mobile_agent/connectors/register.py +48 -0
- mobile_agent/connectors/routes.py +153 -0
- mobile_agent/connectors/service.py +966 -0
- mobile_agent/connectors/shared.py +136 -0
- mobile_agent/connectors/skill.py +446 -0
- mobile_agent/connectors/slack_api.py +269 -0
- mobile_agent/connectors/steps.py +92 -0
- mobile_agent/connectors/store.py +144 -0
- mobile_agent/connectors/vault.py +72 -0
- mobile_agent/console.py +178 -0
- mobile_agent/content_queue/__init__.py +1 -0
- mobile_agent/content_queue/client.py +263 -0
- mobile_agent/content_queue/importing.py +153 -0
- mobile_agent/content_queue/media_check.py +224 -0
- mobile_agent/content_queue/prompt.py +64 -0
- mobile_agent/content_queue/register.py +91 -0
- mobile_agent/content_queue/routes.py +370 -0
- mobile_agent/content_queue/runner.py +475 -0
- mobile_agent/content_queue/store.py +521 -0
- mobile_agent/contract.py +996 -0
- mobile_agent/contract_asks.py +248 -0
- mobile_agent/contract_prompt.py +45 -0
- mobile_agent/contract_screen.py +981 -0
- mobile_agent/contract_view.py +84 -0
- mobile_agent/corpus/__init__.py +26 -0
- mobile_agent/corpus/cli.py +63 -0
- mobile_agent/corpus/consent.py +55 -0
- mobile_agent/corpus/listener.py +76 -0
- mobile_agent/corpus/register.py +9 -0
- mobile_agent/corpus/skeleton.py +72 -0
- mobile_agent/corpus/store.py +143 -0
- mobile_agent/costs.py +161 -0
- mobile_agent/decider_scope.py +40 -0
- mobile_agent/decision_provider.py +189 -0
- mobile_agent/deeplinks.py +352 -0
- mobile_agent/device_info.py +116 -0
- mobile_agent/device_manager.py +978 -0
- mobile_agent/device_status.py +195 -0
- mobile_agent/device_targets.py +217 -0
- mobile_agent/devices.py +676 -0
- mobile_agent/devkit/__init__.py +64 -0
- mobile_agent/devkit/account_cli.py +90 -0
- mobile_agent/devkit/asc.py +562 -0
- mobile_agent/devkit/asc_cli.py +214 -0
- mobile_agent/devkit/ci.py +737 -0
- mobile_agent/devkit/ci_cli.py +196 -0
- mobile_agent/devkit/ci_hosts.py +194 -0
- mobile_agent/devkit/es256.py +197 -0
- mobile_agent/devkit/expo.py +440 -0
- mobile_agent/devkit/expo_cli.py +405 -0
- mobile_agent/devkit/expo_dev.py +203 -0
- mobile_agent/devkit/expo_routes.py +90 -0
- mobile_agent/devkit/fixtures_cli.py +133 -0
- mobile_agent/devkit/fixtures_routes.py +82 -0
- mobile_agent/devkit/links.py +375 -0
- mobile_agent/devkit/links_cli.py +123 -0
- mobile_agent/devkit/links_routes.py +83 -0
- mobile_agent/devkit/nightly.py +152 -0
- mobile_agent/devkit/shots.py +203 -0
- mobile_agent/devkit/testflight.py +155 -0
- mobile_agent/devkit/testflight_cli.py +127 -0
- mobile_agent/devkit/testflight_routes.py +111 -0
- mobile_agent/devkit/xcode.py +236 -0
- mobile_agent/devkit/xcode_cli.py +101 -0
- mobile_agent/devtools.py +419 -0
- mobile_agent/doctor.py +509 -0
- mobile_agent/doctor_fix.py +467 -0
- mobile_agent/downloads.py +55 -0
- mobile_agent/drivers.py +793 -0
- mobile_agent/dry_run.py +102 -0
- mobile_agent/effect_ledger.py +83 -0
- mobile_agent/engine_health.py +378 -0
- mobile_agent/engine_hooks.py +96 -0
- mobile_agent/engines.py +408 -0
- mobile_agent/env_file.py +63 -0
- mobile_agent/env_secrets.py +149 -0
- mobile_agent/errors.py +15 -0
- mobile_agent/evals/__init__.py +3 -0
- mobile_agent/evals/app_survey.py +149 -0
- mobile_agent/evals/development/__init__.py +1 -0
- mobile_agent/evals/development/score.py +276 -0
- mobile_agent/evals/latency_report.py +312 -0
- mobile_agent/evals/offline_report.py +374 -0
- mobile_agent/evals/oracles.py +298 -0
- mobile_agent/evals/redteam/__init__.py +15 -0
- mobile_agent/evals/redteam/__main__.py +72 -0
- mobile_agent/evals/redteam/cases.py +730 -0
- mobile_agent/evals/redteam/harness.py +604 -0
- mobile_agent/evals/redteam/live.py +139 -0
- mobile_agent/evals/redteam/plain.py +85 -0
- mobile_agent/evals/vision/__init__.py +0 -0
- mobile_agent/evals/vision/images/dog_001.jpg +0 -0
- mobile_agent/evals/vision/images/dog_002.jpg +0 -0
- mobile_agent/evals/vision/images/dog_003.jpg +0 -0
- mobile_agent/evals/vision/images/dog_004.jpg +0 -0
- mobile_agent/evals/vision/images/dog_005.jpg +0 -0
- mobile_agent/evals/vision/images/dog_006.jpg +0 -0
- mobile_agent/evals/vision/images/dog_007.jpg +0 -0
- mobile_agent/evals/vision/images/dog_010.jpg +0 -0
- mobile_agent/evals/vision/images/dog_011.jpg +0 -0
- mobile_agent/evals/vision/images/dog_013.jpg +0 -0
- mobile_agent/evals/vision/images/dog_014.jpg +0 -0
- mobile_agent/evals/vision/images/dog_015.jpg +0 -0
- mobile_agent/evals/vision/images/dog_016.jpg +0 -0
- mobile_agent/evals/vision/images/dog_017.jpg +0 -0
- mobile_agent/evals/vision/images/dog_019.jpg +0 -0
- mobile_agent/evals/vision/images/dog_020.jpg +0 -0
- mobile_agent/evals/vision/images/dog_021.jpg +0 -0
- mobile_agent/evals/vision/images/dog_022.jpg +0 -0
- mobile_agent/evals/vision/images/dog_023.jpg +0 -0
- mobile_agent/evals/vision/images/dog_024.jpg +0 -0
- mobile_agent/evals/vision/images/dog_026.jpg +0 -0
- mobile_agent/evals/vision/images/dog_027.jpg +0 -0
- mobile_agent/evals/vision/images/dog_034.jpg +0 -0
- mobile_agent/evals/vision/images/dog_035.jpg +0 -0
- mobile_agent/evals/vision/images/dog_038.jpg +0 -0
- mobile_agent/evals/vision/images/dog_042.jpg +0 -0
- mobile_agent/evals/vision/images/dog_047.jpg +0 -0
- mobile_agent/evals/vision/images/dog_052.jpg +0 -0
- mobile_agent/evals/vision/images/dog_058.jpg +0 -0
- mobile_agent/evals/vision/images/dog_062.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
- mobile_agent/evals/vision/manifest.json +2355 -0
- mobile_agent/eventbus.py +110 -0
- mobile_agent/explain.py +34 -0
- mobile_agent/failure_line.py +197 -0
- mobile_agent/fanout.py +663 -0
- mobile_agent/feedback/__init__.py +2 -0
- mobile_agent/feedback/bundle.py +204 -0
- mobile_agent/feedback/outbox.py +196 -0
- mobile_agent/feedback/register.py +9 -0
- mobile_agent/feedback/routes.py +177 -0
- mobile_agent/fleet.py +841 -0
- mobile_agent/fleet_health.py +729 -0
- mobile_agent/fleet_phone.py +186 -0
- mobile_agent/frontier/__init__.py +79 -0
- mobile_agent/frontier/act.py +380 -0
- mobile_agent/frontier/actions.py +70 -0
- mobile_agent/frontier/agent.py +256 -0
- mobile_agent/frontier/approvals.py +366 -0
- mobile_agent/frontier/hooks.py +199 -0
- mobile_agent/frontier/interrupts.py +125 -0
- mobile_agent/frontier/observe.py +331 -0
- mobile_agent/frontier/phases.py +381 -0
- mobile_agent/frontier/pipeline.py +61 -0
- mobile_agent/frontier/prompt.py +384 -0
- mobile_agent/frontier/protocol.py +91 -0
- mobile_agent/frontier/safety.py +467 -0
- mobile_agent/frontier/saved.py +375 -0
- mobile_agent/frontier/screen.py +419 -0
- mobile_agent/frontier/skills.py +147 -0
- mobile_agent/frontier/stages/__init__.py +1 -0
- mobile_agent/frontier/stages/after.py +192 -0
- mobile_agent/frontier/stages/approve.py +138 -0
- mobile_agent/frontier/stages/controls.py +115 -0
- mobile_agent/frontier/stages/ends.py +102 -0
- mobile_agent/frontier/stages/gate.py +92 -0
- mobile_agent/frontier/stages/guards.py +132 -0
- mobile_agent/frontier/stages/points.py +155 -0
- mobile_agent/frontier/stages/send.py +177 -0
- mobile_agent/frontier/turn.py +151 -0
- mobile_agent/full_disk_access.py +83 -0
- mobile_agent/gemini.py +69 -0
- mobile_agent/genuine.py +120 -0
- mobile_agent/graph/__init__.py +4 -0
- mobile_agent/graph/catalog.py +789 -0
- mobile_agent/graph/chain.py +306 -0
- mobile_agent/graph/dispatch.py +98 -0
- mobile_agent/graph/draft.py +373 -0
- mobile_agent/graph/edit.py +76 -0
- mobile_agent/graph/estimate.py +189 -0
- mobile_agent/graph/expr.py +562 -0
- mobile_agent/graph/functions.py +271 -0
- mobile_agent/graph/infer.py +144 -0
- mobile_agent/graph/nodes.py +936 -0
- mobile_agent/graph/phone_steps.py +178 -0
- mobile_agent/graph/rerun.py +327 -0
- mobile_agent/graph/run.py +899 -0
- mobile_agent/graph/schema.py +842 -0
- mobile_agent/graph/state.py +161 -0
- mobile_agent/graph/values.py +198 -0
- mobile_agent/harness/__init__.py +2 -0
- mobile_agent/harness/checkpoints.py +468 -0
- mobile_agent/harness/control.py +101 -0
- mobile_agent/harness/prewarm.py +213 -0
- mobile_agent/harness/register.py +43 -0
- mobile_agent/harness/routes.py +114 -0
- mobile_agent/harness/settings.py +66 -0
- mobile_agent/harness/tools/__init__.py +67 -0
- mobile_agent/harness/tools/ask_user.py +102 -0
- mobile_agent/harness/tools/watch.py +120 -0
- mobile_agent/harness_api.py +815 -0
- mobile_agent/hedge.py +203 -0
- mobile_agent/help_topics.py +305 -0
- mobile_agent/hints.py +248 -0
- mobile_agent/history.py +69 -0
- mobile_agent/home.py +214 -0
- mobile_agent/http_pool.py +175 -0
- mobile_agent/image_policy.py +139 -0
- mobile_agent/inbox.py +148 -0
- mobile_agent/installed_apps.py +344 -0
- mobile_agent/integrations/__init__.py +1 -0
- mobile_agent/integrations/cli.py +472 -0
- mobile_agent/integrations/client_files.py +65 -0
- mobile_agent/integrations/clients.py +998 -0
- mobile_agent/integrations/doctor.py +185 -0
- mobile_agent/integrations/jsonc.py +383 -0
- mobile_agent/integrations/routes.py +135 -0
- mobile_agent/integrations/rules.py +112 -0
- mobile_agent/integrations/server_options.py +46 -0
- mobile_agent/integrations/skill.py +152 -0
- mobile_agent/integrations/skill_files.py +10 -0
- mobile_agent/integrations/textedit.py +309 -0
- mobile_agent/intents/__init__.py +17 -0
- mobile_agent/intents/atlas.py +216 -0
- mobile_agent/intents/callback.py +44 -0
- mobile_agent/intents/cli.py +55 -0
- mobile_agent/intents/harvest.py +91 -0
- mobile_agent/intents/plan.py +93 -0
- mobile_agent/ios/__init__.py +1 -0
- mobile_agent/ios/guide.py +16 -0
- mobile_agent/ios/kb.py +212 -0
- mobile_agent/ios/kb_data.py +1588 -0
- mobile_agent/ios/mcp.py +44 -0
- mobile_agent/ios/register.py +8 -0
- mobile_agent/ios/system.py +369 -0
- mobile_agent/journal.py +531 -0
- mobile_agent/keychain_acl.py +374 -0
- mobile_agent/keys.py +395 -0
- mobile_agent/latency_overlap.py +36 -0
- mobile_agent/latency_trace.py +170 -0
- mobile_agent/learned/__init__.py +15 -0
- mobile_agent/learned/app_path.py +160 -0
- mobile_agent/learned/code_read.py +38 -0
- mobile_agent/learned/parts.py +546 -0
- mobile_agent/learned/programs.py +691 -0
- mobile_agent/learned/register.py +171 -0
- mobile_agent/learned/routes.py +61 -0
- mobile_agent/learned/taught.py +329 -0
- mobile_agent/learned/teach.py +538 -0
- mobile_agent/learned/workflows.py +647 -0
- mobile_agent/learned/zero.py +41 -0
- mobile_agent/links.py +37 -0
- mobile_agent/listen.py +607 -0
- mobile_agent/lock_spike.py +278 -0
- mobile_agent/lockscreen.py +526 -0
- mobile_agent/login.py +354 -0
- mobile_agent/longform/__init__.py +15 -0
- mobile_agent/longform/ads.py +191 -0
- mobile_agent/longform/blocks.py +117 -0
- mobile_agent/longform/budget.py +265 -0
- mobile_agent/longform/context.py +285 -0
- mobile_agent/longform/costs.py +93 -0
- mobile_agent/longform/digest.py +145 -0
- mobile_agent/longform/extract.py +508 -0
- mobile_agent/longform/feed.py +505 -0
- mobile_agent/longform/followup.py +168 -0
- mobile_agent/longform/frames.py +82 -0
- mobile_agent/longform/identity.py +87 -0
- mobile_agent/longform/inbox.py +221 -0
- mobile_agent/longform/items.py +425 -0
- mobile_agent/longform/models.py +115 -0
- mobile_agent/longform/notebook.py +329 -0
- mobile_agent/longform/plan.py +614 -0
- mobile_agent/longform/recall.py +195 -0
- mobile_agent/longform/receipts.py +81 -0
- mobile_agent/longform/register.py +124 -0
- mobile_agent/longform/report.py +343 -0
- mobile_agent/longform/routes.py +102 -0
- mobile_agent/longform/session.py +644 -0
- mobile_agent/longform/store.py +146 -0
- mobile_agent/longform/synthesis.py +347 -0
- mobile_agent/mac_tools.py +223 -0
- mobile_agent/manual_control.py +143 -0
- mobile_agent/maps.py +149 -0
- mobile_agent/marks.py +92 -0
- mobile_agent/mcp_server/__init__.py +3 -0
- mobile_agent/mcp_server/__main__.py +29 -0
- mobile_agent/mcp_server/action_journal.py +24 -0
- mobile_agent/mcp_server/action_tools.py +638 -0
- mobile_agent/mcp_server/approvals.py +219 -0
- mobile_agent/mcp_server/catalog.py +92 -0
- mobile_agent/mcp_server/cli.py +194 -0
- mobile_agent/mcp_server/codes.py +147 -0
- mobile_agent/mcp_server/device_tools.py +306 -0
- mobile_agent/mcp_server/direct.py +272 -0
- mobile_agent/mcp_server/expo_tools.py +105 -0
- mobile_agent/mcp_server/phone_tools.py +510 -0
- mobile_agent/mcp_server/protocol.py +364 -0
- mobile_agent/mcp_server/registry.py +55 -0
- mobile_agent/mcp_server/run_tools.py +531 -0
- mobile_agent/mcp_server/schemas.py +425 -0
- mobile_agent/mcp_server/screen_tools.py +168 -0
- mobile_agent/mcp_server/session.py +469 -0
- mobile_agent/mcp_server/support.py +145 -0
- mobile_agent/mcp_server/tools.py +493 -0
- mobile_agent/memory/__init__.py +9 -0
- mobile_agent/memory/cli.py +207 -0
- mobile_agent/memory/extract.py +191 -0
- mobile_agent/memory/listener.py +100 -0
- mobile_agent/memory/model.py +144 -0
- mobile_agent/memory/project.py +96 -0
- mobile_agent/memory/provider.py +127 -0
- mobile_agent/memory/register.py +21 -0
- mobile_agent/memory/retrieval.py +172 -0
- mobile_agent/memory/routes.py +216 -0
- mobile_agent/memory/runs.py +124 -0
- mobile_agent/memory/store.py +806 -0
- mobile_agent/memory/writer.py +278 -0
- mobile_agent/mirroring.py +585 -0
- mobile_agent/mirroring_runtime.py +221 -0
- mobile_agent/model_clients.py +604 -0
- mobile_agent/narrate.py +813 -0
- mobile_agent/native_helpers.py +84 -0
- mobile_agent/netxray/__init__.py +1 -0
- mobile_agent/netxray/analyze.py +322 -0
- mobile_agent/netxray/attribution.py +62 -0
- mobile_agent/netxray/capture.py +88 -0
- mobile_agent/netxray/cli.py +142 -0
- mobile_agent/netxray/job.py +219 -0
- mobile_agent/netxray/mobster-pcap.c +560 -0
- mobile_agent/netxray/packets.py +410 -0
- mobile_agent/netxray/pcapng.py +223 -0
- mobile_agent/netxray/register.py +93 -0
- mobile_agent/netxray/report.py +214 -0
- mobile_agent/netxray/store.py +48 -0
- mobile_agent/netxray/summary.py +157 -0
- mobile_agent/netxray/target.py +67 -0
- mobile_agent/netxray/tool.py +66 -0
- mobile_agent/netxray/trackers.py +194 -0
- mobile_agent/notify_triggers/__init__.py +1 -0
- mobile_agent/notify_triggers/rules.py +39 -0
- mobile_agent/notify_triggers/sources.py +315 -0
- mobile_agent/notify_triggers/watch.py +135 -0
- mobile_agent/offers.py +86 -0
- mobile_agent/orchestrate.py +215 -0
- mobile_agent/outlets/__init__.py +1 -0
- mobile_agent/outlets/connect.py +249 -0
- mobile_agent/outlets/folder.py +180 -0
- mobile_agent/outlets/hooks.py +453 -0
- mobile_agent/outlets/inbound.py +472 -0
- mobile_agent/outlets/inbound_reply.py +57 -0
- mobile_agent/outlets/inbound_routes.py +81 -0
- mobile_agent/outlets/kinds.py +308 -0
- mobile_agent/outlets/mail.py +110 -0
- mobile_agent/outlets/net.py +86 -0
- mobile_agent/outlets/notion.py +244 -0
- mobile_agent/outlets/register.py +56 -0
- mobile_agent/outlets/routes.py +135 -0
- mobile_agent/outlets/service.py +580 -0
- mobile_agent/outlets/sheets.py +169 -0
- mobile_agent/outlets/slack.py +177 -0
- mobile_agent/outlets/store.py +194 -0
- mobile_agent/outlets/vault.py +80 -0
- mobile_agent/outlets/webhook.py +316 -0
- mobile_agent/output_contract.py +141 -0
- mobile_agent/owner_presence.py +211 -0
- mobile_agent/ownership.py +219 -0
- mobile_agent/packs/__init__.py +1 -0
- mobile_agent/packs/calendar.py +243 -0
- mobile_agent/packs/context.py +68 -0
- mobile_agent/packs/core.py +757 -0
- mobile_agent/packs/front_door.py +316 -0
- mobile_agent/packs/heal.py +214 -0
- mobile_agent/packs/health.py +122 -0
- mobile_agent/packs/insights.py +76 -0
- mobile_agent/packs/instagram.py +640 -0
- mobile_agent/packs/linkedin.py +106 -0
- mobile_agent/packs/maps.py +196 -0
- mobile_agent/packs/mcp.py +149 -0
- mobile_agent/packs/messages.py +270 -0
- mobile_agent/packs/notes.py +226 -0
- mobile_agent/packs/photos.py +166 -0
- mobile_agent/packs/pins.py +47 -0
- mobile_agent/packs/reddit.py +110 -0
- mobile_agent/packs/register.py +276 -0
- mobile_agent/packs/rows.py +108 -0
- mobile_agent/packs/safari.py +143 -0
- mobile_agent/packs/settings.py +253 -0
- mobile_agent/packs/social.py +300 -0
- mobile_agent/packs/threads.py +97 -0
- mobile_agent/packs/tiktok.py +455 -0
- mobile_agent/packs/tools.py +290 -0
- mobile_agent/packs/x.py +127 -0
- mobile_agent/packs/youtube.py +199 -0
- mobile_agent/parity.py +270 -0
- mobile_agent/paths.py +99 -0
- mobile_agent/perception.py +470 -0
- mobile_agent/permissions/__init__.py +32 -0
- mobile_agent/permissions/consequence.py +535 -0
- mobile_agent/permissions/exceptions.py +99 -0
- mobile_agent/permissions/gate.py +316 -0
- mobile_agent/permissions/outside.py +316 -0
- mobile_agent/permissions/policy.py +288 -0
- mobile_agent/permissions/project_policy.py +411 -0
- mobile_agent/permissions/register.py +148 -0
- mobile_agent/permissions/rules.py +291 -0
- mobile_agent/phone_io/__init__.py +146 -0
- mobile_agent/phone_io/cli.py +427 -0
- mobile_agent/phone_io/clipboard.py +78 -0
- mobile_agent/phone_io/controls.py +78 -0
- mobile_agent/phone_io/files.py +289 -0
- mobile_agent/phone_io/handoff.py +123 -0
- mobile_agent/phone_io/icloud.py +118 -0
- mobile_agent/phone_io/install.py +188 -0
- mobile_agent/phone_io/network_capture.py +188 -0
- mobile_agent/phone_io/network_cli.py +138 -0
- mobile_agent/phone_io/network_profiles.py +138 -0
- mobile_agent/phone_io/photos.py +512 -0
- mobile_agent/phone_io/recording.py +115 -0
- mobile_agent/phone_io/secrets.py +146 -0
- mobile_agent/phone_io/transfer.py +895 -0
- mobile_agent/phone_io/tunnel.py +129 -0
- mobile_agent/phone_ready.py +91 -0
- mobile_agent/pinning.py +158 -0
- mobile_agent/plan_gate.py +807 -0
- mobile_agent/plugins/__init__.py +3 -0
- mobile_agent/plugins/install.py +163 -0
- mobile_agent/plugins/manifest.py +242 -0
- mobile_agent/plugins/register.py +15 -0
- mobile_agent/plugins/routes.py +82 -0
- mobile_agent/plugins/service.py +322 -0
- mobile_agent/plugins/skills.py +76 -0
- mobile_agent/plugins/store.py +79 -0
- mobile_agent/pool.py +285 -0
- mobile_agent/power_watch.py +275 -0
- mobile_agent/premium_model.py +22 -0
- mobile_agent/project_env.py +291 -0
- mobile_agent/project_env_cli.py +74 -0
- mobile_agent/project_env_routes.py +55 -0
- mobile_agent/projects/__init__.py +27 -0
- mobile_agent/projects/ci.py +332 -0
- mobile_agent/projects/clone.py +339 -0
- mobile_agent/projects/drafts.py +158 -0
- mobile_agent/projects/folder.py +436 -0
- mobile_agent/projects/github.py +131 -0
- mobile_agent/projects/register.py +31 -0
- mobile_agent/projects/revisions.py +253 -0
- mobile_agent/projects/routes.py +234 -0
- mobile_agent/projects/runs.py +297 -0
- mobile_agent/projects/service.py +695 -0
- mobile_agent/projects/setup.py +84 -0
- mobile_agent/projects/store.py +379 -0
- mobile_agent/projects/sync.py +380 -0
- mobile_agent/projects/workflow_files.py +270 -0
- mobile_agent/replay_core/__init__.py +26 -0
- mobile_agent/replay_core/checks.py +594 -0
- mobile_agent/replay_core/engine.py +596 -0
- mobile_agent/replay_core/heal.py +364 -0
- mobile_agent/replay_core/path.py +240 -0
- mobile_agent/replay_core/recorder.py +206 -0
- mobile_agent/replay_core/store.py +171 -0
- mobile_agent/result_blocks.py +941 -0
- mobile_agent/run.py +642 -0
- mobile_agent/run_delivery.py +123 -0
- mobile_agent/run_executor.py +200 -0
- mobile_agent/run_export.py +284 -0
- mobile_agent/run_gif.py +891 -0
- mobile_agent/runner_hints.py +86 -0
- mobile_agent/runner_renewal.py +205 -0
- mobile_agent/runtime_create.py +549 -0
- mobile_agent/runtime_driver.py +458 -0
- mobile_agent/runtime_limits.py +319 -0
- mobile_agent/runtime_queue.py +394 -0
- mobile_agent/runtime_runs.py +383 -0
- mobile_agent/runtime_status.py +181 -0
- mobile_agent/runtime_teach.py +295 -0
- mobile_agent/runtime_work.py +599 -0
- mobile_agent/screen_text.py +101 -0
- mobile_agent/secret_filter.py +113 -0
- mobile_agent/serve.py +160 -0
- mobile_agent/server.py +363 -0
- mobile_agent/service_support.py +42 -0
- mobile_agent/settle/__init__.py +1 -0
- mobile_agent/settle/predict.py +102 -0
- mobile_agent/settle/replay.py +92 -0
- mobile_agent/settle/store.py +93 -0
- mobile_agent/settle/stream.py +43 -0
- mobile_agent/setup_cli.py +225 -0
- mobile_agent/setup_errors.py +87 -0
- mobile_agent/setup_service.py +527 -0
- mobile_agent/share.py +104 -0
- mobile_agent/signing.py +184 -0
- mobile_agent/sim/__init__.py +6 -0
- mobile_agent/sim/__main__.py +26 -0
- mobile_agent/sim/api.py +952 -0
- mobile_agent/sim/apps.py +172 -0
- mobile_agent/sim/biometrics.py +84 -0
- mobile_agent/sim/cli.py +271 -0
- mobile_agent/sim/doctor.py +293 -0
- mobile_agent/sim/governor.py +90 -0
- mobile_agent/sim/holders.py +88 -0
- mobile_agent/sim/models.py +62 -0
- mobile_agent/sim/registry.py +161 -0
- mobile_agent/sim/simctl.py +269 -0
- mobile_agent/sim/wda.py +618 -0
- mobile_agent/sim/xcode.py +74 -0
- mobile_agent/skills/__init__.py +12 -0
- mobile_agent/skills/codes.py +597 -0
- mobile_agent/skills/notifications.py +281 -0
- mobile_agent/skills/open_url.py +127 -0
- mobile_agent/smart_events.py +588 -0
- mobile_agent/smart_result.py +490 -0
- mobile_agent/smart_run.py +413 -0
- mobile_agent/state.py +662 -0
- mobile_agent/states.py +217 -0
- mobile_agent/steering.py +70 -0
- mobile_agent/style.py +178 -0
- mobile_agent/supervised.py +234 -0
- mobile_agent/swift/helper_bt_hid.swift +534 -0
- mobile_agent/swift/helper_doc_text.swift +184 -0
- mobile_agent/swift/helper_draft_reply.swift +170 -0
- mobile_agent/swift/helper_map.swift +110 -0
- mobile_agent/swift/helper_mirroring.swift +1099 -0
- mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
- mobile_agent/swift/helper_pdf.swift +100 -0
- mobile_agent/swift/helper_phone_audio.swift +993 -0
- mobile_agent/swift/helper_phone_screen.swift +416 -0
- mobile_agent/swift/helper_screen_text.swift +24 -0
- mobile_agent/swift/helper_transfer.swift +246 -0
- mobile_agent/swift/ocr.swift +22 -0
- mobile_agent/task_policy.py +337 -0
- mobile_agent/team_signing/__init__.py +34 -0
- mobile_agent/team_signing/apple_ca.py +37 -0
- mobile_agent/team_signing/bundle.py +136 -0
- mobile_agent/team_signing/cli.py +208 -0
- mobile_agent/team_signing/identity.py +248 -0
- mobile_agent/team_signing/ledger.py +105 -0
- mobile_agent/team_signing/prebuilt.py +61 -0
- mobile_agent/team_signing/resign.py +256 -0
- mobile_agent/team_signing/service.py +382 -0
- mobile_agent/team_signing/tooling.py +243 -0
- mobile_agent/team_signing/writes.py +268 -0
- mobile_agent/terminal_image.py +81 -0
- mobile_agent/testkit/__init__.py +1 -0
- mobile_agent/testkit/a11y_baseline.py +132 -0
- mobile_agent/testkit/api.py +403 -0
- mobile_agent/testkit/capsule.py +400 -0
- mobile_agent/testkit/capsule_cli.py +57 -0
- mobile_agent/testkit/cli.py +948 -0
- mobile_agent/testkit/cli_arguments.py +193 -0
- mobile_agent/testkit/comparison.py +288 -0
- mobile_agent/testkit/comparison_cli.py +41 -0
- mobile_agent/testkit/discover.py +257 -0
- mobile_agent/testkit/editor.py +243 -0
- mobile_agent/testkit/execute.py +515 -0
- mobile_agent/testkit/github_checks.py +248 -0
- mobile_agent/testkit/gitref.py +66 -0
- mobile_agent/testkit/heal_review.py +176 -0
- mobile_agent/testkit/health.py +480 -0
- mobile_agent/testkit/history.py +255 -0
- mobile_agent/testkit/hosted_results.py +321 -0
- mobile_agent/testkit/input_files.py +173 -0
- mobile_agent/testkit/jobs.py +285 -0
- mobile_agent/testkit/junit_verdict.py +33 -0
- mobile_agent/testkit/maestro.py +802 -0
- mobile_agent/testkit/mcp.py +562 -0
- mobile_agent/testkit/mcp_comparison.py +88 -0
- mobile_agent/testkit/minimize.py +471 -0
- mobile_agent/testkit/minimizer_cli.py +48 -0
- mobile_agent/testkit/notify.py +212 -0
- mobile_agent/testkit/phone_video.py +191 -0
- mobile_agent/testkit/pr.py +491 -0
- mobile_agent/testkit/pr_report.py +562 -0
- mobile_agent/testkit/prbuild.py +148 -0
- mobile_agent/testkit/proof_report.py +55 -0
- mobile_agent/testkit/provenance.py +119 -0
- mobile_agent/testkit/prrun.py +244 -0
- mobile_agent/testkit/recent.py +166 -0
- mobile_agent/testkit/recipes.py +146 -0
- mobile_agent/testkit/record.py +204 -0
- mobile_agent/testkit/register.py +288 -0
- mobile_agent/testkit/reports.py +998 -0
- mobile_agent/testkit/repro.py +432 -0
- mobile_agent/testkit/scenario.py +440 -0
- mobile_agent/testkit/scenario_cli.py +37 -0
- mobile_agent/testkit/snapshots_cli.py +60 -0
- mobile_agent/testkit/suite.py +670 -0
- mobile_agent/testkit/targets.py +497 -0
- mobile_agent/testkit/xctest.py +378 -0
- mobile_agent/threads/__init__.py +1 -0
- mobile_agent/threads/chat_once.py +264 -0
- mobile_agent/threads/cli.py +740 -0
- mobile_agent/threads/client.py +326 -0
- mobile_agent/threads/context.py +246 -0
- mobile_agent/threads/mcp.py +739 -0
- mobile_agent/threads/recent.py +34 -0
- mobile_agent/threads/register.py +54 -0
- mobile_agent/threads/routes.py +188 -0
- mobile_agent/threads/service.py +975 -0
- mobile_agent/threads/store.py +361 -0
- mobile_agent/threads/summary.py +117 -0
- mobile_agent/threads/term.py +101 -0
- mobile_agent/threads/workflow_cli.py +104 -0
- mobile_agent/tls.py +36 -0
- mobile_agent/traces.py +271 -0
- mobile_agent/tracks.py +235 -0
- mobile_agent/transport.py +271 -0
- mobile_agent/triggers/__init__.py +1 -0
- mobile_agent/triggers/actions.py +204 -0
- mobile_agent/triggers/model.py +146 -0
- mobile_agent/triggers/register.py +29 -0
- mobile_agent/triggers/routes.py +101 -0
- mobile_agent/triggers/service.py +387 -0
- mobile_agent/triggers/store.py +142 -0
- mobile_agent/tui/__init__.py +89 -0
- mobile_agent/tui/app.py +582 -0
- mobile_agent/tui/chrome.py +321 -0
- mobile_agent/tui/commands.py +375 -0
- mobile_agent/tui/demo_phone.py +369 -0
- mobile_agent/tui/images.py +29 -0
- mobile_agent/tui/keys.py +64 -0
- mobile_agent/tui/prompt.py +195 -0
- mobile_agent/tui/render.py +395 -0
- mobile_agent/tui/screens.py +180 -0
- mobile_agent/tui/session.py +308 -0
- mobile_agent/tui/tasks.py +287 -0
- mobile_agent/tui/theme.py +118 -0
- mobile_agent/tui/widgets.py +272 -0
- mobile_agent/update_check.py +213 -0
- mobile_agent/usage.py +184 -0
- mobile_agent/verify/__init__.py +0 -0
- mobile_agent/verify/__main__.py +41 -0
- mobile_agent/verify/a11y.py +189 -0
- mobile_agent/verify/actions.py +290 -0
- mobile_agent/verify/assertions.py +570 -0
- mobile_agent/verify/audit.py +312 -0
- mobile_agent/verify/business.py +221 -0
- mobile_agent/verify/business_run.py +131 -0
- mobile_agent/verify/business_state.py +168 -0
- mobile_agent/verify/check_schema.py +398 -0
- mobile_agent/verify/checks.py +614 -0
- mobile_agent/verify/cli.py +423 -0
- mobile_agent/verify/conditions.py +483 -0
- mobile_agent/verify/crashes.py +321 -0
- mobile_agent/verify/device_audit.py +246 -0
- mobile_agent/verify/dialogs.py +287 -0
- mobile_agent/verify/evidence.py +705 -0
- mobile_agent/verify/fixtures.py +305 -0
- mobile_agent/verify/flow.py +213 -0
- mobile_agent/verify/l10n.py +141 -0
- mobile_agent/verify/netcerts.py +67 -0
- mobile_agent/verify/netproxy.py +356 -0
- mobile_agent/verify/netrun.py +81 -0
- mobile_agent/verify/netshim.m +28 -0
- mobile_agent/verify/netshim.py +48 -0
- mobile_agent/verify/netstubs.py +132 -0
- mobile_agent/verify/overlay.py +174 -0
- mobile_agent/verify/perf.py +239 -0
- mobile_agent/verify/proof.py +192 -0
- mobile_agent/verify/replay_admission.py +48 -0
- mobile_agent/verify/report.py +952 -0
- mobile_agent/verify/run_cleanup.py +67 -0
- mobile_agent/verify/run_links.py +48 -0
- mobile_agent/verify/run_perf.py +44 -0
- mobile_agent/verify/run_secrets.py +39 -0
- mobile_agent/verify/runner.py +976 -0
- mobile_agent/verify/runs.py +57 -0
- mobile_agent/verify/screen_coverage.py +181 -0
- mobile_agent/verify/smart.py +106 -0
- mobile_agent/verify/snapshot_run.py +114 -0
- mobile_agent/verify/snapshots.py +306 -0
- mobile_agent/verify/sound.py +335 -0
- mobile_agent/verify/source_observation.py +130 -0
- mobile_agent/verify/system_surfaces/__init__.py +7 -0
- mobile_agent/verify/system_surfaces/play.py +106 -0
- mobile_agent/verify/system_surfaces/spec.py +139 -0
- mobile_agent/verify/system_surfaces/stage.py +138 -0
- mobile_agent/verify/system_surfaces/surfaces.py +121 -0
- mobile_agent/verify/tree.py +223 -0
- mobile_agent/verify/verdict.py +377 -0
- mobile_agent/verify/wire.py +392 -0
- mobile_agent/verify/wirestore.py +176 -0
- mobile_agent/waiting/__init__.py +1 -0
- mobile_agent/waiting/contacts.py +73 -0
- mobile_agent/waiting/drafts.py +136 -0
- mobile_agent/waiting/job.py +145 -0
- mobile_agent/waiting/messages.py +174 -0
- mobile_agent/waiting/rank.py +115 -0
- mobile_agent/waiting/register.py +26 -0
- mobile_agent/watch.py +502 -0
- mobile_agent/wda/__init__.py +2 -0
- mobile_agent/wda/frame_clock.py +646 -0
- mobile_agent/wda/frame_source.py +325 -0
- mobile_agent/wda/frame_store.py +115 -0
- mobile_agent/wda/gate.py +163 -0
- mobile_agent/wda/gestures.py +291 -0
- mobile_agent/wda/guards.py +94 -0
- mobile_agent/wda/identity.py +55 -0
- mobile_agent/wda/observe.py +884 -0
- mobile_agent/wda/patches.py +856 -0
- mobile_agent/wda/point_gestures.py +150 -0
- mobile_agent/wda/source.py +32 -0
- mobile_agent/wda/system_dialogs.py +571 -0
- mobile_agent/wda/text.py +694 -0
- mobile_agent/wda/timeouts.py +77 -0
- mobile_agent/wda/video.py +505 -0
- mobile_agent/wda/watchdog.py +243 -0
- mobile_agent/wireless/__init__.py +15 -0
- mobile_agent/wireless/api.py +354 -0
- mobile_agent/wireless/cli.py +287 -0
- mobile_agent/wireless/devicectl.py +229 -0
- mobile_agent/wireless/exposure.py +33 -0
- mobile_agent/wireless/mobster-wifi-pair.c +122 -0
- mobile_agent/wireless/monitor.py +332 -0
- mobile_agent/wireless/network.py +334 -0
- mobile_agent/wireless/onboarding.py +293 -0
- mobile_agent/wireless/pair.py +124 -0
- mobile_agent/wireless/probe.py +322 -0
- mobile_agent/wireless/register.py +8 -0
- mobile_agent/wireless/relay.py +179 -0
- mobile_agent/wireless/result.py +87 -0
- mobile_agent/wireless/store.py +85 -0
- mobile_agent/wireless/switch.py +77 -0
- mobile_agent/wireless/transport.py +118 -0
- mobile_agent/wireless/words.py +81 -0
- mobile_agent/wireless/xctestrun.py +83 -0
- mobile_agent/workflow_tools.py +59 -0
- mobile_agent/workflows/__init__.py +2 -0
- mobile_agent/workflows/brief.py +201 -0
- mobile_agent/workflows/cron.py +110 -0
- mobile_agent/workflows/health.py +257 -0
- mobile_agent/workflows/history.py +126 -0
- mobile_agent/workflows/records.py +442 -0
- mobile_agent/workflows/runs.py +1000 -0
- mobile_agent/workflows/scheduler.py +134 -0
- mobile_agent/workflows/service.py +973 -0
- mobile_agent/workflows/starters.py +285 -0
- mobile_agent/workflows/triggers.py +248 -0
- mobile_agent/xray/__init__.py +1 -0
- mobile_agent/xray/app_cli.py +116 -0
- mobile_agent/xray/cli.py +21 -0
- mobile_agent/xray/crawl.py +754 -0
- mobile_agent/xray/diff.py +171 -0
- mobile_agent/xray/diff_cli.py +43 -0
- mobile_agent/xray/job.py +184 -0
- mobile_agent/xray/paywall.py +188 -0
- mobile_agent/xray/policy.py +305 -0
- mobile_agent/xray/register.py +43 -0
- mobile_agent/xray/report.py +283 -0
- mobile_agent/xray/screens.py +117 -0
- mobster_cli-0.4.3.dist-info/METADATA +93 -0
- mobster_cli-0.4.3.dist-info/RECORD +1009 -0
- mobster_cli-0.4.3.dist-info/WHEEL +5 -0
- mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
- mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
- mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
mobile_agent/run_gif.py
ADDED
|
@@ -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"
|