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.
- magnax/__init__.py +3 -0
- magnax/__main__.py +25 -0
- magnax/debug.py +65 -0
- magnax/public/__init__.py +1 -0
- magnax/public/adb/linux/adb +0 -0
- magnax/public/adb/linux_arm/adb +0 -0
- magnax/public/adb/mac/adb +0 -0
- magnax/public/adb/windows/AdbWinApi.dll +0 -0
- magnax/public/adb/windows/AdbWinUsbApi.dll +0 -0
- magnax/public/adb/windows/adb.exe +0 -0
- magnax/public/adb.py +96 -0
- magnax/public/android_fps.py +750 -0
- magnax/public/apm.py +1306 -0
- magnax/public/apm_pk.py +184 -0
- magnax/public/common.py +1598 -0
- magnax/public/config.json +1 -0
- magnax/public/ios_perf_adapter.py +790 -0
- magnax/public/report_template/android.html +526 -0
- magnax/public/report_template/ios.html +482 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/AdbWinApi.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/AdbWinUsbApi.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/SDL2.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/adb.exe +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/avcodec-60.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/avformat-60.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/avutil-58.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/icon.png +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/libusb-1.0.dll +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/open_a_terminal_here.bat +1 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-console.bat +2 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-noconsole.vbs +7 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy-server +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/scrcpy.exe +0 -0
- magnax/public/scrcpy/scrcpy-win32-v2.4/swresample-4.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/AdbWinApi.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/AdbWinUsbApi.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/SDL2.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/avformat-60.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/avutil-58.dll +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/open_a_terminal_here.bat +1 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy-noconsole.vbs +7 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy-server +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/scrcpy.exe +0 -0
- magnax/public/scrcpy/scrcpy-win64-v2.4/swresample-4.dll +0 -0
- magnax/static/css/highlight.min.css +9 -0
- magnax/static/css/magnax-dark-theme.css +1237 -0
- magnax/static/css/select2-bootstrap-5-theme.min.css +3 -0
- magnax/static/css/select2-bootstrap-5-theme.rtl.min.css +3 -0
- magnax/static/css/select2.min.css +1 -0
- magnax/static/css/sweetalert2.min.css +1 -0
- magnax/static/css/tabler.demo.min.css +9 -0
- magnax/static/css/tabler.min.css +14 -0
- magnax/static/image/500.png +0 -0
- magnax/static/image/avatar.png +0 -0
- magnax/static/image/empty.png +0 -0
- magnax/static/image/readme/home.png +0 -0
- magnax/static/image/readme/pk.png +0 -0
- magnax/static/js/apexcharts.js +14 -0
- magnax/static/js/gray.js +16 -0
- magnax/static/js/highlight.min.js +1173 -0
- magnax/static/js/highstock.js +803 -0
- magnax/static/js/html2canvas.min.js +20 -0
- magnax/static/js/jquery.min.js +2 -0
- magnax/static/js/magnax-chart-theme.js +492 -0
- magnax/static/js/select2.min.js +2 -0
- magnax/static/js/sweetalert2.min.js +1 -0
- magnax/static/js/tabler.demo.min.js +9 -0
- magnax/static/js/tabler.min.js +9 -0
- magnax/static/logo/logo.png +0 -0
- magnax/templates/404.html +30 -0
- magnax/templates/analysis.html +1375 -0
- magnax/templates/analysis_compare.html +600 -0
- magnax/templates/analysis_pk.html +680 -0
- magnax/templates/base.html +365 -0
- magnax/templates/index.html +2471 -0
- magnax/templates/pk.html +743 -0
- magnax/templates/report.html +416 -0
- magnax/view/__init__.py +1 -0
- magnax/view/apis.py +952 -0
- magnax/view/pages.py +146 -0
- magnax/web.py +345 -0
- magnax-1.0.0.dist-info/METADATA +242 -0
- magnax-1.0.0.dist-info/RECORD +87 -0
- magnax-1.0.0.dist-info/WHEEL +5 -0
- magnax-1.0.0.dist-info/entry_points.txt +2 -0
- magnax-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|