minitap-mobile-use 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,429 @@
1
+ import asyncio
2
+ import json
3
+ import socket
4
+ import subprocess
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from idb.common.types import HIDButtonType, InstalledAppInfo, InstalledArtifact, TCPAddress
10
+ from idb.grpc.client import Client
11
+ from pydantic import BaseModel
12
+
13
+ from minitap.mobile_use.utils.logger import get_logger
14
+
15
+
16
+ class IOSAppInfo(BaseModel):
17
+ name: str | None
18
+ bundle_id: str | None
19
+
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ def _find_available_port(start_port: int = 10882, max_attempts: int = 100) -> int:
25
+ for port in range(start_port, start_port + max_attempts):
26
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27
+ try:
28
+ s.bind(("localhost", port))
29
+ return port
30
+ except OSError:
31
+ continue
32
+ raise RuntimeError(
33
+ f"Could not find available port in range {start_port}-{start_port + max_attempts}"
34
+ )
35
+
36
+
37
+ def with_idb_client(func):
38
+ """Decorator to ensure idb client is initialized before method call.
39
+
40
+ Note: Function must have None or bool in return type.
41
+ """
42
+
43
+ @wraps(func)
44
+ async def wrapper(self, *args, **kwargs):
45
+ method_name = func.__name__
46
+ try:
47
+ if self._client is None:
48
+ raise RuntimeError(
49
+ "IDB client not initialized. "
50
+ "Use 'async with' context manager or call init_companion() first."
51
+ )
52
+ logger.debug(f"Calling {method_name}...")
53
+ result = await func(self, *args, **kwargs)
54
+ logger.debug(f"{method_name} completed successfully")
55
+ return result
56
+ except Exception as e:
57
+ logger.error(f"Failed to {method_name}: {e}")
58
+ import traceback
59
+
60
+ logger.debug(f"Traceback: {traceback.format_exc()}")
61
+
62
+ return_type = func.__annotations__.get("return")
63
+ if return_type is bool:
64
+ return False
65
+ return None
66
+
67
+ return wrapper
68
+
69
+
70
+ class IdbClientWrapper:
71
+ """Wrapper around fb-idb client for iOS device automation with lifecycle management.
72
+
73
+ This wrapper can either manage the idb_companion process lifecycle locally or connect
74
+ to an external companion server.
75
+
76
+ Lifecycle Management:
77
+ - If host is None (default): Manages companion locally on localhost
78
+ - Call init_companion() to start the idb_companion process
79
+ - Call cleanup() to stop the companion process
80
+ - Or use as async context manager for automatic lifecycle
81
+ - If host is provided: Connects to external companion server
82
+ - init_companion() and cleanup() become no-ops
83
+ - You manage the external companion separately
84
+
85
+ Example:
86
+ # Managed companion (recommended for local development)
87
+ async with IdbClientWrapper(udid="device-id") as wrapper:
88
+ await wrapper.tap(100, 200)
89
+
90
+ # External companion (for production/remote)
91
+ wrapper = IdbClientWrapper(udid="device-id", host="remote-host", port=10882)
92
+ await wrapper.tap(100, 200) # No companion lifecycle management needed
93
+ """
94
+
95
+ def __init__(self, udid: str, host: str | None = None, port: int | None = None):
96
+ self.udid = udid
97
+ self._manage_companion = host is None
98
+
99
+ if host is None:
100
+ actual_port = port if port is not None else _find_available_port()
101
+ self.address = TCPAddress(host="localhost", port=actual_port)
102
+ logger.debug(f"Will manage companion for {udid} on port {actual_port}")
103
+ else:
104
+ actual_port = port if port is not None else 10882
105
+ self.address = TCPAddress(host=host, port=actual_port)
106
+
107
+ self.companion_process: subprocess.Popen | None = None
108
+ self._client: Client | None = None
109
+ self._client_generator: Any = None
110
+
111
+ @property
112
+ def client(self) -> Client:
113
+ """Get the initialized IDB client. Raises if not initialized."""
114
+ if self._client is None:
115
+ raise RuntimeError(
116
+ "IDB client not initialized. "
117
+ "Use 'async with' context manager or call init_companion() first."
118
+ )
119
+ return self._client
120
+
121
+ async def init_companion(self, idb_companion_path: str = "idb_companion") -> bool:
122
+ """
123
+ Start the idb_companion process for this device.
124
+ Only starts if managing companion locally (host was None in __init__).
125
+
126
+ Args:
127
+ idb_companion_path: Path to idb_companion binary (default: "idb_companion" from PATH)
128
+
129
+ Returns:
130
+ True if companion started successfully, False otherwise
131
+ """
132
+ if not self._manage_companion:
133
+ logger.info(f"Using external idb_companion at {self.address.host}:{self.address.port}")
134
+ # Still need to build the client connection
135
+ logger.debug("Building IDB client connection...")
136
+ self._client_generator = Client.build(address=self.address, logger=logger.logger)
137
+ self._client = await self._client_generator.__aenter__()
138
+ logger.debug("IDB client connected")
139
+ return True
140
+
141
+ if self.companion_process is not None:
142
+ logger.warning(f"idb_companion already running for {self.udid}")
143
+ return True
144
+
145
+ try:
146
+ cmd = [idb_companion_path, "--udid", self.udid, "--grpc-port", str(self.address.port)]
147
+
148
+ logger.info(f"Starting idb_companion: {' '.join(cmd)}")
149
+ self.companion_process = subprocess.Popen(
150
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
151
+ )
152
+
153
+ # Wait longer for gRPC server to be fully ready
154
+ logger.debug("Waiting for idb_companion gRPC server to be ready...")
155
+ await asyncio.sleep(5)
156
+
157
+ if self.companion_process.poll() is not None:
158
+ stdout, stderr = self.companion_process.communicate()
159
+ logger.error(f"idb_companion failed to start: {stderr}")
160
+ self.companion_process = None
161
+ return False
162
+
163
+ logger.info(
164
+ f"idb_companion started successfully for {self.udid} on port {self.address.port}"
165
+ )
166
+
167
+ # Build and store the client connection
168
+ logger.debug("Building IDB client connection...")
169
+ self._client_generator = Client.build(address=self.address, logger=logger.logger)
170
+ self._client = await self._client_generator.__aenter__()
171
+ logger.debug("IDB client connected")
172
+
173
+ return True
174
+
175
+ except FileNotFoundError:
176
+ logger.error(
177
+ "idb_companion not found. Please install fb-idb to use iOS devices.\n"
178
+ "Installation guide: https://fbidb.io/docs/installation/\n"
179
+ "On macOS with Homebrew: brew install idb-companion"
180
+ )
181
+ self.companion_process = None
182
+ return False
183
+ except Exception as e:
184
+ logger.error(f"Failed to start idb_companion: {e}")
185
+ self.companion_process = None
186
+ return False
187
+
188
+ async def cleanup(self) -> None:
189
+ # Always close the client context manager if it exists
190
+ if self._client_generator is not None:
191
+ try:
192
+ logger.debug("Closing IDB client connection...")
193
+ await self._client_generator.__aexit__(None, None, None)
194
+ logger.debug("IDB client closed")
195
+ except Exception as e:
196
+ logger.error(f"Error closing IDB client: {e}")
197
+ finally:
198
+ self._client = None
199
+ self._client_generator = None
200
+
201
+ if not self._manage_companion:
202
+ logger.debug(f"Not managing companion for {self.udid}, skipping companion cleanup")
203
+ return
204
+
205
+ if self.companion_process is None:
206
+ return
207
+
208
+ try:
209
+ logger.info(f"Stopping idb_companion for {self.udid}")
210
+
211
+ self.companion_process.terminate()
212
+
213
+ try:
214
+ await asyncio.wait_for(asyncio.to_thread(self.companion_process.wait), timeout=5.0)
215
+ logger.info(f"idb_companion stopped gracefully for {self.udid}")
216
+ except TimeoutError:
217
+ logger.warning(f"Force killing idb_companion for {self.udid}")
218
+ self.companion_process.kill()
219
+ await asyncio.to_thread(self.companion_process.wait)
220
+
221
+ except Exception as e:
222
+ logger.error(f"Error stopping idb_companion: {e}")
223
+ finally:
224
+ self.companion_process = None
225
+
226
+ def __del__(self):
227
+ if self.companion_process is not None:
228
+ try:
229
+ self.companion_process.terminate()
230
+ self.companion_process.wait(timeout=2)
231
+ except Exception:
232
+ try:
233
+ self.companion_process.kill()
234
+ except Exception:
235
+ pass
236
+
237
+ async def __aenter__(self):
238
+ if not await self.init_companion():
239
+ raise RuntimeError(f"Failed to initialize idb_companion for device {self.udid}")
240
+ return self
241
+
242
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
243
+ await self.cleanup()
244
+ return False
245
+
246
+ @with_idb_client
247
+ async def tap(self, x: int, y: int, duration: float | None = None) -> bool:
248
+ await self.client.tap(x=x, y=y, duration=duration)
249
+ return True
250
+
251
+ @with_idb_client
252
+ async def swipe(
253
+ self,
254
+ x_start: int,
255
+ y_start: int,
256
+ x_end: int,
257
+ y_end: int,
258
+ duration: float | None = None,
259
+ ) -> bool:
260
+ await self.client.swipe(p_start=(x_start, y_start), p_end=(x_end, y_end), duration=duration)
261
+ return True
262
+
263
+ @with_idb_client
264
+ async def screenshot(self, output_path: str | None = None) -> bytes | None:
265
+ """
266
+ Take a screenshot and return raw image data.
267
+
268
+ Returns:
269
+ Raw image data (PNG bytes not base64 encoded)
270
+ """
271
+ screenshot_data = await self.client.screenshot()
272
+ if output_path:
273
+ with open(output_path, "wb") as f:
274
+ f.write(screenshot_data)
275
+ return screenshot_data
276
+
277
+ @with_idb_client
278
+ async def launch(
279
+ self,
280
+ bundle_id: str,
281
+ args: list[str] | None = None,
282
+ env: dict[str, str] | None = None,
283
+ ) -> bool:
284
+ await self.client.launch(
285
+ bundle_id=bundle_id, args=args or [], env=env or {}, foreground_if_running=True
286
+ )
287
+ return True
288
+
289
+ @with_idb_client
290
+ async def terminate(self, bundle_id: str) -> bool:
291
+ await self.client.terminate(bundle_id)
292
+ return True
293
+
294
+ @with_idb_client
295
+ async def install(self, app_path: str) -> list[InstalledArtifact] | None:
296
+ bundle_path = Path(app_path)
297
+ artifacts = []
298
+ with open(bundle_path, "rb") as f:
299
+ async for artifact in self.client.install(bundle=f):
300
+ artifacts.append(artifact)
301
+ return artifacts
302
+
303
+ @with_idb_client
304
+ async def uninstall(self, bundle_id: str) -> bool:
305
+ await self.client.uninstall(bundle_id)
306
+ return True
307
+
308
+ @with_idb_client
309
+ async def list_apps(self) -> list[InstalledAppInfo] | None:
310
+ apps = await self.client.list_apps()
311
+ return apps
312
+
313
+ @with_idb_client
314
+ async def text(self, text: str) -> bool:
315
+ await self.client.text(text)
316
+ return True
317
+
318
+ @with_idb_client
319
+ async def key(self, key_code: int) -> bool:
320
+ await self.client.key(key_code)
321
+ return True
322
+
323
+ @with_idb_client
324
+ async def button(self, button_type: HIDButtonType) -> bool:
325
+ await self.client.button(button_type=button_type)
326
+ return True
327
+
328
+ @with_idb_client
329
+ async def clear_keychain(self) -> bool:
330
+ await self.client.clear_keychain()
331
+ return True
332
+
333
+ @with_idb_client
334
+ async def open_url(self, url: str) -> bool:
335
+ await self.client.open_url(url)
336
+ return True
337
+
338
+ async def app_current(self) -> IOSAppInfo | None:
339
+ """Get information about the currently active app on simulator.
340
+
341
+ Uses idb ui describe-all to find the app name from the UI hierarchy,
342
+ then looks up the bundle ID from simctl listapps.
343
+ Returns dict with bundleId or None.
344
+ """
345
+ try:
346
+ # Get the accessibility hierarchy to find the foreground app name
347
+ elements = await self.describe_all()
348
+ if not elements:
349
+ return None
350
+
351
+ # Find the Application element - it contains the app name in AXLabel
352
+ app_name = None
353
+ for elem in elements:
354
+ if elem.get("type") == "Application":
355
+ app_name = elem.get("AXLabel") or elem.get("label")
356
+ break
357
+
358
+ if not app_name:
359
+ return None
360
+
361
+ # Get installed apps from simctl and find bundle ID by display name
362
+ cmd = ["xcrun", "simctl", "listapps", self.udid]
363
+ process = await asyncio.create_subprocess_exec(
364
+ *cmd,
365
+ stdout=asyncio.subprocess.PIPE,
366
+ stderr=asyncio.subprocess.PIPE,
367
+ )
368
+ stdout, _ = await process.communicate()
369
+
370
+ if process.returncode != 0:
371
+ return IOSAppInfo(name=app_name, bundle_id=None)
372
+
373
+ # Parse plist-style output
374
+ # Format: "com.apple.MobileAddressBook" = { ... CFBundleDisplayName = Contacts; ...}
375
+ import re
376
+
377
+ output = stdout.decode()
378
+ current_bundle_id = None
379
+
380
+ for line in output.split("\n"):
381
+ line = line.strip()
382
+ # Match app entry: "com.bundle.id" = {
383
+ bundle_match = re.match(r'"([^"]+)"\s*=\s*\{', line)
384
+ if bundle_match:
385
+ current_bundle_id = bundle_match.group(1)
386
+ continue
387
+
388
+ # Match display name: CFBundleDisplayName = AppName; (no quotes)
389
+ # or CFBundleName = AppName;
390
+ if current_bundle_id:
391
+ name_match = re.match(r"CFBundle(?:Display)?Name\s*=\s*([^;]+);", line)
392
+ if name_match:
393
+ display_name = name_match.group(1).strip()
394
+ if display_name == app_name:
395
+ return IOSAppInfo(name=app_name, bundle_id=current_bundle_id)
396
+
397
+ # Reset on closing brace
398
+ if line == "};":
399
+ current_bundle_id = None
400
+
401
+ return IOSAppInfo(name=app_name, bundle_id=None)
402
+ except Exception as e:
403
+ logger.debug(f"Failed to get current app: {e}")
404
+ return None
405
+
406
+ async def describe_all(self) -> list[dict[str, Any]] | None:
407
+ try:
408
+ cmd = ["idb", "ui", "describe-all", "--udid", self.udid, "--json"]
409
+ process = await asyncio.create_subprocess_exec(
410
+ *cmd,
411
+ stdout=asyncio.subprocess.PIPE,
412
+ stderr=asyncio.subprocess.PIPE,
413
+ )
414
+ stdout, stderr = await process.communicate()
415
+
416
+ if process.returncode != 0:
417
+ logger.error(f"idb describe-all failed: {stderr.decode()}")
418
+ return None
419
+
420
+ parsed = json.loads(stdout.decode())
421
+ return parsed if isinstance(parsed, list) else [parsed]
422
+ except Exception as e:
423
+ logger.error(f"Failed to describe_all: {e}")
424
+ return None
425
+
426
+ @with_idb_client
427
+ async def describe_point(self, x: int, y: int) -> dict[str, Any] | None:
428
+ accessibility_info = await self.client.accessibility_info(point=(x, y), nested=True)
429
+ return json.loads(accessibility_info.json)