magnax 1.0.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 (87) hide show
  1. magnax/__init__.py +3 -0
  2. magnax/__main__.py +25 -0
  3. magnax/debug.py +65 -0
  4. magnax/public/__init__.py +1 -0
  5. magnax/public/adb/linux/adb +0 -0
  6. magnax/public/adb/linux_arm/adb +0 -0
  7. magnax/public/adb/mac/adb +0 -0
  8. magnax/public/adb/windows/AdbWinApi.dll +0 -0
  9. magnax/public/adb/windows/AdbWinUsbApi.dll +0 -0
  10. magnax/public/adb/windows/adb.exe +0 -0
  11. magnax/public/adb.py +96 -0
  12. magnax/public/android_fps.py +750 -0
  13. magnax/public/apm.py +1306 -0
  14. magnax/public/apm_pk.py +184 -0
  15. magnax/public/common.py +1598 -0
  16. magnax/public/config.json +1 -0
  17. magnax/public/ios_perf_adapter.py +790 -0
  18. magnax/public/report_template/android.html +526 -0
  19. magnax/public/report_template/ios.html +482 -0
  20. magnax/public/scrcpy/scrcpy-win32-v2.4/AdbWinApi.dll +0 -0
  21. magnax/public/scrcpy/scrcpy-win32-v2.4/AdbWinUsbApi.dll +0 -0
  22. magnax/public/scrcpy/scrcpy-win32-v2.4/SDL2.dll +0 -0
  23. magnax/public/scrcpy/scrcpy-win32-v2.4/adb.exe +0 -0
  24. magnax/public/scrcpy/scrcpy-win32-v2.4/avcodec-60.dll +0 -0
  25. magnax/public/scrcpy/scrcpy-win32-v2.4/avformat-60.dll +0 -0
  26. magnax/public/scrcpy/scrcpy-win32-v2.4/avutil-58.dll +0 -0
  27. magnax/public/scrcpy/scrcpy-win32-v2.4/icon.png +0 -0
  28. magnax/public/scrcpy/scrcpy-win32-v2.4/libusb-1.0.dll +0 -0
  29. magnax/public/scrcpy/scrcpy-win32-v2.4/open_a_terminal_here.bat +1 -0
  30. magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-console.bat +2 -0
  31. magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-noconsole.vbs +7 -0
  32. magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-server +0 -0
  33. magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy.exe +0 -0
  34. magnax/public/scrcpy/scrcpy-win32-v2.4/swresample-4.dll +0 -0
  35. magnax/public/scrcpy/scrcpy-win64-v2.4/AdbWinApi.dll +0 -0
  36. magnax/public/scrcpy/scrcpy-win64-v2.4/AdbWinUsbApi.dll +0 -0
  37. magnax/public/scrcpy/scrcpy-win64-v2.4/SDL2.dll +0 -0
  38. magnax/public/scrcpy/scrcpy-win64-v2.4/avformat-60.dll +0 -0
  39. magnax/public/scrcpy/scrcpy-win64-v2.4/avutil-58.dll +0 -0
  40. magnax/public/scrcpy/scrcpy-win64-v2.4/open_a_terminal_here.bat +1 -0
  41. magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy-noconsole.vbs +7 -0
  42. magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy-server +0 -0
  43. magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy.exe +0 -0
  44. magnax/public/scrcpy/scrcpy-win64-v2.4/swresample-4.dll +0 -0
  45. magnax/static/css/highlight.min.css +9 -0
  46. magnax/static/css/magnax-dark-theme.css +1237 -0
  47. magnax/static/css/select2-bootstrap-5-theme.min.css +3 -0
  48. magnax/static/css/select2-bootstrap-5-theme.rtl.min.css +3 -0
  49. magnax/static/css/select2.min.css +1 -0
  50. magnax/static/css/sweetalert2.min.css +1 -0
  51. magnax/static/css/tabler.demo.min.css +9 -0
  52. magnax/static/css/tabler.min.css +14 -0
  53. magnax/static/image/500.png +0 -0
  54. magnax/static/image/avatar.png +0 -0
  55. magnax/static/image/empty.png +0 -0
  56. magnax/static/image/readme/home.png +0 -0
  57. magnax/static/image/readme/pk.png +0 -0
  58. magnax/static/js/apexcharts.js +14 -0
  59. magnax/static/js/gray.js +16 -0
  60. magnax/static/js/highlight.min.js +1173 -0
  61. magnax/static/js/highstock.js +803 -0
  62. magnax/static/js/html2canvas.min.js +20 -0
  63. magnax/static/js/jquery.min.js +2 -0
  64. magnax/static/js/magnax-chart-theme.js +492 -0
  65. magnax/static/js/select2.min.js +2 -0
  66. magnax/static/js/sweetalert2.min.js +1 -0
  67. magnax/static/js/tabler.demo.min.js +9 -0
  68. magnax/static/js/tabler.min.js +9 -0
  69. magnax/static/logo/logo.png +0 -0
  70. magnax/templates/404.html +30 -0
  71. magnax/templates/analysis.html +1375 -0
  72. magnax/templates/analysis_compare.html +600 -0
  73. magnax/templates/analysis_pk.html +680 -0
  74. magnax/templates/base.html +365 -0
  75. magnax/templates/index.html +2471 -0
  76. magnax/templates/pk.html +743 -0
  77. magnax/templates/report.html +416 -0
  78. magnax/view/__init__.py +1 -0
  79. magnax/view/apis.py +952 -0
  80. magnax/view/pages.py +146 -0
  81. magnax/web.py +345 -0
  82. magnax-1.0.0.dist-info/METADATA +242 -0
  83. magnax-1.0.0.dist-info/RECORD +87 -0
  84. magnax-1.0.0.dist-info/WHEEL +5 -0
  85. magnax-1.0.0.dist-info/entry_points.txt +2 -0
  86. magnax-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. magnax-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,790 @@
