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
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
// mirroring: drives an iPhone through macOS iPhone Mirroring, for mobile_agent/mirroring.py (an experimental
|
|
2
|
+
// connection: no cable, no Xcode, nothing installed on the phone).
|
|
3
|
+
//
|
|
4
|
+
// It reads one JSON request per line on standard input and prints one JSON line per request:
|
|
5
|
+
// {"op":"state"} where the Mirroring window is, and whether it shows the phone:
|
|
6
|
+
// {"ok":true,"state":"ready"|"blocked"|"no-window"|"not-running",
|
|
7
|
+
// "window":{"x","y","w","h","id"},"said":[...]} ("said": the Mac's own
|
|
8
|
+
// words on an interstitial such as "iPhone in Use"; never the phone's)
|
|
9
|
+
// {"op":"capture","path":P,"ocr":true} the window as a JPEG at P (ScreenCaptureKit), its pixel hash, how much
|
|
10
|
+
// of it is black, and with "ocr" the text Vision reads on it:
|
|
11
|
+
// {"rows":[{"text","confidence","rect":[x,y,w,h]}]}, rects as fractions
|
|
12
|
+
// of the window from its top left
|
|
13
|
+
// {"op":"tap","x":fx,"y":fy} a tap at a point given as fractions of the window
|
|
14
|
+
// {"op":"press","x","y","ms"} a long press
|
|
15
|
+
// {"op":"drag","x1","y1","x2","y2","ms"} a touch drag (iOS sees a swipe)
|
|
16
|
+
// {"op":"scroll","x","y","dy","dx"} a scroll gesture at a point, in window points (dy > 0 reveals what is below)
|
|
17
|
+
// {"op":"key","combo":"cmd+1"} a key or shortcut (cmd+1 Home, cmd+2 App Switcher, cmd+3 Spotlight)
|
|
18
|
+
// {"op":"type","text":T,"mode":"keys"|"paste"} text into the focused field: US key codes, or the Mac pasteboard
|
|
19
|
+
// and cmd+v
|
|
20
|
+
// `mirroring stream FPS MAX_WIDTH QUALITY` (a separate process): the window as JPEG frames, for the live view
|
|
21
|
+
// `mirroring drop FX FY PATH...` / `mirroring drag-out FX FY DEST` (separate processes): files to and from the phone
|
|
22
|
+
// `mirroring audio --seconds N --out FILE [--live]` (a separate process): the phone's sound, for hearing
|
|
23
|
+
// {"op":"restore"} gives the front back to the app that had it before the first gesture
|
|
24
|
+
//
|
|
25
|
+
// A gesture request may carry "input":"foreground" (the person set MOBSTER_MIRRORING_INPUT=foreground); without it
|
|
26
|
+
// the gesture goes in the background and the answer says which way it went: {"ok":true,"input":"background"}.
|
|
27
|
+
//
|
|
28
|
+
// Errors are {"ok":false,"error":"...","code":"..."}: "not_running", "no_window", "blocked", "covered" (foreground
|
|
29
|
+
// input only: another window would take the gesture, so nothing was sent), "background" (the gesture can't be
|
|
30
|
+
// addressed to the window, or Mirroring isn't answering, so nothing was sent), "permission" (Screen Recording or
|
|
31
|
+
// Accessibility is missing), "capture", "unsupported" (macOS before 15), "usage". A gesture is sent at most once and
|
|
32
|
+
// never retried here, and a failed background gesture never turns into a foreground one.
|
|
33
|
+
//
|
|
34
|
+
// The phone image is a video stream: macOS accessibility sees nothing inside it, so input goes in as mouse and
|
|
35
|
+
// keyboard events at the window's position. In the background they are posted to the Mirroring process and addressed
|
|
36
|
+
// to its window: no cursor move, no focus change, and the window can be covered. Foreground input brings the window
|
|
37
|
+
// to the front and posts to the HID event tap. Capture works with the window covered either way.
|
|
38
|
+
//
|
|
39
|
+
// The Mac app carries it as Contents/Resources/mirroring; a source checkout builds it with
|
|
40
|
+
// native_helpers.build("mirroring").
|
|
41
|
+
|
|
42
|
+
import AVFoundation
|
|
43
|
+
import AppKit
|
|
44
|
+
import CoreImage
|
|
45
|
+
import CoreMedia
|
|
46
|
+
import ApplicationServices
|
|
47
|
+
import CoreGraphics
|
|
48
|
+
import Foundation
|
|
49
|
+
import ImageIO
|
|
50
|
+
import ScreenCaptureKit
|
|
51
|
+
import UniformTypeIdentifiers
|
|
52
|
+
import Vision
|
|
53
|
+
|
|
54
|
+
let bundleID = "com.apple.ScreenContinuity"
|
|
55
|
+
let appPath = "/System/Applications/iPhone Mirroring.app"
|
|
56
|
+
|
|
57
|
+
struct HelperError: Error {
|
|
58
|
+
let code: String
|
|
59
|
+
let message: String
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func emit(_ value: [String: Any]) {
|
|
63
|
+
if let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]),
|
|
64
|
+
let line = String(data: data, encoding: .utf8) {
|
|
65
|
+
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - the app and its window
|
|
70
|
+
|
|
71
|
+
struct Window {
|
|
72
|
+
let id: CGWindowID
|
|
73
|
+
let frame: CGRect // global screen points, top-left origin (the space CGEvent uses)
|
|
74
|
+
var json: [String: Any] {
|
|
75
|
+
["x": frame.origin.x, "y": frame.origin.y, "w": frame.width, "h": frame.height, "id": Int(id)]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func runningApp() -> NSRunningApplication? {
|
|
80
|
+
NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func windowList(onScreenOnly: Bool) -> [[String: Any]] {
|
|
84
|
+
let options: CGWindowListOption = onScreenOnly ? [.optionOnScreenOnly, .excludeDesktopElements] : [.optionAll]
|
|
85
|
+
return CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The phone window: the app's normal-layer window whose title is the app's own name (both localized together), else
|
|
89
|
+
// its frontmost normal-layer window. Matched by process, never by the localized owner name.
|
|
90
|
+
func phoneWindow(_ app: NSRunningApplication) -> Window? {
|
|
91
|
+
let pid = app.processIdentifier
|
|
92
|
+
let mine = windowList(onScreenOnly: false).filter {
|
|
93
|
+
($0[kCGWindowOwnerPID as String] as? pid_t) == pid && ($0[kCGWindowLayer as String] as? Int ?? 1) == 0
|
|
94
|
+
}
|
|
95
|
+
let named = mine.filter {
|
|
96
|
+
let name = $0[kCGWindowName as String] as? String
|
|
97
|
+
return name != nil && name == ($0[kCGWindowOwnerName as String] as? String)
|
|
98
|
+
}
|
|
99
|
+
for info in (named.isEmpty ? mine : named) {
|
|
100
|
+
guard let bounds = info[kCGWindowBounds as String] as? [String: Any],
|
|
101
|
+
let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary),
|
|
102
|
+
let number = info[kCGWindowNumber as String] as? Int,
|
|
103
|
+
rect.width > 40, rect.height > 80 else { continue }
|
|
104
|
+
return Window(id: CGWindowID(number), frame: rect)
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func attribute(_ element: AXUIElement, _ name: String) -> AnyObject? {
|
|
110
|
+
var value: AnyObject?
|
|
111
|
+
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func axFrame(_ element: AXUIElement) -> CGRect? {
|
|
115
|
+
guard let position = attribute(element, kAXPositionAttribute), let size = attribute(element, kAXSizeAttribute)
|
|
116
|
+
else { return nil }
|
|
117
|
+
var point = CGPoint.zero, extent = CGSize.zero
|
|
118
|
+
guard AXValueGetValue(position as! AXValue, .cgPoint, &point), AXValueGetValue(size as! AXValue, .cgSize, &extent)
|
|
119
|
+
else { return nil }
|
|
120
|
+
return CGRect(origin: point, size: extent)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func axWindow(_ app: NSRunningApplication, _ window: Window) -> AXUIElement? {
|
|
124
|
+
let element = AXUIElementCreateApplication(app.processIdentifier)
|
|
125
|
+
let windows = attribute(element, kAXWindowsAttribute) as? [AXUIElement] ?? []
|
|
126
|
+
return windows.first { candidate in
|
|
127
|
+
guard let frame = axFrame(candidate) else { return false }
|
|
128
|
+
return abs(frame.minX - window.frame.minX) < 4 && abs(frame.minY - window.frame.minY) < 4
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The Mac's own views inside the phone window. A live session has none (the phone is a video stream); "iPhone in
|
|
133
|
+
// Use", "Paused", "Connect" and the Mac login gate are ordinary views with words and buttons.
|
|
134
|
+
func interstitial(_ app: NSRunningApplication, _ window: Window) -> (blocked: Bool, said: [String], login: Bool) {
|
|
135
|
+
guard let element = axWindow(app, window) else { return (false, [], false) }
|
|
136
|
+
var said: [String] = []
|
|
137
|
+
var found = false
|
|
138
|
+
var login = false
|
|
139
|
+
func walk(_ node: AXUIElement, _ depth: Int) {
|
|
140
|
+
if depth > 8 || said.count >= 8 { return }
|
|
141
|
+
let role = attribute(node, kAXRoleAttribute) as? String ?? ""
|
|
142
|
+
if ["AXStaticText", "AXButton", "AXTextField", "AXSecureTextField"].contains(role) {
|
|
143
|
+
found = true
|
|
144
|
+
if role == "AXSecureTextField" || role == "AXTextField" { login = true }
|
|
145
|
+
if role == "AXStaticText", let value = attribute(node, kAXValueAttribute) as? String {
|
|
146
|
+
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
147
|
+
if !text.isEmpty { said.append(String(text.prefix(160))) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for child in attribute(node, kAXChildrenAttribute) as? [AXUIElement] ?? [] { walk(child, depth + 1) }
|
|
151
|
+
}
|
|
152
|
+
walk(element, 0)
|
|
153
|
+
return (found, said, login)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func state() -> [String: Any] {
|
|
157
|
+
guard let app = runningApp() else { return ["ok": true, "state": "not-running"] }
|
|
158
|
+
guard let window = phoneWindow(app) else { return ["ok": true, "state": "no-window"] }
|
|
159
|
+
let seen = interstitial(app, window)
|
|
160
|
+
var out: [String: Any] = ["ok": true, "state": seen.blocked ? "blocked" : "ready", "window": window.json,
|
|
161
|
+
"front": isFront(app)]
|
|
162
|
+
if seen.blocked {
|
|
163
|
+
out["said"] = seen.said
|
|
164
|
+
out["login"] = seen.login
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func readyWindow() throws -> (NSRunningApplication, Window) {
|
|
170
|
+
guard let app = runningApp() else {
|
|
171
|
+
throw HelperError(code: "not_running", message: "iPhone Mirroring isn't open")
|
|
172
|
+
}
|
|
173
|
+
guard let window = phoneWindow(app) else {
|
|
174
|
+
throw HelperError(code: "no_window", message: "iPhone Mirroring has no phone window")
|
|
175
|
+
}
|
|
176
|
+
return (app, window)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// MARK: - the front
|
|
180
|
+
|
|
181
|
+
var previousFront: pid_t? = nil
|
|
182
|
+
|
|
183
|
+
func frontmostOwner() -> pid_t? {
|
|
184
|
+
for info in windowList(onScreenOnly: true) where (info[kCGWindowLayer as String] as? Int ?? 1) == 0 {
|
|
185
|
+
return info[kCGWindowOwnerPID as String] as? pid_t
|
|
186
|
+
}
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// In front means both: the app has keyboard focus (AXFrontmost) and its window is the frontmost one. Either alone
|
|
191
|
+
// has been seen without the other, and then keystrokes or clicks land in another app.
|
|
192
|
+
func isFront(_ app: NSRunningApplication) -> Bool {
|
|
193
|
+
let element = AXUIElementCreateApplication(app.processIdentifier)
|
|
194
|
+
let front = attribute(element, kAXFrontmostAttribute) as? Bool ?? false
|
|
195
|
+
return front && frontmostOwner() == app.processIdentifier
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func bringToFront(_ app: NSRunningApplication, _ window: Window) throws {
|
|
199
|
+
if isFront(app) { return }
|
|
200
|
+
if previousFront == nil, let owner = frontmostOwner(), owner != app.processIdentifier { previousFront = owner }
|
|
201
|
+
let element = axWindow(app, window)
|
|
202
|
+
if let element, attribute(element, kAXMinimizedAttribute) as? Bool == true {
|
|
203
|
+
AXUIElementSetAttributeValue(element, kAXMinimizedAttribute as CFString, kCFBooleanFalse)
|
|
204
|
+
}
|
|
205
|
+
var deadline = Date().addingTimeInterval(1.5)
|
|
206
|
+
while Date() < deadline {
|
|
207
|
+
app.activate()
|
|
208
|
+
if let element { AXUIElementPerformAction(element, kAXRaiseAction as CFString) }
|
|
209
|
+
Thread.sleep(forTimeInterval: 0.08)
|
|
210
|
+
if isFront(app) { return }
|
|
211
|
+
}
|
|
212
|
+
// macOS 14's cooperative activation can refuse a background process; Launch Services' open doesn't.
|
|
213
|
+
let process = Process()
|
|
214
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
215
|
+
process.arguments = ["-a", appPath]
|
|
216
|
+
try? process.run()
|
|
217
|
+
process.waitUntilExit()
|
|
218
|
+
deadline = Date().addingTimeInterval(1.5)
|
|
219
|
+
while Date() < deadline {
|
|
220
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
221
|
+
if isFront(app) { return }
|
|
222
|
+
}
|
|
223
|
+
throw HelperError(code: "covered", message: "iPhone Mirroring couldn't come to the front, so nothing was sent")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The topmost normal window at a global point belongs to iPhone Mirroring: a click there reaches the phone.
|
|
227
|
+
func ownsPoint(_ app: NSRunningApplication, _ point: CGPoint) -> Bool {
|
|
228
|
+
for info in windowList(onScreenOnly: true) where (info[kCGWindowLayer as String] as? Int ?? 1) == 0 {
|
|
229
|
+
guard let bounds = info[kCGWindowBounds as String] as? [String: Any],
|
|
230
|
+
let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary), rect.contains(point) else { continue }
|
|
231
|
+
return (info[kCGWindowOwnerPID as String] as? pid_t) == app.processIdentifier
|
|
232
|
+
}
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func restoreFront() -> [String: Any] {
|
|
237
|
+
if let pid = previousFront, let app = NSRunningApplication(processIdentifier: pid) {
|
|
238
|
+
app.activate(options: [])
|
|
239
|
+
}
|
|
240
|
+
previousFront = nil
|
|
241
|
+
return ["ok": true]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// MARK: - capture
|
|
245
|
+
|
|
246
|
+
final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
|
247
|
+
|
|
248
|
+
var cachedWindow: SCWindow? = nil
|
|
249
|
+
|
|
250
|
+
func scWindow(_ id: CGWindowID) throws -> SCWindow {
|
|
251
|
+
if let cached = cachedWindow, cached.windowID == id { return cached }
|
|
252
|
+
let done = DispatchSemaphore(value: 0)
|
|
253
|
+
let result = Box<(SCShareableContent?, Error?)>((nil, nil))
|
|
254
|
+
SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: false) { content, error in
|
|
255
|
+
result.value = (content, error)
|
|
256
|
+
done.signal()
|
|
257
|
+
}
|
|
258
|
+
if done.wait(timeout: .now() + 5) == .timedOut {
|
|
259
|
+
throw HelperError(code: "capture", message: "Screen capture didn't answer")
|
|
260
|
+
}
|
|
261
|
+
guard let content = result.value.0 else {
|
|
262
|
+
throw HelperError(code: "permission", message: "Screen Recording permission is needed to read the phone")
|
|
263
|
+
}
|
|
264
|
+
guard let window = content.windows.first(where: { $0.windowID == id }) else {
|
|
265
|
+
throw HelperError(code: "no_window", message: "The Mirroring window isn't capturable")
|
|
266
|
+
}
|
|
267
|
+
cachedWindow = window
|
|
268
|
+
return window
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@available(macOS 14.0, *)
|
|
272
|
+
func captureImage(_ window: Window) throws -> CGImage {
|
|
273
|
+
let target = try scWindow(window.id)
|
|
274
|
+
let filter = SCContentFilter(desktopIndependentWindow: target)
|
|
275
|
+
let config = SCStreamConfiguration()
|
|
276
|
+
let scale = CGFloat(filter.pointPixelScale)
|
|
277
|
+
config.width = Int(window.frame.width * scale)
|
|
278
|
+
config.height = Int(window.frame.height * scale)
|
|
279
|
+
config.showsCursor = false
|
|
280
|
+
config.ignoreShadowsSingleWindow = true
|
|
281
|
+
let done = DispatchSemaphore(value: 0)
|
|
282
|
+
let result = Box<(CGImage?, Error?)>((nil, nil))
|
|
283
|
+
SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { image, error in
|
|
284
|
+
result.value = (image, error)
|
|
285
|
+
done.signal()
|
|
286
|
+
}
|
|
287
|
+
if done.wait(timeout: .now() + 5) == .timedOut {
|
|
288
|
+
throw HelperError(code: "capture", message: "The window capture didn't finish")
|
|
289
|
+
}
|
|
290
|
+
guard let image = result.value.0 else {
|
|
291
|
+
cachedWindow = nil
|
|
292
|
+
throw HelperError(code: "capture", message: "The window capture failed")
|
|
293
|
+
}
|
|
294
|
+
return image
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A small grey picture of the frame: its hash says whether the screen changed; its share of near-black cells says
|
|
298
|
+
// whether protected video (DRM) is drawn black.
|
|
299
|
+
func signature(_ image: CGImage) -> (hash: String, dark: Double) {
|
|
300
|
+
let width = 24, height = 52
|
|
301
|
+
var pixels = [UInt8](repeating: 0, count: width * height)
|
|
302
|
+
let context = CGContext(data: &pixels, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width,
|
|
303
|
+
space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue)
|
|
304
|
+
context?.interpolationQuality = .low
|
|
305
|
+
context?.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
306
|
+
var hash: UInt64 = 1469598103934665603
|
|
307
|
+
var dark = 0
|
|
308
|
+
for value in pixels {
|
|
309
|
+
hash = (hash ^ UInt64(value >> 3)) &* 1099511628211
|
|
310
|
+
if value < 10 { dark += 1 }
|
|
311
|
+
}
|
|
312
|
+
return (String(format: "%016llx", hash), Double(dark) / Double(pixels.count))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func writeJPEG(_ image: CGImage, _ path: String) throws {
|
|
316
|
+
let url = URL(fileURLWithPath: path)
|
|
317
|
+
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil)
|
|
318
|
+
else { throw HelperError(code: "capture", message: "Couldn't write the frame") }
|
|
319
|
+
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.8] as CFDictionary)
|
|
320
|
+
if !CGImageDestinationFinalize(destination) {
|
|
321
|
+
throw HelperError(code: "capture", message: "Couldn't write the frame")
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
func recognize(_ image: CGImage) throws -> [[String: Any]] {
|
|
326
|
+
let request = VNRecognizeTextRequest()
|
|
327
|
+
request.recognitionLevel = .accurate
|
|
328
|
+
request.usesLanguageCorrection = false
|
|
329
|
+
try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
|
|
330
|
+
return (request.results ?? []).compactMap { observation in
|
|
331
|
+
guard let candidate = observation.topCandidates(1).first else { return nil }
|
|
332
|
+
let box = observation.boundingBox
|
|
333
|
+
return ["text": candidate.string, "confidence": Double(candidate.confidence),
|
|
334
|
+
"rect": [box.minX, 1 - box.maxY, box.width, box.height]]
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
func capture(_ request: [String: Any]) throws -> [String: Any] {
|
|
339
|
+
let (app, window) = try readyWindow()
|
|
340
|
+
let started = Date()
|
|
341
|
+
guard #available(macOS 14.0, *) else { throw HelperError(code: "unsupported", message: "This needs macOS 15") }
|
|
342
|
+
let image = try captureImage(window)
|
|
343
|
+
let captured = Date()
|
|
344
|
+
let sig = signature(image)
|
|
345
|
+
var out: [String: Any] = ["ok": true, "window": window.json, "hash": sig.hash, "dark": sig.dark,
|
|
346
|
+
"pixels": [image.width, image.height]]
|
|
347
|
+
if let path = request["path"] as? String { try writeJPEG(image, path) }
|
|
348
|
+
if request["ocr"] as? Bool == true {
|
|
349
|
+
out["rows"] = try recognize(image)
|
|
350
|
+
}
|
|
351
|
+
let seen = interstitial(app, window)
|
|
352
|
+
if seen.blocked {
|
|
353
|
+
out["blocked"] = true
|
|
354
|
+
out["said"] = seen.said
|
|
355
|
+
out["login"] = seen.login
|
|
356
|
+
}
|
|
357
|
+
out["ms"] = ["capture": Int(captured.timeIntervalSince(started) * 1000),
|
|
358
|
+
"total": Int(Date().timeIntervalSince(started) * 1000)]
|
|
359
|
+
return out
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// MARK: - input
|
|
363
|
+
|
|
364
|
+
// Two ways in. The default posts each event to the iPhone Mirroring process, addressed to its window: the Mac's
|
|
365
|
+
// cursor, keyboard focus and front app stay where the person left them, and the window can be behind other windows.
|
|
366
|
+
// The other (`"input":"foreground"`, from MOBSTER_MIRRORING_INPUT=foreground) is the earlier path: it brings the
|
|
367
|
+
// window to the front, moves the real cursor and posts to the HID event tap. It is used only when asked for.
|
|
368
|
+
//
|
|
369
|
+
// Background delivery rests on two things Apple doesn't document, both read off macOS 26:
|
|
370
|
+
// CGEvent field 51 holds the number of the window an event is for, and CGEventSetWindowLocation (exported by
|
|
371
|
+
// CoreGraphics, absent from its headers) sets where in that window it lands. Without both, AppKit hands
|
|
372
|
+
// the app an event with no window and drops it. When either is missing, or the app doesn't answer, nothing is sent
|
|
373
|
+
// and the error says so: a later macOS can break this, and it must not fall back to the front on its own.
|
|
374
|
+
|
|
375
|
+
var foreground = false // set from each request
|
|
376
|
+
|
|
377
|
+
struct Target {
|
|
378
|
+
let pid: pid_t
|
|
379
|
+
let window: Window
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
typealias SetWindowLocation = @convention(c) (CGEvent, CGPoint) -> Void
|
|
383
|
+
let setWindowLocation: SetWindowLocation? = {
|
|
384
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "CGEventSetWindowLocation") else { return nil }
|
|
385
|
+
return unsafeBitCast(symbol, to: SetWindowLocation.self)
|
|
386
|
+
}()
|
|
387
|
+
let windowField = CGEventField(rawValue: 51)!
|
|
388
|
+
// A private source starts with no modifier flags and never merges the person's own held keys into what we post.
|
|
389
|
+
let source = CGEventSource(stateID: .privateState)
|
|
390
|
+
|
|
391
|
+
func background(_ message: String) -> HelperError { HelperError(code: "background", message: message) }
|
|
392
|
+
|
|
393
|
+
let foregroundHint = "Set MOBSTER_MIRRORING_INPUT=foreground to let Mobster bring the window forward and use your cursor and keyboard for each gesture."
|
|
394
|
+
|
|
395
|
+
// Ready for background input: the window we address is the live one, and its app answers. A frozen Mirroring would
|
|
396
|
+
// take a gesture late, and Mobster couldn't say when it landed, so it is refused.
|
|
397
|
+
func confirmBackground(_ app: NSRunningApplication, _ window: Window) throws {
|
|
398
|
+
guard setWindowLocation != nil else {
|
|
399
|
+
throw background("This macOS doesn't let Mobster address iPhone Mirroring's window, so nothing was sent. " + foregroundHint)
|
|
400
|
+
}
|
|
401
|
+
guard !app.isTerminated, axWindow(app, window) != nil else {
|
|
402
|
+
throw background("iPhone Mirroring isn't answering, so nothing was sent. Wait for it, or quit and reopen it, then try again. " + foregroundHint)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func number(_ request: [String: Any], _ key: String) throws -> Double {
|
|
407
|
+
guard let value = request[key] as? Double ?? (request[key] as? Int).map(Double.init), value.isFinite else {
|
|
408
|
+
throw HelperError(code: "usage", message: "\(key) is missing")
|
|
409
|
+
}
|
|
410
|
+
return value
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A point given as fractions of the window, in window points from its top left; refused outside the window.
|
|
414
|
+
func localPoint(_ window: Window, _ fx: Double, _ fy: Double) throws -> CGPoint {
|
|
415
|
+
guard (0...1).contains(fx), (0...1).contains(fy) else {
|
|
416
|
+
throw HelperError(code: "usage", message: "The point is outside the phone's screen")
|
|
417
|
+
}
|
|
418
|
+
return CGPoint(x: window.frame.width * fx, y: window.frame.height * fy)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func globalPoint(_ target: Target, _ local: CGPoint) -> CGPoint {
|
|
422
|
+
CGPoint(x: target.window.frame.minX + local.x, y: target.window.frame.minY + local.y)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Ready for a gesture: the session shows the phone and Mirroring can take it. In the foreground path the window is
|
|
426
|
+
// also in front and owns every point the gesture touches. Otherwise nothing is sent.
|
|
427
|
+
func readyForInput(_ fractions: [(Double, Double)] = []) throws -> (target: Target, points: [CGPoint]) {
|
|
428
|
+
let (app, found) = try readyWindow()
|
|
429
|
+
let seen = interstitial(app, found)
|
|
430
|
+
if seen.blocked {
|
|
431
|
+
throw HelperError(code: "blocked", message: seen.said.first ?? "iPhone Mirroring isn't showing the phone")
|
|
432
|
+
}
|
|
433
|
+
guard AXIsProcessTrusted() else {
|
|
434
|
+
throw HelperError(code: "permission", message: "Accessibility permission is needed to tap the phone")
|
|
435
|
+
}
|
|
436
|
+
let target = Target(pid: app.processIdentifier, window: found)
|
|
437
|
+
let locals = try fractions.map { try localPoint(found, $0.0, $0.1) }
|
|
438
|
+
if foreground {
|
|
439
|
+
try bringToFront(app, found)
|
|
440
|
+
for local in locals where !ownsPoint(app, globalPoint(target, local)) {
|
|
441
|
+
throw HelperError(code: "covered", message: "Another window covers the phone, so nothing was sent")
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
try confirmBackground(app, found)
|
|
445
|
+
}
|
|
446
|
+
return (target, locals)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func withCursorKept(_ body: () throws -> Void) rethrows {
|
|
450
|
+
guard foreground else { return try body() }
|
|
451
|
+
let saved = CGEvent(source: nil)?.location
|
|
452
|
+
defer { if let saved { CGWarpMouseCursorPosition(saved) } }
|
|
453
|
+
try body()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// One mouse event at a point in the window.
|
|
457
|
+
func post(_ type: CGEventType, _ local: CGPoint, _ target: Target) throws {
|
|
458
|
+
let point = globalPoint(target, local)
|
|
459
|
+
if foreground { return postHID(type, point) }
|
|
460
|
+
guard let setLocation = setWindowLocation,
|
|
461
|
+
let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: .left)
|
|
462
|
+
else { throw background("The Mac wouldn't make the mouse event, so nothing was sent. " + foregroundHint) }
|
|
463
|
+
event.setIntegerValueField(windowField, value: Int64(target.window.id))
|
|
464
|
+
setLocation(event, local)
|
|
465
|
+
if type != .mouseMoved { event.setIntegerValueField(.mouseEventClickState, value: 1) }
|
|
466
|
+
event.postToPid(target.pid)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// One mouse event at a global point, through the Mac's pointer. A file drag uses it: the drag runs between two apps,
|
|
470
|
+
// so it can't be sent to one window.
|
|
471
|
+
func postHID(_ type: CGEventType, _ point: CGPoint) {
|
|
472
|
+
CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: point, mouseButton: .left)?
|
|
473
|
+
.post(tap: .cghidEventTap)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func tap(_ request: [String: Any], holdMs: Double = 50) throws -> [String: Any] {
|
|
477
|
+
let (target, points) = try readyForInput([(try number(request, "x"), try number(request, "y"))])
|
|
478
|
+
let point = points[0]
|
|
479
|
+
try withCursorKept {
|
|
480
|
+
try post(.mouseMoved, point, target)
|
|
481
|
+
usleep(foreground ? 40_000 : 10_000)
|
|
482
|
+
try post(.leftMouseDown, point, target)
|
|
483
|
+
usleep(useconds_t(max(holdMs, 20) * 1000))
|
|
484
|
+
try post(.leftMouseUp, point, target)
|
|
485
|
+
}
|
|
486
|
+
return ["ok": true, "input": foreground ? "foreground" : "background"]
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
func drag(_ request: [String: Any]) throws -> [String: Any] {
|
|
490
|
+
let (target, points) = try readyForInput([(try number(request, "x1"), try number(request, "y1")),
|
|
491
|
+
(try number(request, "x2"), try number(request, "y2"))])
|
|
492
|
+
let start = points[0], end = points[1]
|
|
493
|
+
let ms = max(120, (request["ms"] as? Double) ?? 350)
|
|
494
|
+
let steps = 16
|
|
495
|
+
try withCursorKept {
|
|
496
|
+
try post(.mouseMoved, start, target)
|
|
497
|
+
usleep(foreground ? 40_000 : 10_000)
|
|
498
|
+
try post(.leftMouseDown, start, target)
|
|
499
|
+
usleep(30_000)
|
|
500
|
+
for step in 1...steps {
|
|
501
|
+
let t = Double(step) / Double(steps)
|
|
502
|
+
try post(.leftMouseDragged, CGPoint(x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t), target)
|
|
503
|
+
usleep(useconds_t(ms / Double(steps) * 1000))
|
|
504
|
+
}
|
|
505
|
+
// Held still a moment before lifting, so the lift carries no speed and the content doesn't fling on.
|
|
506
|
+
usleep(60_000)
|
|
507
|
+
try post(.leftMouseUp, end, target)
|
|
508
|
+
}
|
|
509
|
+
return ["ok": true, "input": foreground ? "foreground" : "background"]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
func scroll(_ request: [String: Any]) throws -> [String: Any] {
|
|
513
|
+
let (target, points) = try readyForInput([(try number(request, "x"), try number(request, "y"))])
|
|
514
|
+
let local = points[0]
|
|
515
|
+
let dy = (request["dy"] as? Double) ?? 0, dx = (request["dx"] as? Double) ?? 0
|
|
516
|
+
let steps = 8
|
|
517
|
+
try withCursorKept {
|
|
518
|
+
try post(.mouseMoved, local, target)
|
|
519
|
+
usleep(foreground ? 40_000 : 10_000)
|
|
520
|
+
for _ in 0..<steps {
|
|
521
|
+
let event = CGEvent(scrollWheelEvent2Source: foreground ? nil : source, units: .pixel, wheelCount: 2,
|
|
522
|
+
wheel1: Int32(dy / Double(steps)), wheel2: Int32(dx / Double(steps)), wheel3: 0)
|
|
523
|
+
event?.location = globalPoint(target, local)
|
|
524
|
+
if foreground {
|
|
525
|
+
event?.post(tap: .cghidEventTap)
|
|
526
|
+
} else {
|
|
527
|
+
guard let event, let setLocation = setWindowLocation else {
|
|
528
|
+
throw background("The Mac wouldn't make the scroll event, so nothing was sent. " + foregroundHint)
|
|
529
|
+
}
|
|
530
|
+
event.setIntegerValueField(windowField, value: Int64(target.window.id))
|
|
531
|
+
setLocation(event, local)
|
|
532
|
+
event.postToPid(target.pid)
|
|
533
|
+
}
|
|
534
|
+
usleep(25_000)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return ["ok": true, "input": foreground ? "foreground" : "background"]
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// US key codes. iPhone Mirroring forwards raw key codes to the phone and drops both the event's flags and any
|
|
541
|
+
// Unicode payload, so a modifier has to be a key held down, and text has to be key codes (or a paste).
|
|
542
|
+
let keyCodes: [String: CGKeyCode] = [
|
|
543
|
+
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, "b": 11, "q": 12, "w": 13,
|
|
544
|
+
"e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "=": 24, "9": 25,
|
|
545
|
+
"7": 26, "-": 27, "8": 28, "0": 29, "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "return": 36, "l": 37,
|
|
546
|
+
"j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48,
|
|
547
|
+
"space": 49, " ": 49, "`": 50, "delete": 51, "escape": 53, "left": 123, "right": 124, "down": 125, "up": 126,
|
|
548
|
+
]
|
|
549
|
+
let shifted: [Character: String] = [
|
|
550
|
+
"!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", ")": "0", "_": "-",
|
|
551
|
+
"+": "=", ":": ";", "\"": "'", "<": ",", ">": ".", "?": "/", "~": "`", "{": "[", "}": "]", "|": "\\",
|
|
552
|
+
]
|
|
553
|
+
let modifierKeys: [String: (CGKeyCode, CGEventFlags)] = [
|
|
554
|
+
"cmd": (55, .maskCommand), "shift": (56, .maskShift), "alt": (58, .maskAlternate), "ctrl": (59, .maskControl),
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
func key(_ code: CGKeyCode, _ down: Bool, _ flags: CGEventFlags, _ target: Target) throws {
|
|
558
|
+
if foreground {
|
|
559
|
+
let event = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down)
|
|
560
|
+
if !flags.isEmpty { event?.flags = flags }
|
|
561
|
+
event?.post(tap: .cghidEventTap)
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
guard let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down) else {
|
|
565
|
+
throw background("The Mac wouldn't make the key event, so nothing was sent. " + foregroundHint)
|
|
566
|
+
}
|
|
567
|
+
event.flags = flags
|
|
568
|
+
event.setIntegerValueField(windowField, value: Int64(target.window.id))
|
|
569
|
+
event.postToPid(target.pid)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func press(_ code: CGKeyCode, holding modifiers: [String], _ target: Target) throws {
|
|
573
|
+
var flags: CGEventFlags = []
|
|
574
|
+
var held: [(CGKeyCode, CGEventFlags)] = []
|
|
575
|
+
// Always released, last first, each without its own flag: a modifier left down corrupts every later key.
|
|
576
|
+
func release() {
|
|
577
|
+
for (modCode, mask) in held.reversed() {
|
|
578
|
+
flags.remove(mask)
|
|
579
|
+
try? key(modCode, false, flags, target)
|
|
580
|
+
usleep(10_000)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
do {
|
|
584
|
+
for name in modifiers {
|
|
585
|
+
guard let (modCode, mask) = modifierKeys[name] else { continue }
|
|
586
|
+
flags.insert(mask)
|
|
587
|
+
held.append((modCode, mask))
|
|
588
|
+
try key(modCode, true, flags, target)
|
|
589
|
+
usleep(10_000)
|
|
590
|
+
}
|
|
591
|
+
try key(code, true, flags, target)
|
|
592
|
+
usleep(25_000)
|
|
593
|
+
try key(code, false, flags, target)
|
|
594
|
+
usleep(10_000)
|
|
595
|
+
} catch {
|
|
596
|
+
release()
|
|
597
|
+
throw error
|
|
598
|
+
}
|
|
599
|
+
release()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
func combo(_ request: [String: Any]) throws -> [String: Any] {
|
|
603
|
+
guard let text = request["combo"] as? String else { throw HelperError(code: "usage", message: "combo is missing") }
|
|
604
|
+
let parts = text.lowercased().split(separator: "+").map(String.init)
|
|
605
|
+
guard let last = parts.last, let code = keyCodes[last], parts.dropLast().allSatisfy({ modifierKeys[$0] != nil })
|
|
606
|
+
else { throw HelperError(code: "usage", message: "Unknown key \(text)") }
|
|
607
|
+
let (target, _) = try readyForInput()
|
|
608
|
+
try press(code, holding: Array(parts.dropLast()), target)
|
|
609
|
+
return ["ok": true, "input": foreground ? "foreground" : "background"]
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
func typeText(_ request: [String: Any]) throws -> [String: Any] {
|
|
613
|
+
guard let text = request["text"] as? String, !text.isEmpty else {
|
|
614
|
+
throw HelperError(code: "usage", message: "text is missing")
|
|
615
|
+
}
|
|
616
|
+
let paste = request["mode"] as? String == "paste"
|
|
617
|
+
var strokes: [(CGKeyCode, Bool)] = []
|
|
618
|
+
if !paste {
|
|
619
|
+
for character in text {
|
|
620
|
+
if character == "\n" { strokes.append((keyCodes["return"]!, false)); continue }
|
|
621
|
+
let lower = String(character).lowercased()
|
|
622
|
+
if let base = shifted[character], let code = keyCodes[base] {
|
|
623
|
+
strokes.append((code, true))
|
|
624
|
+
} else if let code = keyCodes[lower], lower.count == 1 {
|
|
625
|
+
strokes.append((code, character.isUppercase))
|
|
626
|
+
} else {
|
|
627
|
+
throw HelperError(code: "usage", message: "This text needs a paste: it has characters with no key")
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
let (target, _) = try readyForInput()
|
|
632
|
+
if paste {
|
|
633
|
+
// The text stays on the Mac's pasteboard: putting the earlier contents back races the phone's read of it,
|
|
634
|
+
// and the phone could paste those instead.
|
|
635
|
+
let board = NSPasteboard.general
|
|
636
|
+
board.clearContents()
|
|
637
|
+
board.setString(text, forType: .string)
|
|
638
|
+
guard board.string(forType: .string) == text else {
|
|
639
|
+
throw HelperError(code: "usage", message: "The Mac's pasteboard didn't take the text; nothing was typed")
|
|
640
|
+
}
|
|
641
|
+
usleep(200_000)
|
|
642
|
+
try press(keyCodes["v"]!, holding: ["cmd"], target)
|
|
643
|
+
} else {
|
|
644
|
+
for (code, shift) in strokes {
|
|
645
|
+
try press(code, holding: shift ? ["shift"] : [], target)
|
|
646
|
+
usleep(15_000)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return ["ok": true, "input": foreground ? "foreground" : "background"]
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// MARK: - the loop
|
|
653
|
+
|
|
654
|
+
func handle(_ request: [String: Any]) throws -> [String: Any] {
|
|
655
|
+
// iPhone Mirroring itself came with macOS 15; the Mac app still runs on 13.
|
|
656
|
+
guard #available(macOS 15.0, *) else {
|
|
657
|
+
throw HelperError(code: "unsupported", message: "iPhone Mirroring needs macOS 15 or later")
|
|
658
|
+
}
|
|
659
|
+
foreground = request["input"] as? String == "foreground"
|
|
660
|
+
switch request["op"] as? String ?? "" {
|
|
661
|
+
case "state": return state()
|
|
662
|
+
case "capture": return try capture(request)
|
|
663
|
+
case "tap": return try tap(request)
|
|
664
|
+
case "press": return try tap(request, holdMs: (request["ms"] as? Double) ?? 1500)
|
|
665
|
+
case "drag": return try drag(request)
|
|
666
|
+
case "scroll": return try scroll(request)
|
|
667
|
+
case "key": return try combo(request)
|
|
668
|
+
case "type": return try typeText(request)
|
|
669
|
+
case "restore": return restoreFront()
|
|
670
|
+
default: throw HelperError(code: "usage", message: "Unknown op")
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// MARK: - the live stream
|
|
675
|
+
|
|
676
|
+
// `mirroring stream FPS MAX_WIDTH QUALITY`: the Mirroring window as a stream of JPEG frames on standard output, each
|
|
677
|
+
// a 4-byte big-endian length and the JPEG, for the live view (mirroring.MirroringVideo). ScreenCaptureKit captures the
|
|
678
|
+
// window itself, so it streams while the window is behind other windows. A frame that shows no change is not sent.
|
|
679
|
+
// It ends when standard input closes (its parent went away) or the window does.
|
|
680
|
+
@available(macOS 14.0, *)
|
|
681
|
+
final class FrameStream: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
682
|
+
let quality: Double
|
|
683
|
+
let out = FileHandle.standardOutput
|
|
684
|
+
let context = CIContext(options: [.useSoftwareRenderer: false])
|
|
685
|
+
var stream: SCStream?
|
|
686
|
+
init(quality: Double) { self.quality = quality }
|
|
687
|
+
|
|
688
|
+
func start(window: Window, fps: Double, maxWidth: Double) throws {
|
|
689
|
+
let target = try scWindow(window.id)
|
|
690
|
+
let filter = SCContentFilter(desktopIndependentWindow: target)
|
|
691
|
+
let config = SCStreamConfiguration()
|
|
692
|
+
let scale = min(Double(filter.pointPixelScale), maxWidth / max(window.frame.width, 1))
|
|
693
|
+
config.width = Int(window.frame.width * scale)
|
|
694
|
+
config.height = Int(window.frame.height * scale)
|
|
695
|
+
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, min(fps, 60))))
|
|
696
|
+
config.showsCursor = false
|
|
697
|
+
config.queueDepth = 3
|
|
698
|
+
config.ignoreShadowsSingleWindow = true
|
|
699
|
+
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
700
|
+
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: DispatchQueue(label: "frames"))
|
|
701
|
+
self.stream = stream
|
|
702
|
+
stream.startCapture { error in
|
|
703
|
+
if error != nil { exit(3) }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
func stream(_ stream: SCStream, didOutputSampleBuffer buffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
708
|
+
guard type == .screen, buffer.isValid,
|
|
709
|
+
let attachments = CMSampleBufferGetSampleAttachmentsArray(buffer, createIfNecessary: false)
|
|
710
|
+
as? [[SCStreamFrameInfo: Any]],
|
|
711
|
+
let raw = attachments.first?[.status] as? Int, SCFrameStatus(rawValue: raw) == .complete,
|
|
712
|
+
let pixels = CMSampleBufferGetImageBuffer(buffer) else { return }
|
|
713
|
+
let image = CIImage(cvPixelBuffer: pixels)
|
|
714
|
+
guard let cg = context.createCGImage(image, from: image.extent)
|
|
715
|
+
else { return }
|
|
716
|
+
let data = NSMutableData()
|
|
717
|
+
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)
|
|
718
|
+
else { return }
|
|
719
|
+
CGImageDestinationAddImage(destination, cg, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
|
|
720
|
+
guard CGImageDestinationFinalize(destination) else { return }
|
|
721
|
+
var length = UInt32(data.length).bigEndian
|
|
722
|
+
out.write(Data(bytes: &length, count: 4))
|
|
723
|
+
out.write(data as Data)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
func stream(_ stream: SCStream, didStopWithError error: Error) { exit(4) }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if CommandLine.arguments.count >= 2 && CommandLine.arguments[1] == "stream" {
|
|
730
|
+
let args = CommandLine.arguments
|
|
731
|
+
let fps = args.count > 2 ? Double(args[2]) ?? 15 : 15
|
|
732
|
+
let maxWidth = args.count > 3 ? Double(args[3]) ?? 640 : 640
|
|
733
|
+
let quality = args.count > 4 ? Double(args[4]) ?? 0.6 : 0.6
|
|
734
|
+
guard #available(macOS 15.0, *) else { exit(2) }
|
|
735
|
+
guard let app = runningApp(), let window = phoneWindow(app) else { exit(5) }
|
|
736
|
+
let frames = FrameStream(quality: quality)
|
|
737
|
+
do { try frames.start(window: window, fps: fps, maxWidth: maxWidth) } catch { exit(6) }
|
|
738
|
+
Thread.detachNewThread {
|
|
739
|
+
while readLine() != nil {}
|
|
740
|
+
exit(0)
|
|
741
|
+
}
|
|
742
|
+
dispatchMain()
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// MARK: - files, by drag and drop (macOS 15.1+)
|
|
746
|
+
|
|
747
|
+
// `mirroring drop FX FY PATH...`: drags the files from a small Mobster window onto the phone at (FX, FY), fractions of
|
|
748
|
+
// the Mirroring window; the app in front on the phone receives them as a drop. `mirroring drag-out FX FY DEST`:
|
|
749
|
+
// long-presses the item at (FX, FY) on the phone, drags it onto a small Mobster window and saves what iOS hands over
|
|
750
|
+
// in DEST. Each prints one JSON line ({"ok":true,"sent":[...]} / {"ok":true,"received":[...]}, or {"ok":false,
|
|
751
|
+
// "code","error"}) and exits. The pointer is driven with HID events, so the Mirroring window must be in front.
|
|
752
|
+
|
|
753
|
+
final class DragSource: NSView, NSDraggingSource {
|
|
754
|
+
var urls: [URL] = []
|
|
755
|
+
var ended: ((NSDragOperation) -> Void)?
|
|
756
|
+
var started = false
|
|
757
|
+
override func mouseDown(with event: NSEvent) {}
|
|
758
|
+
override func mouseDragged(with event: NSEvent) {
|
|
759
|
+
if started { return }
|
|
760
|
+
started = true
|
|
761
|
+
let items = urls.map { url -> NSDraggingItem in
|
|
762
|
+
let item = NSDraggingItem(pasteboardWriter: url as NSURL)
|
|
763
|
+
item.setDraggingFrame(NSRect(x: 0, y: 0, width: 32, height: 32), contents: NSWorkspace.shared.icon(forFile: url.path))
|
|
764
|
+
return item
|
|
765
|
+
}
|
|
766
|
+
beginDraggingSession(with: items, event: event, source: self)
|
|
767
|
+
}
|
|
768
|
+
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
|
769
|
+
context == .outsideApplication ? [.copy, .generic] : []
|
|
770
|
+
}
|
|
771
|
+
func draggingSession(_ session: NSDraggingSession, endedAt point: NSPoint, operation: NSDragOperation) {
|
|
772
|
+
ended?(operation)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
final class DropTarget: NSView {
|
|
777
|
+
var destination = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
778
|
+
var finished: (([String], String?) -> Void)?
|
|
779
|
+
override init(frame: NSRect) {
|
|
780
|
+
super.init(frame: frame)
|
|
781
|
+
registerForDraggedTypes(NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
|
|
782
|
+
+ [.fileURL, .URL, .string, .png, .tiff])
|
|
783
|
+
}
|
|
784
|
+
required init?(coder: NSCoder) { nil }
|
|
785
|
+
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { .copy }
|
|
786
|
+
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { .copy }
|
|
787
|
+
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
788
|
+
let board = sender.draggingPasteboard
|
|
789
|
+
if let promises = board.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver],
|
|
790
|
+
!promises.isEmpty {
|
|
791
|
+
var received: [String] = []
|
|
792
|
+
let group = DispatchGroup()
|
|
793
|
+
let queue = OperationQueue()
|
|
794
|
+
for promise in promises {
|
|
795
|
+
group.enter()
|
|
796
|
+
promise.receivePromisedFiles(atDestination: destination, options: [:], operationQueue: queue) { url, error in
|
|
797
|
+
if error == nil { received.append(url.path) }
|
|
798
|
+
group.leave()
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
group.notify(queue: .main) { self.finished?(received, received.isEmpty ? "refused" : nil) }
|
|
802
|
+
return true
|
|
803
|
+
}
|
|
804
|
+
if let urls = board.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL],
|
|
805
|
+
!urls.isEmpty {
|
|
806
|
+
var copied: [String] = []
|
|
807
|
+
for url in urls {
|
|
808
|
+
let target = destination.appendingPathComponent(url.lastPathComponent)
|
|
809
|
+
if (try? FileManager.default.copyItem(at: url, to: target)) != nil { copied.append(target.path) }
|
|
810
|
+
}
|
|
811
|
+
finished?(copied, copied.isEmpty ? "refused" : nil)
|
|
812
|
+
return true
|
|
813
|
+
}
|
|
814
|
+
if let image = NSImage(pasteboard: board), let tiff = image.tiffRepresentation,
|
|
815
|
+
let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
|
|
816
|
+
let target = destination.appendingPathComponent("from-iphone-\(Int(Date().timeIntervalSince1970)).png")
|
|
817
|
+
if (try? png.write(to: target)) != nil {
|
|
818
|
+
finished?([target.path], nil)
|
|
819
|
+
return true
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if let text = board.string(forType: .string) {
|
|
823
|
+
let target = destination.appendingPathComponent("from-iphone-\(Int(Date().timeIntervalSince1970)).txt")
|
|
824
|
+
if (try? text.write(to: target, atomically: true, encoding: .utf8)) != nil {
|
|
825
|
+
finished?([target.path], nil)
|
|
826
|
+
return true
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
finished?([], "refused")
|
|
830
|
+
return false
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
func finishTransfer(_ value: [String: Any]) -> Never {
|
|
835
|
+
emit(value)
|
|
836
|
+
exit(0)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// A small floating window of Mobster's at a global top-left point (CG coordinates), holding ``view``.
|
|
840
|
+
func transferWindow(at point: CGPoint, view: NSView) -> NSWindow {
|
|
841
|
+
let screenHeight = NSScreen.screens.first?.frame.height ?? 900
|
|
842
|
+
let size: CGFloat = 72
|
|
843
|
+
let frame = NSRect(x: point.x - size / 2, y: screenHeight - point.y - size / 2, width: size, height: size)
|
|
844
|
+
let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false)
|
|
845
|
+
window.level = .floating
|
|
846
|
+
window.isOpaque = false
|
|
847
|
+
window.backgroundColor = NSColor.systemPink.withAlphaComponent(0.25)
|
|
848
|
+
view.frame = NSRect(x: 0, y: 0, width: size, height: size)
|
|
849
|
+
window.contentView = view
|
|
850
|
+
window.orderFrontRegardless()
|
|
851
|
+
return window
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
func glide(_ from: CGPoint, _ to: CGPoint, steps: Int = 24, seconds: Double = 0.6) {
|
|
855
|
+
for step in 1...steps {
|
|
856
|
+
let t = Double(step) / Double(steps)
|
|
857
|
+
postHID(.leftMouseDragged, CGPoint(x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t))
|
|
858
|
+
usleep(useconds_t(seconds / Double(steps) * 1_000_000))
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
func runTransfer(_ args: [String]) -> Never {
|
|
863
|
+
guard #available(macOS 15.1, *) else {
|
|
864
|
+
finishTransfer(["ok": false, "code": "unsupported", "error": "Dragging files to and from iPhone Mirroring needs macOS 15.1 or later"])
|
|
865
|
+
}
|
|
866
|
+
let mode = args[1]
|
|
867
|
+
guard args.count >= 5, let fx = Double(args[2]), let fy = Double(args[3]), (0...1).contains(fx), (0...1).contains(fy)
|
|
868
|
+
else { finishTransfer(["ok": false, "code": "usage", "error": "usage: drop FX FY PATH... | drag-out FX FY DEST"]) }
|
|
869
|
+
let app = NSApplication.shared
|
|
870
|
+
app.setActivationPolicy(.accessory)
|
|
871
|
+
var mirror: Window
|
|
872
|
+
do {
|
|
873
|
+
foreground = true // the drag needs the window in front and the Mac's pointer
|
|
874
|
+
mirror = try readyForInput().target.window
|
|
875
|
+
} catch let error as HelperError {
|
|
876
|
+
finishTransfer(["ok": false, "code": error.code, "error": error.message])
|
|
877
|
+
} catch {
|
|
878
|
+
finishTransfer(["ok": false, "code": "capture", "error": "iPhone Mirroring isn't ready"])
|
|
879
|
+
}
|
|
880
|
+
let phonePoint = CGPoint(x: mirror.frame.minX + mirror.frame.width * fx, y: mirror.frame.minY + mirror.frame.height * fy)
|
|
881
|
+
// Mobster's window sits just right of the Mirroring window (left of it when there's no room).
|
|
882
|
+
let screenWidth = NSScreen.screens.first?.frame.width ?? 1440
|
|
883
|
+
let side = mirror.frame.maxX + 60 < screenWidth ? mirror.frame.maxX + 50 : mirror.frame.minX - 50
|
|
884
|
+
let macPoint = CGPoint(x: side, y: mirror.frame.midY)
|
|
885
|
+
let deadline = DispatchTime.now() + 60
|
|
886
|
+
DispatchQueue.main.asyncAfter(deadline: deadline) {
|
|
887
|
+
finishTransfer(["ok": false, "code": "refused", "error": "Nothing came through in 60 s"])
|
|
888
|
+
}
|
|
889
|
+
if mode == "drop" {
|
|
890
|
+
let paths = Array(args[4...])
|
|
891
|
+
let urls = paths.map { URL(fileURLWithPath: $0) }
|
|
892
|
+
for url in urls where !FileManager.default.fileExists(atPath: url.path) {
|
|
893
|
+
finishTransfer(["ok": false, "code": "usage", "error": "No file at \(url.lastPathComponent)"])
|
|
894
|
+
}
|
|
895
|
+
let source = DragSource()
|
|
896
|
+
source.urls = urls
|
|
897
|
+
source.ended = { operation in
|
|
898
|
+
finishTransfer(operation.isEmpty
|
|
899
|
+
? ["ok": false, "code": "refused", "error": "The app on the phone didn't take the drop"]
|
|
900
|
+
: ["ok": true, "sent": urls.map { $0.lastPathComponent }])
|
|
901
|
+
}
|
|
902
|
+
let window = transferWindow(at: macPoint, view: source)
|
|
903
|
+
_ = window
|
|
904
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
|
|
905
|
+
postHID(.mouseMoved, macPoint)
|
|
906
|
+
usleep(80_000)
|
|
907
|
+
postHID(.leftMouseDown, macPoint)
|
|
908
|
+
usleep(80_000)
|
|
909
|
+
glide(macPoint, CGPoint(x: macPoint.x + 6, y: macPoint.y + 6), steps: 4, seconds: 0.1)
|
|
910
|
+
glide(CGPoint(x: macPoint.x + 6, y: macPoint.y + 6), phonePoint)
|
|
911
|
+
usleep(700_000) // hover: the phone shows where the drop goes
|
|
912
|
+
postHID(.leftMouseUp, phonePoint)
|
|
913
|
+
}
|
|
914
|
+
} else if mode == "drag-out" {
|
|
915
|
+
let destination = URL(fileURLWithPath: args[4], isDirectory: true)
|
|
916
|
+
try? FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
|
917
|
+
let target = DropTarget(frame: .zero)
|
|
918
|
+
target.destination = destination
|
|
919
|
+
target.finished = { received, failure in
|
|
920
|
+
finishTransfer(failure == nil ? ["ok": true, "received": received]
|
|
921
|
+
: ["ok": false, "code": failure!, "error": "Nothing came off the phone"])
|
|
922
|
+
}
|
|
923
|
+
let window = transferWindow(at: macPoint, view: target)
|
|
924
|
+
_ = window
|
|
925
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
|
|
926
|
+
postHID(.mouseMoved, phonePoint)
|
|
927
|
+
usleep(80_000)
|
|
928
|
+
postHID(.leftMouseDown, phonePoint)
|
|
929
|
+
usleep(900_000) // a long press lifts the item on iOS
|
|
930
|
+
glide(phonePoint, CGPoint(x: phonePoint.x + 8, y: phonePoint.y + 8), steps: 6, seconds: 0.3)
|
|
931
|
+
glide(CGPoint(x: phonePoint.x + 8, y: phonePoint.y + 8), macPoint, steps: 30, seconds: 0.9)
|
|
932
|
+
usleep(600_000)
|
|
933
|
+
postHID(.leftMouseUp, macPoint)
|
|
934
|
+
}
|
|
935
|
+
} else {
|
|
936
|
+
finishTransfer(["ok": false, "code": "usage", "error": "Unknown transfer"])
|
|
937
|
+
}
|
|
938
|
+
app.run()
|
|
939
|
+
exit(0)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if CommandLine.arguments.count >= 2 && ["drop", "drag-out"].contains(CommandLine.arguments[1]) {
|
|
943
|
+
runTransfer(CommandLine.arguments)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// MARK: - the phone's sound (iPhone Mirroring plays it on the Mac)
|
|
947
|
+
|
|
948
|
+
// `mirroring audio --seconds N --out FILE [--live]`: records the iPhone Mirroring app's audio (ScreenCaptureKit) into
|
|
949
|
+
// FILE for N seconds (1 to 120), then prints {"ok":true,"recordedSeconds","peakDb","file"}. With --live it prints
|
|
950
|
+
// {"event":"recording"} once audio flows and {"event":"level","t","db"} every half second, and ends early when standard
|
|
951
|
+
// input reads "stop" or closes: phone-audio's live protocol, so mobile_agent/listen.py reads both the same way.
|
|
952
|
+
// Speech and sounds are told apart afterwards by phone-audio's transcribe and classify, on this Mac.
|
|
953
|
+
@available(macOS 14.0, *)
|
|
954
|
+
final class AudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
955
|
+
let url: URL
|
|
956
|
+
let live: Bool
|
|
957
|
+
var file: AVAudioFile?
|
|
958
|
+
var stream: SCStream?
|
|
959
|
+
var started: Date?
|
|
960
|
+
var peak: Float = -160
|
|
961
|
+
var levelSum: Float = 0
|
|
962
|
+
var levelCount = 0
|
|
963
|
+
var lastLevel = Date.distantPast
|
|
964
|
+
let lock = NSLock()
|
|
965
|
+
init(url: URL, live: Bool) { self.url = url; self.live = live }
|
|
966
|
+
|
|
967
|
+
func start(app: SCRunningApplication, display: SCDisplay) throws {
|
|
968
|
+
let filter = SCContentFilter(display: display, including: [app], exceptingWindows: [])
|
|
969
|
+
let config = SCStreamConfiguration()
|
|
970
|
+
config.capturesAudio = true
|
|
971
|
+
config.excludesCurrentProcessAudio = true
|
|
972
|
+
config.sampleRate = 48000
|
|
973
|
+
config.channelCount = 1
|
|
974
|
+
config.width = 2
|
|
975
|
+
config.height = 2
|
|
976
|
+
config.minimumFrameInterval = CMTime(value: 1, timescale: 1)
|
|
977
|
+
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
978
|
+
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: DispatchQueue(label: "audio"))
|
|
979
|
+
self.stream = stream
|
|
980
|
+
stream.startCapture { error in
|
|
981
|
+
if let error { emit(["ok": false, "error": "capture_failed", "message": "Mobster couldn't capture iPhone Mirroring's sound (\(type(of: error)))."]); exit(0) }
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
func stream(_ stream: SCStream, didOutputSampleBuffer buffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
986
|
+
guard type == .audio, buffer.isValid, let description = buffer.formatDescription else { return }
|
|
987
|
+
let format = AVAudioFormat(cmAudioFormatDescription: description)
|
|
988
|
+
let frames = AVAudioFrameCount(CMSampleBufferGetNumSamples(buffer))
|
|
989
|
+
guard frames > 0, let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames) else { return }
|
|
990
|
+
pcm.frameLength = frames
|
|
991
|
+
guard CMSampleBufferCopyPCMDataIntoAudioBufferList(buffer, at: 0, frameCount: Int32(frames),
|
|
992
|
+
into: pcm.mutableAudioBufferList) == noErr else { return }
|
|
993
|
+
lock.lock()
|
|
994
|
+
defer { lock.unlock() }
|
|
995
|
+
if file == nil {
|
|
996
|
+
file = try? AVAudioFile(forWriting: url, settings: format.settings)
|
|
997
|
+
started = Date()
|
|
998
|
+
if live { emit(["event": "recording"]) }
|
|
999
|
+
}
|
|
1000
|
+
try? file?.write(from: pcm)
|
|
1001
|
+
if let data = pcm.floatChannelData?[0] {
|
|
1002
|
+
var sum: Float = 0
|
|
1003
|
+
for i in 0..<Int(frames) { sum += data[i] * data[i] }
|
|
1004
|
+
let rms = sqrt(sum / Float(frames))
|
|
1005
|
+
let db = rms > 0 ? 20 * log10(rms) : -160
|
|
1006
|
+
peak = max(peak, db)
|
|
1007
|
+
levelSum += db
|
|
1008
|
+
levelCount += 1
|
|
1009
|
+
}
|
|
1010
|
+
if live, Date().timeIntervalSince(lastLevel) >= 0.5, levelCount > 0, let started {
|
|
1011
|
+
emit(["event": "level", "t": Date().timeIntervalSince(started), "db": Double(levelSum / Float(levelCount))])
|
|
1012
|
+
levelSum = 0
|
|
1013
|
+
levelCount = 0
|
|
1014
|
+
lastLevel = Date()
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
func finish() -> Never {
|
|
1019
|
+
let done = DispatchSemaphore(value: 0)
|
|
1020
|
+
stream?.stopCapture { _ in done.signal() }
|
|
1021
|
+
_ = done.wait(timeout: .now() + 3)
|
|
1022
|
+
lock.lock()
|
|
1023
|
+
let seconds = started.map { Date().timeIntervalSince($0) } ?? 0
|
|
1024
|
+
file = nil // closes the file
|
|
1025
|
+
lock.unlock()
|
|
1026
|
+
if seconds == 0 {
|
|
1027
|
+
emit(["ok": false, "error": "capture_failed", "message": "No sound came from iPhone Mirroring. Check that it's connected and the media is playing."])
|
|
1028
|
+
} else {
|
|
1029
|
+
emit(["ok": true, "recordedSeconds": seconds, "peakDb": Double(peak), "file": url.path])
|
|
1030
|
+
}
|
|
1031
|
+
exit(0)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
func stream(_ stream: SCStream, didStopWithError error: Error) { finish() }
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
func runAudio(_ args: [String]) -> Never {
|
|
1038
|
+
func option(_ name: String) -> String? {
|
|
1039
|
+
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
|
|
1040
|
+
return args[index + 1]
|
|
1041
|
+
}
|
|
1042
|
+
guard #available(macOS 15.0, *) else {
|
|
1043
|
+
emit(["ok": false, "error": "capture_failed", "message": "iPhone Mirroring needs macOS 15 or later."]); exit(0)
|
|
1044
|
+
}
|
|
1045
|
+
guard let out = option("--out") else {
|
|
1046
|
+
emit(["ok": false, "error": "usage", "message": "audio --seconds N --out FILE [--live]"]); exit(2)
|
|
1047
|
+
}
|
|
1048
|
+
let seconds = min(120, max(1, Double(option("--seconds") ?? "10") ?? 10))
|
|
1049
|
+
guard let running = runningApp() else {
|
|
1050
|
+
emit(["ok": false, "error": "no_device", "message": "iPhone Mirroring isn't open. Open it and connect your iPhone, then try again."]); exit(0)
|
|
1051
|
+
}
|
|
1052
|
+
let done = DispatchSemaphore(value: 0)
|
|
1053
|
+
let found = Box<(SCShareableContent?, Error?)>((nil, nil))
|
|
1054
|
+
SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: false) { content, error in
|
|
1055
|
+
found.value = (content, error)
|
|
1056
|
+
done.signal()
|
|
1057
|
+
}
|
|
1058
|
+
_ = done.wait(timeout: .now() + 5)
|
|
1059
|
+
guard let content = found.value.0,
|
|
1060
|
+
let app = content.applications.first(where: { $0.processID == running.processIdentifier }),
|
|
1061
|
+
let display = content.displays.first else {
|
|
1062
|
+
emit(["ok": false, "error": "permission", "message": "Mobster needs Screen Recording to hear iPhone Mirroring. Allow it in System Settings › Privacy & Security › Screen & System Audio Recording."]); exit(0)
|
|
1063
|
+
}
|
|
1064
|
+
let recorder = AudioRecorder(url: URL(fileURLWithPath: out), live: args.contains("--live"))
|
|
1065
|
+
do { try recorder.start(app: app, display: display) } catch {
|
|
1066
|
+
emit(["ok": false, "error": "capture_failed", "message": "Mobster couldn't capture iPhone Mirroring's sound."]); exit(0)
|
|
1067
|
+
}
|
|
1068
|
+
Thread.detachNewThread {
|
|
1069
|
+
while let line = readLine() { if line.trimmingCharacters(in: .whitespaces) == "stop" { break } }
|
|
1070
|
+
if args.contains("--live") { recorder.finish() }
|
|
1071
|
+
}
|
|
1072
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + seconds) { recorder.finish() }
|
|
1073
|
+
dispatchMain()
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if CommandLine.arguments.count >= 2 && CommandLine.arguments[1] == "audio" {
|
|
1077
|
+
runAudio(CommandLine.arguments)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
while let line = readLine() {
|
|
1081
|
+
guard let data = line.data(using: .utf8),
|
|
1082
|
+
let request = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
|
1083
|
+
emit(["ok": false, "code": "usage", "error": "Each line must be a JSON object"])
|
|
1084
|
+
continue
|
|
1085
|
+
}
|
|
1086
|
+
do {
|
|
1087
|
+
var out = try handle(request)
|
|
1088
|
+
if let id = request["id"] { out["id"] = id }
|
|
1089
|
+
emit(out)
|
|
1090
|
+
} catch let error as HelperError {
|
|
1091
|
+
var out: [String: Any] = ["ok": false, "code": error.code, "error": error.message]
|
|
1092
|
+
if let id = request["id"] { out["id"] = id }
|
|
1093
|
+
emit(out)
|
|
1094
|
+
} catch {
|
|
1095
|
+
var out: [String: Any] = ["ok": false, "code": "capture", "error": "\(type(of: error))"]
|
|
1096
|
+
if let id = request["id"] { out["id"] = id }
|
|
1097
|
+
emit(out)
|
|
1098
|
+
}
|
|
1099
|
+
}
|