1
+ """
2
+ iOS Performance Adapter using pymobiledevice3 Python API only.
3
+
4
+ This module provides a unified adapter for collecting iOS performance metrics
5
+ using pymobiledevice3 DVT services directly (no CLI subprocess calls).
6
+
7
+ For iOS 17+: Uses tunneld service
8
+ Start with: sudo python3 -m pymobiledevice3 remote start-tunnel
9
+ Or daemon mode: sudo python3 -m pymobiledevice3 remote tunneld
10
+
11
+ For iOS < 17: Uses direct USB connection via lockdown
12
+ """
13
+
14
+ import time
15
+ import asyncio
16
+ import threading
17
+ from dataclasses import dataclass, field
18
+ from typing import Tuple, Optional, Dict, Any, List
19
+ from loguru import logger
20
+
21
+
22
+ def _run_async(coro):
23
+ """Run an async coroutine in sync context."""
24
+ try:
25
+ loop = asyncio.get_running_loop()
26
+ except RuntimeError:
27
+ loop = None
28
+
29
+ if loop and loop.is_running():
30
+ # We're already in an async context, create a new loop in a thread
31
+ import concurrent.futures
32
+ with concurrent.futures.ThreadPoolExecutor() as pool:
33
+ future = pool.submit(asyncio.run, coro)
34
+ return future.result(timeout=30)
35
+ else:
36
+ return asyncio.run(coro)
37
+
38
+
39
+ @dataclass
40
+ class PerformanceCache:
41
+ """Performance data cache with TTL support."""
42
+ cpu_app: float = 0.0
43
+ cpu_sys: float = 0.0
44
+ memory_mb: float = 0.0
45
+ fps: int = 0
46
+ gpu: float = 0.0
47
+ network_rx_kb: float = 0.0
48
+ network_tx_kb: float = 0.0
49
+ timestamp: float = field(default_factory=time.time)
50
+
51
+ # Raw network bytes for delta calculation
52
+ _last_net_rx_bytes: int = 0
53
+ _last_net_tx_bytes: int = 0
54
+ _last_net_time: float = 0.0
55
+
56
+ def is_valid(self, ttl: float = 2.0) -> bool:
57
+ """Check if cache is still valid."""
58
+ return (time.time() - self.timestamp) < ttl
59
+
60
+
61
+ class PMD3PerformanceAdapter:
62
+ """
63
+ Unified pymobiledevice3 performance adapter.
64
+
65
+ Uses DVT services directly via Python API for all iOS versions.
66
+ For iOS 17+, tunneld must be running:
67
+ sudo python3 -m pymobiledevice3 remote tunneld
68
+ """
69
+
70
+ def __init__(self, device_id: str, bundle_id: str):
71
+ self.device_id = device_id
72
+ self.bundle_id = bundle_id
73
+
74
+ self._lock = threading.RLock()
75
+ self._dvt = None
76
+ self._lockdown = None
77
+ self._rsd = None
78
+ self._is_ios17: Optional[bool] = None
79
+ self._init_error: Optional[str] = None
80
+ self._target_pid: Optional[int] = None
81
+
82
+ # Cache
83
+ self._cache = PerformanceCache()
84
+ self._cache_ttl = 2.0
85
+
86
+ # Sysmontap data cache (shared between CPU/Memory/Network)
87
+ self._sysmon_data: Optional[Dict] = None
88
+ self._sysmon_processes: Optional[List[Dict]] = None
89
+ self._sysmon_time: float = 0.0
90
+
91
+ # CPU delta tracking (for calculating CPU % from cpuTotalUser/cpuTotalSystem)
92
+ self._last_cpu_time: float = 0.0
93
+ self._last_cpu_total: Dict[int, int] = {} # pid -> cpuTotalUser + cpuTotalSystem
94
+ self._sysmon_ttl = 1.0 # Reduced TTL for more responsive data
95
+
96
+ # Graphics data cache (shared between FPS/GPU)
97
+ self._graphics_data: Optional[Dict] = None
98
+ self._graphics_time: float = 0.0
99
+ self._graphics_ttl = 1.0 # Reduced TTL for more responsive data
100
+
101
+ # Collection state tracking
102
+ self._collecting_sysmon = False
103
+ self._collecting_graphics = False
104
+
105
+ def _detect_ios_version(self) -> bool:
106
+ """Detect if device is iOS 17+."""
107
+ if self._is_ios17 is not None:
108
+ return self._is_ios17
109
+
110
+ try:
111
+ from pymobiledevice3.lockdown import create_using_usbmux
112
+ lockdown = create_using_usbmux(serial=self.device_id)
113
+ version = lockdown.product_version
114
+ if version:
115
+ major = int(version.split('.')[0])
116
+ self._is_ios17 = major >= 17
117
+ logger.info(f"[iOS Perf] Device iOS version: {version}, iOS 17+: {self._is_ios17}")
118
+ return self._is_ios17
119
+ except Exception as e:
120
+ logger.debug(f"[iOS Perf] Failed to detect iOS version via USB: {e}")
121
+
122
+ # If USB detection fails, try tunneld service (iOS 17+ only works via tunnel)
123
+ try:
124
+ from pymobiledevice3.tunneld.api import get_tunneld_devices
125
+ devices = get_tunneld_devices()
126
+ if devices:
127
+ for rsd in devices:
128
+ if self.device_id is None or self.device_id in str(rsd.udid):
129
+ self._is_ios17 = True
130
+ logger.info(f"[iOS Perf] Found device via tunneld, assuming iOS 17+")
131
+ return True
132
+ except ImportError:
133
+ pass
134
+ except Exception as e:
135
+ logger.debug(f"[iOS Perf] tunneld service check failed: {e}")
136
+
137
+ # Default to iOS < 17
138
+ self._is_ios17 = False
139
+ return False
140
+
141
+ def _get_tunnel_rsd(self):
142
+ """Get RemoteServiceDiscovery from tunneld service."""
143
+ try:
144
+ from pymobiledevice3.tunneld.api import get_tunneld_devices, get_tunneld_device_by_udid
145
+ from pymobiledevice3.exceptions import TunneldConnectionError
146
+
147
+ # Try to get all devices first (more reliable)
148
+ try:
149
+ devices = get_tunneld_devices()
150
+ if devices and len(devices) > 0:
151
+ for rsd in devices:
152
+ if self.device_id is None or self.device_id in str(rsd.udid):
153
+ logger.info(f"[iOS Perf] Found tunneld device: {rsd.udid}")
154
+ return rsd
155
+ # Use first device if no exact match
156
+ logger.info(f"[iOS Perf] Using first tunneld device: {devices[0].udid}")
157
+ return devices[0]
158
+ else:
159
+ logger.warning("[iOS Perf] tunneld returned no devices")
160
+ except TunneldConnectionError:
161
+ logger.warning("[iOS Perf] tunneld connection failed - is tunneld running?")
162
+
163
+ except ImportError as e:
164
+ logger.warning(f"[iOS Perf] tunneld API not available: {e}")
165
+ except Exception as e:
166
+ logger.warning(f"[iOS Perf] tunneld error: {type(e).__name__}: {e}")
167
+ return None
168
+
169
+ def _create_dvt_service(self):
170
+ """Create DVT service based on iOS version."""
171
+ from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService
172
+
173
+ self._detect_ios_version()
174
+
175
+ if self._is_ios17:
176
+ # iOS 17+ requires tunnel service
177
+ rsd = self._get_tunnel_rsd()
178
+ if rsd is None:
179
+ self._init_error = (
180
+ "iOS 17+ requires tunnel service. Please run:\n"
181
+ " sudo python3 -m pymobiledevice3 remote start-tunnel\n"
182
+ "Or keep tunneld running in background:\n"
183
+ " sudo python3 -m pymobiledevice3 remote tunneld"
184
+ )
185
+ logger.error(f"[iOS Perf] {self._init_error}")
186
+ return None
187
+
188
+ self._rsd = rsd
189
+ dvt = DvtSecureSocketProxyService(lockdown=rsd)
190
+ dvt.__enter__()
191
+ logger.info("[iOS Perf] DVT service connected via tunnel (iOS 17+)")
192
+ return dvt
193
+ else:
194
+ # iOS < 17 uses direct USB
195
+ from pymobiledevice3.lockdown import create_using_usbmux
196
+ lockdown = create_using_usbmux(serial=self.device_id)
197
+ self._lockdown = lockdown
198
+ dvt = DvtSecureSocketProxyService(lockdown=lockdown)
199
+ dvt.__enter__()
200
+ logger.info("[iOS Perf] DVT service connected via USB (iOS < 17)")
201
+ return dvt
202
+
203
+ def _ensure_connected(self, max_retries: int = 2) -> bool:
204
+ """Ensure DVT service is connected with retry logic."""
205
+ with self._lock:
206
+ if self._dvt is not None:
207
+ return True
208
+
209
+ last_error = None
210
+ for attempt in range(max_retries):
211
+ try:
212
+ self._dvt = self._create_dvt_service()
213
+ if self._dvt:
214
+ self._resolve_target_pid()
215
+ return True
216
+ except Exception as e:
217
+ last_error = e
218
+ logger.warning(f"[iOS Perf] Connection attempt {attempt + 1}/{max_retries} failed: {e}")
219
+ if attempt < max_retries - 1:
220
+ time.sleep(0.5) # Brief delay before retry
221
+
222
+ if last_error:
223
+ self._init_error = str(last_error)
224
+ logger.error(f"[iOS Perf] Failed to connect DVT service after {max_retries} attempts: {last_error}")
225
+
226
+ return False
227
+
228
+ def _resolve_target_pid(self):
229
+ """Try to resolve PID for bundle_id using ProcessControl."""
230
+ if not self._dvt or not self.bundle_id:
231
+ return
232
+
233
+ try:
234
+ from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl
235
+ proc_ctrl = ProcessControl(self._dvt)
236
+ pid = proc_ctrl.process_identifier_for_bundle_identifier(self.bundle_id)
237
+ if pid and pid > 0:
238
+ self._target_pid = pid
239
+ logger.info(f"[iOS Perf] Resolved PID {pid} for bundle_id {self.bundle_id}")
240
+ return
241
+ except Exception as e:
242
+ logger.debug(f"[iOS Perf] ProcessControl lookup failed: {e}")
243
+
244
+ self._target_pid = None
245
+
246
+ def _collect_sysmontap_data(self) -> Tuple[Optional[Dict], Optional[List[Dict]]]:
247
+ """
248
+ Collect system monitoring data via Sysmontap.
249
+ Returns (system_data, processes_list).
250
+ Uses caching to avoid repeated calls.
251
+ """
252
+ # Non-blocking check: if another thread is collecting, return cached data
253
+ if self._collecting_sysmon:
254
+ logger.debug("[iOS Perf] Sysmontap collection in progress, returning cached data")
255
+ return self._sysmon_data, self._sysmon_processes
256
+
257
+ with self._lock:
258
+ current_time = time.time()
259
+ if (self._sysmon_data is not None and
260
+ (current_time - self._sysmon_time) < self._sysmon_ttl):
261
+ return self._sysmon_data, self._sysmon_processes
262
+
263
+ if not self._ensure_connected():
264
+ return None, None
265
+
266
+ self._collecting_sysmon = True
267
+
268
+ try:
269
+ from pymobiledevice3.services.dvt.instruments.sysmontap import Sysmontap
270
+
271
+ system_data = {}
272
+ processes = []
273
+ sys_attrs = None
274
+ proc_attrs = None
275
+
276
+ sysmon = Sysmontap(self._dvt)
277
+ sysmon.__enter__()
278
+
279
+ try:
280
+ sample_count = 0
281
+ has_system_data = False
282
+ has_process_data = False
283
+ processes_sample_count = 0
284
+
285
+ for raw_data in sysmon:
286
+ # Skip non-dict samples (sometimes strings are returned)
287
+ if not isinstance(raw_data, dict):
288
+ continue
289
+
290
+ sample_count += 1
291
+
292
+ # Get attribute lists (field names) - only available in first sample
293
+ if sys_attrs is None:
294
+ sys_attrs = raw_data.get('SystemAttributes', [])
295
+ proc_attrs = raw_data.get('ProcessesAttributes', [])
296
+
297
+ # Parse System data (network, CPU total)
298
+ sys_values = raw_data.get('System')
299
+ if sys_values and isinstance(sys_values, list) and sys_attrs and len(sys_values) > 0:
300
+ system_data = dict(zip(sys_attrs, sys_values))
301
+ has_system_data = True
302
+
303
+ # Also capture SystemCPUUsage if available
304
+ cpu_usage = raw_data.get('SystemCPUUsage', {})
305
+ if cpu_usage:
306
+ system_data.update(cpu_usage)
307
+
308
+ # Capture CPUCount if available
309
+ if 'CPUCount' in raw_data:
310
+ system_data['CPUCount'] = raw_data.get('CPUCount', 1)
311
+
312
+ # Parse Processes data (CPU per process, memory)
313
+ proc_dict = raw_data.get('Processes')
314
+ if proc_dict and isinstance(proc_dict, dict) and proc_attrs:
315
+ processes_sample_count += 1
316
+
317
+ # Skip first Processes sample - it often has cpuUsage=None
318
+ if processes_sample_count == 1:
319
+ logger.debug("[iOS Perf] Skipping first Processes sample")
320
+ continue
321
+
322
+ processes = [] # Clear and rebuild each time
323
+ for pid, proc_values in proc_dict.items():
324
+ if isinstance(proc_values, list):
325
+ proc_info = dict(zip(proc_attrs, proc_values))
326
+ proc_info['pid'] = pid
327
+ processes.append(proc_info)
328
+ elif isinstance(proc_values, dict):
329
+ proc_values['pid'] = pid
330
+ processes.append(proc_values)
331
+ has_process_data = True
332
+
333
+ # Need both system data and valid process data
334
+ # Processes start around sample 6, so allow up to 10 samples
335
+ if (has_system_data and has_process_data) or sample_count >= 10:
336
+ break
337
+ finally:
338
+ # Close Sysmontap, ignoring "clear" errors on some iOS versions
339
+ try:
340
+ sysmon.__exit__(None, None, None)
341
+ except Exception:
342
+ pass
343
+
344
+ # Only update cache if we got USEFUL data
345
+ # system_data must have netBytesIn to be useful
346
+ # processes must have at least one entry to be useful
347
+ has_valid_system = bool(system_data and 'netBytesIn' in system_data)
348
+ has_valid_processes = bool(processes and len(processes) > 0)
349
+
350
+ if has_valid_system or has_valid_processes:
351
+ if has_valid_system:
352
+ self._sysmon_data = system_data
353
+ if has_valid_processes:
354
+ self._sysmon_processes = processes
355
+ self._sysmon_time = current_time
356
+ logger.debug(f"[iOS Perf] Sysmontap collected: {len(processes)} processes, "
357
+ f"netBytesIn={system_data.get('netBytesIn', 'N/A')}")
358
+ else:
359
+ logger.debug("[iOS Perf] Sysmontap returned no useful data, keeping previous cache")
360
+
361
+ self._collecting_sysmon = False
362
+ return system_data, processes
363
+
364
+ except Exception as e:
365
+ self._collecting_sysmon = False
366
+ error_msg = str(e)
367
+ logger.warning(f"[iOS Perf] Sysmontap collection failed: {e}")
368
+ # Close DVT on connection errors to force reconnection
369
+ if 'Bad file descriptor' in error_msg or 'magic' in error_msg or 'closed' in error_msg.lower():
370
+ self._close_dvt()
371
+ return self._sysmon_data, self._sysmon_processes
372
+
373
+ def _collect_graphics_data(self) -> Optional[Dict]:
374
+ """
375
+ Collect graphics data via Graphics service.
376
+ Returns dict with FPS and GPU metrics.
377
+ Uses caching to avoid repeated calls.
378
+ """
379
+ # Non-blocking check: if another thread is collecting, return cached data
380
+ if self._collecting_graphics:
381
+ logger.debug("[iOS Perf] Graphics collection in progress, returning cached data")
382
+ return self._graphics_data
383
+
384
+ with self._lock:
385
+ current_time = time.time()
386
+ if (self._graphics_data is not None and
387
+ (current_time - self._graphics_time) < self._graphics_ttl):
388
+ return self._graphics_data
389
+
390
+ if not self._ensure_connected():
391
+ return None
392
+
393
+ self._collecting_graphics = True
394
+
395
+ try:
396
+ from pymobiledevice3.services.dvt.instruments.graphics import Graphics
397
+
398
+ logger.debug("[iOS Perf] Starting Graphics data collection...")
399
+ graphics = Graphics(self._dvt)
400
+ graphics.__enter__()
401
+
402
+ try:
403
+ sample_count = 0
404
+ for data in graphics:
405
+ sample_count += 1
406
+ if data:
407
+ fps = data.get('CoreAnimationFramesPerSecond', 0)
408
+ gpu = data.get('Device Utilization %', 0)
409
+
410
+ # Skip samples with FPS=0 (common when screen is idle or during startup)
411
+ # But always accept after sample 3 to avoid getting stuck
412
+ if fps == 0 and sample_count < 3:
413
+ logger.debug(f"[iOS Perf] Skipping sample {sample_count} with FPS=0")
414
+ continue
415
+
416
+ self._graphics_data = data
417
+ self._graphics_time = current_time
418
+ logger.debug(f"[iOS Perf] Graphics collected: FPS={fps}, GPU={gpu}")
419
+ break # Got sample
420
+
421
+ if sample_count >= 5: # Give up after 5 samples
422
+ logger.warning("[iOS Perf] Graphics returned no valid data after 5 samples")
423
+ break
424
+ finally:
425
+ # Close Graphics, ignoring "clear" errors on some iOS versions
426
+ try:
427
+ graphics.__exit__(None, None, None)
428
+ except Exception:
429
+ pass
430
+
431
+ self._collecting_graphics = False
432
+ return self._graphics_data
433
+
434
+ except Exception as e:
435
+ self._collecting_graphics = False
436
+ error_msg = str(e)
437
+ logger.warning(f"[iOS Perf] Graphics collection failed: {e}")
438
+ # Close DVT on connection errors to force reconnection
439
+ if 'Bad file descriptor' in error_msg or 'magic' in error_msg or 'closed' in error_msg.lower():
440
+ self._close_dvt()
441
+ return self._graphics_data
442
+
443
+ def _find_app_process(self, processes: List[Dict]) -> Optional[Dict]:
444
+ """Find process matching bundle_id using multiple matching strategies."""
445
+ if not processes or not self.bundle_id:
446
+ return None
447
+
448
+ # If we have a resolved PID, use it directly
449
+ if self._target_pid:
450
+ for proc in processes:
451
+ if proc.get('pid') == self._target_pid:
452
+ return proc
453
+
454
+ bundle_id_lower = self.bundle_id.lower()
455
+ app_name = self.bundle_id.split('.')[-1].lower()
456
+
457
+ # Filter meaningful bundle parts
458
+ bundle_parts = [p for p in bundle_id_lower.split('.')
459
+ if p not in ('com', 'apple', 'app', 'ios', 'mobile') and len(p) >= 3]
460
+
461
+ best_match = None
462
+ best_score = 0
463
+
464
+ for proc in processes:
465
+ name = str(proc.get('name', '')).lower()
466
+ exec_name = str(proc.get('execName', '')).lower()
467
+ bundle_identifier = str(proc.get('bundleIdentifier', '')).lower()
468
+
469
+ score = 0
470
+
471
+ # Exact bundle identifier match (highest priority)
472
+ if bundle_identifier and bundle_id_lower == bundle_identifier:
473
+ return proc
474
+
475
+ # Exact name match with bundle_id
476
+ if bundle_id_lower == name:
477
+ return proc
478
+
479
+ # Process name starts with bundle_id (handles truncation)
480
+ if name.startswith(bundle_id_lower[:len(name)]) and len(name) >= 8:
481
+ score = 100
482
+
483
+ # Bundle_id starts with process name
484
+ elif bundle_id_lower.startswith(name) and len(name) >= 8:
485
+ score = 95
486
+
487
+ # App name exact match
488
+ elif app_name == name:
489
+ score = 90
490
+
491
+ # App name in process name
492
+ elif app_name in name and len(app_name) >= 3:
493
+ score = 85
494
+
495
+ # Name in app name
496
+ elif name in app_name and len(name) >= 4:
497
+ score = 80
498
+
499
+ # Bundle parts in name
500
+ elif bundle_parts and any(part == name or part in name for part in bundle_parts):
501
+ score = 75
502
+
503
+ # Check execName
504
+ elif bundle_id_lower in exec_name:
505
+ score = 70
506
+ elif app_name in exec_name and len(app_name) >= 3:
507
+ score = 65
508
+ elif bundle_parts and any(part in exec_name for part in bundle_parts):
509
+ score = 60
510
+
511
+ if score > best_score:
512
+ best_score = score
513
+ best_match = proc
514
+
515
+ if best_score >= 60:
516
+ logger.debug(f"[iOS Perf] Matched process: {best_match.get('name')} (score={best_score})")
517
+ return best_match
518
+
519
+ logger.warning(f"[iOS Perf] No process match for {self.bundle_id}. "
520
+ f"Available: {[p.get('name') for p in processes[:15]]}")
521
+ return None
522
+
523
+ def _calculate_cpu_from_delta(self, proc: Dict, current_time: float) -> float:
524
+ """Calculate CPU % from cpuTotalUser + cpuTotalSystem delta."""
525
+ pid = proc.get('pid', 0)
526
+ cpu_user = proc.get('cpuTotalUser', 0) or 0
527
+ cpu_sys = proc.get('cpuTotalSystem', 0) or 0
528
+ cpu_total = cpu_user + cpu_sys
529
+
530
+ if self._last_cpu_time > 0 and pid in self._last_cpu_total:
531
+ dt = current_time - self._last_cpu_time
532
+ if dt > 0:
533
+ delta = cpu_total - self._last_cpu_total[pid]
534
+ # Convert from nanoseconds to seconds, then to percentage
535
+ cpu_pct = (delta / 1e9) / dt * 100
536
+ # Store for next calculation
537
+ self._last_cpu_total[pid] = cpu_total
538
+ return max(0.0, cpu_pct)
539
+
540
+ # First reading - store baseline
541
+ self._last_cpu_total[pid] = cpu_total
542
+ return 0.0
543
+
544
+ def get_cpu(self) -> Tuple[float, float]:
545
+ """
546
+ Get CPU usage.
547
+ Returns (app_cpu%, sys_cpu%).
548
+
549
+ Note: CPU % is calculated from cpuTotalUser + cpuTotalSystem deltas
550
+ since the cpuUsage field may not be populated on iOS 17+.
551
+ """
552
+ try:
553
+ system_data, processes = self._collect_sysmontap_data()
554
+ current_time = time.time()
555
+
556
+ app_cpu = 0.0
557
+ sys_cpu = 0.0
558
+ cpu_count = system_data.get('CPUCount', 6) if system_data else 6
559
+
560
+ if system_data:
561
+ # Try System CPU from total load first
562
+ total_load = system_data.get('CPU_TotalLoad', 0)
563
+ if total_load and total_load > 0:
564
+ sys_cpu = total_load / cpu_count if cpu_count > 1 else total_load
565
+
566
+ if processes:
567
+ # Find target app process
568
+ app_proc = self._find_app_process(processes)
569
+ if app_proc:
570
+ # Try cpuUsage field first
571
+ raw_cpu = app_proc.get('cpuUsage')
572
+ if raw_cpu is not None and isinstance(raw_cpu, (int, float)) and raw_cpu > 0:
573
+ app_cpu = float(raw_cpu)
574
+ else:
575
+ # Calculate from cpuTotalUser + cpuTotalSystem delta
576
+ app_cpu = self._calculate_cpu_from_delta(app_proc, current_time)
577
+
578
+ # Calculate system CPU from all processes if not available
579
+ if sys_cpu <= 0:
580
+ total_cpu = 0.0
581
+ for p in processes:
582
+ raw = p.get('cpuUsage')
583
+ if raw is not None and isinstance(raw, (int, float)) and raw > 0:
584
+ total_cpu += float(raw)
585
+ else:
586
+ # Use delta calculation
587
+ total_cpu += self._calculate_cpu_from_delta(p, current_time)
588
+ sys_cpu = total_cpu / cpu_count if cpu_count > 0 else total_cpu
589
+
590
+ # Update last CPU time for delta calculations
591
+ self._last_cpu_time = current_time
592
+
593
+ self._cache.cpu_app = round(app_cpu, 2)
594
+ self._cache.cpu_sys = round(min(sys_cpu, 100.0), 2) # Cap at 100%
595
+ return (self._cache.cpu_app, self._cache.cpu_sys)
596
+
597
+ except Exception as e:
598
+ logger.error(f"[iOS Perf] get_cpu failed: {e}")
599
+ return (self._cache.cpu_app, self._cache.cpu_sys)
600
+
601
+ def get_memory(self) -> float:
602
+ """Get memory usage in MB."""
603
+ try:
604
+ _, processes = self._collect_sysmontap_data()
605
+
606
+ if processes:
607
+ app_proc = self._find_app_process(processes)
608
+ if app_proc:
609
+ # physFootprint is the most accurate for app memory
610
+ mem_bytes = (app_proc.get('physFootprint', 0) or
611
+ app_proc.get('memResidentSize', 0) or
612
+ app_proc.get('memVirtualSize', 0) or 0)
613
+
614
+ if isinstance(mem_bytes, (int, float)) and mem_bytes > 0:
615
+ # Convert to MB
616
+ if mem_bytes > 1000000: # Bytes
617
+ self._cache.memory_mb = round(mem_bytes / (1024 * 1024), 2)
618
+ else: # Already in MB or some other unit
619
+ self._cache.memory_mb = round(mem_bytes, 2)
620
+
621
+ logger.debug(f"[iOS Perf] App memory: {self._cache.memory_mb} MB")
622
+
623
+ return self._cache.memory_mb
624
+
625
+ except Exception as e:
626
+ logger.error(f"[iOS Perf] get_memory failed: {e}")
627
+ return self._cache.memory_mb
628
+
629
+ def get_fps(self) -> int:
630
+ """Get current FPS."""
631
+ try:
632
+ data = self._collect_graphics_data()
633
+
634
+ if data:
635
+ fps = data.get('CoreAnimationFramesPerSecond', 0)
636
+ if isinstance(fps, (int, float)):
637
+ self._cache.fps = int(fps)
638
+
639
+ return self._cache.fps
640
+
641
+ except Exception as e:
642
+ logger.error(f"[iOS Perf] get_fps failed: {e}")
643
+ return self._cache.fps
644
+
645
+ def get_gpu(self) -> float:
646
+ """Get GPU utilization percentage."""
647
+ try:
648
+ data = self._collect_graphics_data()
649
+
650
+ if data:
651
+ # Try different GPU metric keys
652
+ gpu = (data.get('Device Utilization %', 0) or
653
+ data.get('Renderer Utilization %', 0) or
654
+ data.get('Tiler Utilization %', 0) or 0)
655
+
656
+ if isinstance(gpu, (int, float)):
657
+ self._cache.gpu = float(gpu)
658
+
659
+ return self._cache.gpu
660
+
661
+ except Exception as e:
662
+ logger.error(f"[iOS Perf] get_gpu failed: {e}")
663
+ return self._cache.gpu
664
+
665
+ def get_network(self) -> Tuple[float, float]:
666
+ """
667
+ Get network usage.
668
+ Returns (download_kb/s, upload_kb/s) - rate per second.
669
+ """
670
+ try:
671
+ system_data, _ = self._collect_sysmontap_data()
672
+
673
+ if system_data:
674
+ rx_bytes = system_data.get('netBytesIn', 0) or 0
675
+ tx_bytes = system_data.get('netBytesOut', 0) or 0
676
+ current_time = time.time()
677
+
678
+ logger.debug(f"[iOS Perf] Network raw: rx={rx_bytes}, tx={tx_bytes}")
679
+
680
+ if self._cache._last_net_time > 0:
681
+ # Calculate delta
682
+ time_delta = current_time - self._cache._last_net_time
683
+ if time_delta > 0:
684
+ rx_delta = max(0, rx_bytes - self._cache._last_net_rx_bytes)
685
+ tx_delta = max(0, tx_bytes - self._cache._last_net_tx_bytes)
686
+
687
+ # Convert to KB/s
688
+ self._cache.network_rx_kb = round((rx_delta / 1024) / time_delta, 2)
689
+ self._cache.network_tx_kb = round((tx_delta / 1024) / time_delta, 2)
690
+
691
+ logger.debug(f"[iOS Perf] Network delta: rx_kb/s={self._cache.network_rx_kb}, tx_kb/s={self._cache.network_tx_kb}")
692
+ else:
693
+ logger.debug("[iOS Perf] Network: first reading, storing baseline")
694
+
695
+ # Store for next delta calculation
696
+ self._cache._last_net_rx_bytes = rx_bytes
697
+ self._cache._last_net_tx_bytes = tx_bytes
698
+ self._cache._last_net_time = current_time
699
+ else:
700
+ logger.debug("[iOS Perf] Network: no system_data available")
701
+
702
+ return (self._cache.network_rx_kb, self._cache.network_tx_kb)
703
+
704
+ except Exception as e:
705
+ logger.error(f"[iOS Perf] get_network failed: {e}")
706
+ return (self._cache.network_rx_kb, self._cache.network_tx_kb)
707
+
708
+ def get_init_error(self) -> Optional[str]:
709
+ """Get initialization error message if any."""
710
+ return self._init_error
711
+
712
+ def _close_dvt(self):
713
+ """Close DVT service."""
714
+ with self._lock:
715
+ if self._dvt:
716
+ try:
717
+ self._dvt.__exit__(None, None, None)
718
+ except Exception as e:
719
+ logger.debug(f"[iOS Perf] DVT close error: {e}")
720
+ self._dvt = None
721
+ self._lockdown = None
722
+ self._rsd = None
723
+ self._target_pid = None
724
+ self._collecting_sysmon = False
725
+ self._collecting_graphics = False
726
+
727
+ def close(self):
728
+ """Clean up resources."""
729
+ self._close_dvt()
730
+ logger.info("[iOS Perf] Adapter closed")
731
+
732
+
733
+ # Backward compatibility alias
734
+ PyiOSDeviceAdapter = PMD3PerformanceAdapter
735
+
736
+
737
+ # Keep utility functions for external use
738
+ def get_ios_version(device_id: str) -> Optional[str]:
739
+ """Get iOS version for a device."""
740
+ try:
741
+ from pymobiledevice3.lockdown import create_using_usbmux
742
+ lockdown = create_using_usbmux(serial=device_id)
743
+ return lockdown.product_version
744
+ except Exception as e:
745
+ logger.error(f"[iOS Perf] Failed to get iOS version: {e}")
746
+ return None
747
+
748
+
749
+ def is_ios17_or_above(device_id: str) -> bool:
750
+ """Check if device is running iOS 17 or above."""
751
+ version = get_ios_version(device_id)
752
+ if version:
753
+ try:
754
+ major = int(version.split('.')[0])
755
+ return major >= 17
756
+ except:
757
+ pass
758
+ return False
759
+
760
+
761
+ def get_tunnel_rsd(device_id: str = None):
762
+ """Get RemoteServiceDiscovery from tunneld service."""
763
+ try:
764
+ from pymobiledevice3.tunneld.api import get_tunneld_devices, get_tunneld_device_by_udid
765
+
766
+ # Try to get specific device by UDID first
767
+ if device_id:
768
+ rsd = get_tunneld_device_by_udid(device_id)
769
+ if rsd:
770
+ logger.info(f"[iOS Perf] Found tunneld device: {rsd.udid}")
771
+ return rsd
772
+
773
+ # Fall back to getting all devices
774
+ devices = get_tunneld_devices()
775
+ if devices:
776
+ for rsd in devices:
777
+ if device_id is None or device_id in str(rsd.udid):
778
+ logger.info(f"[iOS Perf] Found tunneld device: {rsd.udid}")
779
+ return rsd
780
+ logger.info(f"[iOS Perf] Using first tunneld device: {devices[0].udid}")
781
+ return devices[0]
782
+ except ImportError:
783
+ logger.debug("[iOS Perf] tunneld API not available")
784
+ except Exception as e:
785
+ logger.debug(f"[iOS Perf] tunneld daemon not running: {e}")
786
+ return None
787
+
788
+
789
+ # Backward compatibility alias
790
+ get_tunneld_rsd = get_tunnel_rsd