diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc
new file mode 100644
index 0000000..e219383
Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ
diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc
new file mode 100644
index 0000000..6270b9f
Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ
diff --git a/__pycache__/server.cpython-313.pyc b/__pycache__/server.cpython-313.pyc
new file mode 100644
index 0000000..b2a6cc3
Binary files /dev/null and b/__pycache__/server.cpython-313.pyc differ
diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc
new file mode 100644
index 0000000..27fc1ae
Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ
diff --git a/__pycache__/socktest.cpython-314.pyc b/__pycache__/socktest.cpython-314.pyc
new file mode 100644
index 0000000..441fda9
Binary files /dev/null and b/__pycache__/socktest.cpython-314.pyc differ
diff --git a/main.py b/main.py
index 4ad58d3..a8684dc 100644
--- a/main.py
+++ b/main.py
@@ -1,930 +1,96 @@
import asyncio
+import dataclasses
import json
import logging
-import socket
-import time
-from dataclasses import dataclass
-from typing import Dict, Optional
-
-import uvicorn
-from fastapi import FastAPI, HTTPException
-from pydantic import BaseModel
-from pymobiledevice3.lockdown import create_using_usbmux
-from pymobiledevice3.remote.common import TunnelProtocol
-from pymobiledevice3.remote.remotexpc import RemoteXPCConnection
-from pymobiledevice3.remote.tunnel_service import CoreDeviceTunnelProxy, start_tunnel, TunnelResult
-from pymobiledevice3.service_connection import ServiceConnection
-from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService
-from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
-from pymobiledevice3.services.mobile_image_mounter import MobileImageMounterService
-from pymobiledevice3.usbmux import list_devices
-from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService
-
-
-class JsonFormatter(logging.Formatter):
- def format(self, record: logging.LogRecord) -> str:
- payload = {
- "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
- "level": record.levelname,
- "logger": record.name,
- "message": record.getMessage(),
- }
- if record.exc_info:
- payload["exc_info"] = self.formatException(record.exc_info)
- return json.dumps(payload, ensure_ascii=True)
-
-
-class LocationUpdate(BaseModel):
- latitude: float
- longitude: float
- rsd_address: Optional[str] = None
- rsd_port: Optional[int] = None
-
-
-class DeviceShort(BaseModel):
- udid: str
- connection_type: str
-
-
-class DeviceStatus(BaseModel):
- device_connected: bool = False
- device_count: int = 0
- devices: Optional[list[DeviceShort]] = None
- udid: Optional[str] = None
- device_name: Optional[str] = None
- product_version: Optional[str] = None
- phone_number: Optional[str] = None
- developer_mode_enabled: Optional[bool] = None
- ddi_mounted: Optional[bool] = None
- rsd_address: Optional[str] = None
- rsd_port: Optional[int] = None
- lockdown_trusted_port: Optional[int] = None
- lockdown_untrusted_port: Optional[int] = None
- lockdown_trusted_reachable: bool = False
- lockdown_untrusted_reachable: bool = False
- dtservicehub_reachable: bool = False
-
-
-class TunnelStartRequest(BaseModel):
- protocol: str = "tcp"
- wait_for_device: bool = False
- wait_timeout_seconds: int = 30
-
-
-class PreflightResponse(BaseModel):
- rsd_address: str
- rsd_port: int
- interface: Optional[str] = None
- protocol: Optional[str] = None
- dtservicehub_port: Optional[int] = None
- dtservicehub_reachable: bool = False
- lockdown_trusted_port: Optional[int] = None
- lockdown_untrusted_port: Optional[int] = None
- lockdown_trusted_reachable: bool = False
- lockdown_untrusted_reachable: bool = False
-
-
-@dataclass
-class TunnelState:
- task: Optional[asyncio.Task]
- stop_event: asyncio.Event
- ready_event: asyncio.Event
- result: Optional[TunnelResult] = None
- error: Optional[str] = None
- udid: Optional[str] = None
-
-
-_TUNNELS: Dict[str, TunnelState] = {}
-
-_orig_rsd_connect = RemoteServiceDiscoveryService.connect
-_orig_remotexpc_connect = RemoteXPCConnection.connect
-_orig_create_using_tcp = ServiceConnection.create_using_tcp
-
-# Global reference to keep the CLI process alive
-_location_sim_process: Optional[asyncio.subprocess.Process] = None
-
-# Global DVT session for reuse
-_dvt_session: Optional[DvtSecureSocketProxyService] = None
-_dvt_session_lock = asyncio.Lock()
-_current_rsd: Optional[RemoteServiceDiscoveryService] = None
-
-handler = logging.StreamHandler()
-handler.setFormatter(JsonFormatter())
-root_logger = logging.getLogger()
-root_logger.handlers = [handler]
-root_logger.setLevel(logging.INFO)
-logger = logging.getLogger("ios-api")
-
-
-async def _remotexpc_connect_with_timeout(self):
- """Wrapper to add timeout to TCP connection and handshake"""
- logger.info(f"RemoteXPC attempting TCP connection to {self.address}")
- tcp_start = time.time()
- try:
- self._reader, self._writer = await asyncio.wait_for(
- asyncio.open_connection(self.address[0], self.address[1]),
- timeout=5.0
- )
- tcp_elapsed = time.time() - tcp_start
- logger.info(f"RemoteXPC TCP connected in {tcp_elapsed:.2f}s")
- except asyncio.TimeoutError as exc:
- tcp_elapsed = time.time() - tcp_start
- logger.error(f"RemoteXPC TCP connection to {self.address} timed out after {tcp_elapsed:.2f}s")
- raise asyncio.TimeoutError(f"TCP connection to {self.address} timed out after 5s") from exc
- except Exception as exc:
- tcp_elapsed = time.time() - tcp_start
- logger.error(f"RemoteXPC TCP connection to {self.address} failed after {tcp_elapsed:.2f}s: {exc}")
- raise
-
- logger.info(f"RemoteXPC starting handshake")
- handshake_start = time.time()
- try:
- await self._do_handshake()
- handshake_elapsed = time.time() - handshake_start
- logger.info(f"RemoteXPC handshake complete in {handshake_elapsed:.2f}s")
- except Exception as exc:
- handshake_elapsed = time.time() - handshake_start
- logger.error(f"RemoteXPC handshake failed after {handshake_elapsed:.2f}s: {exc}")
- await self.close()
- raise
-
-
-async def _rsd_connect_with_timeout(self):
- """Modified connect with faster timeout for lockdown connections"""
- await self.service.connect()
- try:
- self.peer_info = await self.service.receive_response()
- self.udid = self.peer_info["Properties"]["UniqueDeviceID"]
- self.product_type = self.peer_info["Properties"]["ProductType"]
-
- # Skip lockdown connection - not working over RSD tunnel and not needed for DVT
- self.lockdown = None
- logger.info("Skipping lockdown connection for RSD (not required for DVT)")
-
- self.all_values = self.lockdown.all_values if self.lockdown is not None else {}
- except Exception:
- await self.close()
- raise
-
-
-RemoteXPCConnection.connect = _remotexpc_connect_with_timeout
-RemoteServiceDiscoveryService.connect = _rsd_connect_with_timeout
-
-
-def _create_using_tcp_with_ipv6(
- hostname: str,
- port: int,
- keep_alive: bool = True,
- create_connection_timeout: int = 5, # Reduced from 20 to 5 seconds
-):
- """Force IPv6 connection with reduced timeout"""
- import socket as socket_module
- from pymobiledevice3.osu.os_utils import get_os_utils
-
- # Force IPv6 socket creation for tunnel addresses
- if ':' in hostname: # IPv6 address
- logger.info(f"ServiceConnection connecting to {hostname}:{port} with {create_connection_timeout}s timeout")
- sock = socket_module.socket(socket_module.AF_INET6, socket_module.SOCK_STREAM)
- sock.settimeout(create_connection_timeout)
- connect_start = time.time()
- try:
- sock.connect((hostname, port))
- connect_elapsed = time.time() - connect_start
- logger.info(f"ServiceConnection connected to {hostname}:{port} in {connect_elapsed:.2f}s")
- # Keep a 5-second timeout for subsequent operations for faster failure detection
- sock.settimeout(5.0)
- if keep_alive:
- get_os_utils().set_keepalive(sock)
- return ServiceConnection(sock)
- except Exception as exc:
- connect_elapsed = time.time() - connect_start
- logger.error(f"ServiceConnection failed to {hostname}:{port} after {connect_elapsed:.2f}s: {exc}")
- sock.close()
- raise
- else:
- # Fall back to original for non-IPv6
- return _orig_create_using_tcp(
- hostname,
- port,
- keep_alive=keep_alive,
- create_connection_timeout=create_connection_timeout,
- )
-
-
-ServiceConnection.create_using_tcp = staticmethod(_create_using_tcp_with_ipv6)
-
-
-def _get_developer_mode_status() -> bool:
- device_serial = _get_single_device_udid()
- lockdown = create_using_usbmux(serial=device_serial, autopair=False)
- return True if MobileImageMounterService(lockdown).query_developer_mode_status() == 1 else False
-
-def _get_developer_disk_image_status() -> bool:
- device_serial = _get_single_device_udid()
- lockdown = create_using_usbmux(serial=device_serial, autopair=False)
- images = MobileImageMounterService(lockdown).copy_devices()
- is_ddi = False
- for image in images:
- if image.get("DiskImageType") == "Personalized" and image.get("PersonalizedImageType") == "DeveloperDiskImage":
- is_ddi = True
-
- return is_ddi
-
-
-
- return True if MobileImageMounterService(lockdown).is_image_mounted() == 1 else False
-
-def _get_single_device_udid() -> str:
- devices = list_devices()
- if not devices:
- raise HTTPException(status_code=404, detail="No devices connected")
- if len(devices) > 1:
- raise HTTPException(status_code=400, detail="Multiple devices connected")
- return devices[0].serial
-
-
-def _parse_protocol(value: str) -> TunnelProtocol:
- try:
- return TunnelProtocol[value.upper()]
- except KeyError as exc:
- raise HTTPException(status_code=400, detail=f"Unsupported protocol: {value}") from exc
-
-
-async def _run_usbmux_tunnel(udid: str, protocol: TunnelProtocol, state: TunnelState) -> None:
- lockdown = None
- proxy = None
- try:
- logger.info("Starting usbmux tunnel for udid=%s protocol=%s", udid, protocol.name.lower())
- lockdown = create_using_usbmux(serial=udid) if udid else create_using_usbmux()
- proxy = await CoreDeviceTunnelProxy.create(lockdown)
- async with start_tunnel(proxy, protocol=protocol) as tunnel:
- state.result = tunnel
- logger.info(
- "Tunnel ready udid=%s address=%s port=%s interface=%s protocol=%s",
- udid,
- tunnel.address,
- tunnel.port,
- tunnel.interface,
- tunnel.protocol.name.lower(),
- )
- state.ready_event.set()
- await state.stop_event.wait()
- except Exception as e:
- logger.exception("Tunnel failed udid=%s", udid)
- state.error = str(e)
- state.ready_event.set()
- finally:
- logger.info("Shutting down tunnel udid=%s", udid)
- if proxy is not None:
- await proxy.close()
- if lockdown is not None:
- lockdown.close()
-
-
-async def _start_tunnel_internal(
- udid: str,
- protocol: TunnelProtocol,
- wait_for_device: bool,
- wait_timeout_seconds: int,
-) -> TunnelResult:
- key = ""
- existing = _TUNNELS.get(key)
- if existing and existing.task is not None and not existing.task.done():
- if existing.result is not None:
- return existing.result
-
- if wait_for_device:
- timeout = max(1, wait_timeout_seconds)
- start = asyncio.get_event_loop().time()
- while True:
- devices = list_devices()
- if len(devices) == 1:
- udid = devices[0].serial
- break
- if asyncio.get_event_loop().time() - start > timeout:
- raise HTTPException(status_code=504, detail="Timed out waiting for device")
- await asyncio.sleep(0.5)
-
- stop_event = asyncio.Event()
- ready_event = asyncio.Event()
- state = TunnelState(
- task=None,
- stop_event=stop_event,
- ready_event=ready_event,
- udid=udid,
- )
- _TUNNELS[key] = state
- state.task = asyncio.create_task(_run_usbmux_tunnel(udid, protocol, state))
-
- try:
- await asyncio.wait_for(ready_event.wait(), timeout=15)
- except asyncio.TimeoutError as exc:
- raise HTTPException(status_code=504, detail="Timed out waiting for tunnel to start") from exc
-
- if state.error:
- _TUNNELS.pop(key, None)
- raise HTTPException(status_code=500, detail=f"Failed to start tunnel: {state.error}")
-
- return state.result
-
-
-def _wait_for_port(address: str, port: int, timeout_seconds: float = 10.0, interval_seconds: float = 0.5) -> bool:
- deadline = time.time() + timeout_seconds
- while time.time() < deadline:
- try:
- sock = socket.create_connection((address, port), timeout=2)
- sock.close()
- return True
- except OSError:
- if time.time() >= deadline:
- break
- time.sleep(interval_seconds)
- return False
-
-
-async def _wait_for_port_async(
- address: str,
- port: int,
- timeout_seconds: float = 10.0,
- interval_seconds: float = 0.5,
-) -> bool:
- return await asyncio.to_thread(_wait_for_port, address, port, timeout_seconds, interval_seconds)
-
-
-def _simulate_location_with_dvt(service_provider, latitude: float, longitude: float) -> None:
- logger.info("DVT opening session")
-
- # For RSD connections, manually create the service connection to dtservicehub
- # without going through lockdown (which doesn't work over RSD tunnel)
- if isinstance(service_provider, RemoteServiceDiscoveryService):
- from pymobiledevice3.services.remote_server import RemoteServer
-
- logger.info("Using RSD dtservicehub connection")
- # Get raw TCP connection to dtservicehub without RSDCheckin
- service = service_provider.start_lockdown_service_without_checkin(
- DvtSecureSocketProxyService.RSD_SERVICE_NAME
- )
-
- # Manually create RemoteServer instance bypassing LockdownService.__init__
- dvt = RemoteServer.__new__(RemoteServer)
- dvt.service_name = DvtSecureSocketProxyService.RSD_SERVICE_NAME
- dvt.lockdown = service_provider
- dvt.service = service
- dvt.logger = logging.getLogger(DvtSecureSocketProxyService.__module__)
- dvt.should_remove_ssl_context = False
- dvt.channel_cache = {}
- from pymobiledevice3.services.remote_server import ChannelFragmenter
- dvt.channel_messages = {0: ChannelFragmenter()} # BROADCAST_CHANNEL = 0
- from pymobiledevice3.services.remote_server import Channel
- dvt.broadcast = Channel.create(0, dvt)
- import threading
- dvt.lock = threading.Lock()
- dvt.supported_identifiers = []
- else:
- dvt = DvtSecureSocketProxyService(service_provider)
-
- try:
- handshake_start = time.monotonic()
- logger.info("DVT handshake start")
- dvt.perform_handshake()
- handshake_seconds = time.monotonic() - handshake_start
- logger.info("DVT handshake complete in %.2fs", handshake_seconds)
-
- set_start = time.monotonic()
- LocationSimulation(dvt).set(latitude, longitude)
- set_seconds = time.monotonic() - set_start
- logger.info("DVT location set sent in %.2fs", set_seconds)
- finally:
- if isinstance(service_provider, RemoteServiceDiscoveryService):
- dvt.service.close()
-
-
-async def _get_or_create_dvt_session(rsd: RemoteServiceDiscoveryService):
- """Get existing DVT session or create a new one (with proper initialization)"""
- global _dvt_session, _current_rsd
-
- async with _dvt_session_lock:
- # If we have a session and it's for the same RSD connection, reuse it
- if _dvt_session is not None and _current_rsd is rsd:
- logger.info("Reusing existing DVT session")
- return _dvt_session
-
- # Close old session if it exists
- if _dvt_session is not None:
- logger.info("Closing old DVT session")
- try:
- _dvt_session.service.close()
- except:
- pass
- _dvt_session = None
- _current_rsd = None
-
- # Create new DVT session
- logger.info("Creating new DVT session")
-
- def _create_dvt():
- from pymobiledevice3.services.remote_server import RemoteServer
-
- # Get raw TCP connection to dtservicehub without RSDCheckin
- service = rsd.start_lockdown_service_without_checkin(
- DvtSecureSocketProxyService.RSD_SERVICE_NAME
- )
-
- # Manually create RemoteServer instance
- dvt = RemoteServer.__new__(RemoteServer)
- dvt.service_name = DvtSecureSocketProxyService.RSD_SERVICE_NAME
- dvt.lockdown = rsd
- dvt.service = service
- dvt.logger = logging.getLogger(DvtSecureSocketProxyService.__module__)
- dvt.should_remove_ssl_context = False
- dvt.channel_cache = {}
- from pymobiledevice3.services.remote_server import ChannelFragmenter
- dvt.channel_messages = {0: ChannelFragmenter()}
- from pymobiledevice3.services.remote_server import Channel
- dvt.broadcast = Channel.create(0, dvt)
- import threading
- dvt.lock = threading.Lock()
- dvt.supported_identifiers = []
-
- # Perform handshake
- logger.info("DVT handshake start")
- handshake_start = time.monotonic()
- dvt.perform_handshake()
- handshake_seconds = time.monotonic() - handshake_start
- logger.info("DVT handshake complete in %.2fs", handshake_seconds)
-
- return dvt
-
- _dvt_session = await asyncio.wait_for(
- asyncio.to_thread(_create_dvt),
- timeout=10.0
- )
- _current_rsd = rsd
-
- return _dvt_session
-
-
-async def _simulate_location_via_library(rsd: RemoteServiceDiscoveryService, latitude: float, longitude: float) -> None:
- """Use library with persistent DVT session to avoid location bounce"""
- logger.info("Using library for location simulation (persistent session)")
-
- dvt = await _get_or_create_dvt_session(rsd)
-
- # Set location using the persistent session
- def _set_location():
- set_start = time.monotonic()
- LocationSimulation(dvt).set(latitude, longitude)
- set_seconds = time.monotonic() - set_start
- logger.info("DVT location set in %.2fs", set_seconds)
-
- await asyncio.to_thread(_set_location)
- logger.info("Location updated successfully (session maintained)")
-
-
-async def _simulate_location_via_cli(address: str, port: int, latitude: float, longitude: float) -> None:
- """Use pymobiledevice3 CLI with GPX playback to avoid location bounce"""
- global _location_sim_process
- import tempfile
- import xml.etree.ElementTree as ET
-
- logger.info("Using pymobiledevice3 CLI for location simulation (GPX playback)")
-
- # Kill any existing location simulation process
- if _location_sim_process is not None:
- logger.info("Stopping previous location simulation")
- try:
- _location_sim_process.terminate()
- await asyncio.wait_for(_location_sim_process.wait(), timeout=2.0)
- except:
- try:
- _location_sim_process.kill()
- await _location_sim_process.wait()
- except:
- pass
- _location_sim_process = None
-
- # Create a GPX file with a single stationary point that loops
- # This prevents the location from bouncing back when we restart the process
- gpx_content = f'''
-
-
- Simulated Location
-
-
-
-
-
-
-
-
-
-'''
-
- # Write GPX to temporary file
- gpx_file = tempfile.NamedTemporaryFile(mode='w', suffix='.gpx', delete=False)
- gpx_file.write(gpx_content)
- gpx_file.flush()
- gpx_path = gpx_file.name
- gpx_file.close()
-
- cmd = [
- ".venv/bin/pymobiledevice3",
- "developer",
- "dvt",
- "simulate-location",
- "play",
- "--rsd",
- address,
- str(port),
- "--disable-sleep", # Play immediately without delays
- gpx_path,
- ]
-
- logger.info(f"Running CLI command: {' '.join(cmd)}")
-
- # Start the process and keep it alive
- _location_sim_process = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- stdin=asyncio.subprocess.DEVNULL,
- )
-
- # Wait briefly to ensure location is set
- await asyncio.sleep(1.5)
-
- # Check if process is still running
- if _location_sim_process.returncode is not None:
- error_msg = f"CLI process exited with code {_location_sim_process.returncode}"
- logger.error(error_msg)
- _location_sim_process = None
- # Clean up temp file
- try:
- os.unlink(gpx_path)
- except:
- pass
- raise HTTPException(status_code=500, detail=error_msg)
-
- logger.info("CLI location simulation started (GPX playback maintains location)")
-
- # Note: We don't clean up the GPX file here since the process needs it
- # It will be cleaned up when the process is stopped
-
-
-async def _simulate_location_via_rsd_async(address: str, port: int, latitude: float, longitude: float) -> None:
- logger.info("Connecting to RSD address=%s port=%s", address, port)
- port_check_start = time.monotonic()
- if not await _wait_for_port_async(address, port, timeout_seconds=3, interval_seconds=0.5):
- port_check_seconds = time.monotonic() - port_check_start
- raise HTTPException(
- status_code=504,
- detail=f"RSD port unreachable after {port_check_seconds:.2f}s",
- )
- port_check_seconds = time.monotonic() - port_check_start
- logger.info("RSD port reachable in %.2fs", port_check_seconds)
- rsd = RemoteServiceDiscoveryService((address, port))
- connect_timeout = 60
- max_attempts = 2
- for attempt in range(1, max_attempts + 1):
- connect_start = time.monotonic()
- try:
- await asyncio.wait_for(rsd.connect(), timeout=connect_timeout)
- connect_seconds = time.monotonic() - connect_start
- logger.info("RSD connect complete in %.2fs on attempt %s/%s", connect_seconds, attempt, max_attempts)
- break
- except asyncio.TimeoutError as exc:
- connect_seconds = time.monotonic() - connect_start
- if attempt >= max_attempts:
- raise HTTPException(
- status_code=504,
- detail=f"Timed out connecting to RSD after {max_attempts} attempts",
- ) from exc
- logger.warning(
- "RSD connect timed out in %.2fs on attempt %s/%s; retrying",
- connect_seconds,
- attempt,
- max_attempts,
- )
- await asyncio.sleep(2)
- try:
- services = rsd.peer_info.get("Services", {})
- dtservicehub = services.get(DvtSecureSocketProxyService.RSD_SERVICE_NAME, {})
- logger.info("RSD services keys=%s", list(services.keys()))
- logger.info("RSD dtservicehub entry=%s", dtservicehub)
- dt_port = int(dtservicehub.get("Port", 0)) if dtservicehub else 0
- if dt_port:
- logger.info("Waiting for RSD dtservicehub port address=%s port=%s", address, dt_port)
- wait_start = time.monotonic()
- if await _wait_for_port_async(address, dt_port, timeout_seconds=30, interval_seconds=0.5):
- wait_seconds = time.monotonic() - wait_start
- logger.info("RSD dtservicehub port reachable address=%s port=%s", address, dt_port)
- else:
- wait_seconds = time.monotonic() - wait_start
- logger.warning("RSD dtservicehub port still unreachable address=%s port=%s", address, dt_port)
- logger.info("RSD dtservicehub wait complete in %.2fs", wait_seconds)
- else:
- logger.warning("RSD dtservicehub missing or port=0")
- logger.info("Starting DVT location set")
- await asyncio.to_thread(_simulate_location_with_dvt, rsd, latitude, longitude)
- finally:
- await rsd.close()
-
-
-def _simulate_location_via_rsd(address: str, port: int, latitude: float, longitude: float) -> None:
- asyncio.run(_simulate_location_via_rsd_async(address, port, latitude, longitude))
-
-
-async def _preflight_rsd_async(
- address: str,
- port: int,
- interface: Optional[str],
- protocol: Optional[str],
-) -> PreflightResponse:
- rsd = RemoteServiceDiscoveryService((address, port))
- await rsd.connect()
- try:
- services = rsd.peer_info.get("Services", {})
- dtservicehub = services.get(DvtSecureSocketProxyService.RSD_SERVICE_NAME, {})
- dt_port = int(dtservicehub.get("Port", 0)) if dtservicehub else 0
- lockdown_trusted = services.get("com.apple.mobile.lockdown.remote.trusted", {})
- lockdown_untrusted = services.get("com.apple.mobile.lockdown.remote.untrusted", {})
- trusted_port = int(lockdown_trusted.get("Port", 0)) if lockdown_trusted else 0
- untrusted_port = int(lockdown_untrusted.get("Port", 0)) if lockdown_untrusted else 0
-
- dt_reachable = (
- await _wait_for_port_async(address, dt_port, timeout_seconds=5, interval_seconds=0.5) if dt_port else False
- )
- trusted_reachable = (
- await _wait_for_port_async(address, trusted_port, timeout_seconds=3, interval_seconds=0.5)
- if trusted_port
- else False
- )
- untrusted_reachable = (
- await _wait_for_port_async(address, untrusted_port, timeout_seconds=3, interval_seconds=0.5)
- if untrusted_port
- else False
- )
-
- return PreflightResponse(
- rsd_address=address,
- rsd_port=port,
- interface=interface,
- protocol=protocol,
- dtservicehub_port=dt_port or None,
- dtservicehub_reachable=dt_reachable,
- lockdown_trusted_port=trusted_port or None,
- lockdown_untrusted_port=untrusted_port or None,
- lockdown_trusted_reachable=trusted_reachable,
- lockdown_untrusted_reachable=untrusted_reachable,
- )
- finally:
- await rsd.close()
-
-
-app = FastAPI(title="iOS Device Management API")
-
-
-@app.get("/api/status")
-async def get_status():
- """Lists all devices visible to USBMux."""
- try:
- devices = list_devices()
- if len(devices) > 1:
- device_list = []
- for d in devices:
- device_list.append(DeviceShort(udid=d.serial, connection_type=d.connection_type))
- return DeviceStatus(device_connected=True, device_count=len(devices), devices=device_list)
- try:
- lockdown = create_using_usbmux(serial=devices[0].serial, autopair=False)
- state = _TUNNELS.get("")
- if state and state.result:
- rsd_address = state.result.address
- rsd_port = state.result.port
- else:
- udid = devices[0].serial
- logger.info("Auto-starting tunnel")
- result = await _start_tunnel_internal(
- udid,
- TunnelProtocol.TCP,
- True,
- 30,
- )
- rsd_address = result.address
- rsd_port = result.port
-
- return DeviceStatus(device_connected=True, device_count=len(devices), udid=devices[0].serial,
- device_name=lockdown.get_value(key='DeviceName'),
- product_version=lockdown.product_version,
- phone_number=lockdown.get_value(key='PhoneNumber'),
- developer_mode_enabled=_get_developer_mode_status(),
- ddi_mounted=_get_developer_disk_image_status(),
- rsd_address=rsd_address, rsd_port=rsd_port, )
- except Exception as e:
- logger.info("Error establishing lockdown: %s", str(e))
- return DeviceStatus(device_connected=False, device_count=0)
- except Exception as e:
- logger.info("No device connected: %s", str(e))
- return DeviceStatus(device_connected=False, device_count=0)
-
-
-@app.get("/api/lockdown/status")
-async def get_lockdown_status():
- """Checks lockdown connectivity and basic device info."""
- try:
- device_serial = _get_single_device_udid()
- lockdown = create_using_usbmux(serial=device_serial, autopair=False)
- return {
- "udid": device_serial,
- "product_version": lockdown.product_version,
- "device_name": lockdown.get_value(key='DeviceName'),
- "phone_number": lockdown.get_value(key='PhoneNumber'),
- "status": "Connected"
- }
- except Exception as e:
- return {"status": "Disconnected", "error": str(e)}
-
-
-@app.post("/api/tunnel/start")
-async def start_usb_tunnel(data: TunnelStartRequest):
- """Starts a CoreDevice tunnel to a USB device and returns RSD connection details."""
- udid = _get_single_device_udid()
- key = ""
-
-
-@app.post("/api/tunnel/stop")
-async def stop_usb_tunnel():
- """Stops a previously started tunnel."""
- key = ""
- state = _TUNNELS.get(key)
- if state is None:
- raise HTTPException(status_code=404, detail="No tunnel found")
- if state.task is None:
- _TUNNELS.pop(key, None)
- raise HTTPException(status_code=500, detail="Tunnel state is incomplete")
-
- logger.info("Stopping tunnel")
- state.stop_event.set()
- await state.task
- _TUNNELS.pop(key, None)
- return {"status": "stopped"}
-
-
-@app.post("/api/tunnel/stop-all")
-async def stop_all_tunnels():
- """Stops all running tunnels."""
- if not _TUNNELS:
- return {"status": "stopped", "count": 0}
- logger.info("Stopping all tunnels count=%s", len(_TUNNELS))
- items = list(_TUNNELS.items())
- for _, state in items:
- if state.task is not None:
- state.stop_event.set()
- await asyncio.gather(
- *[state.task for _, state in items if state.task is not None],
- return_exceptions=True,
- )
- _TUNNELS.clear()
- return {"status": "stopped", "count": len(items)}
-
-
-@app.get("/api/tunnel/status")
-async def get_tunnel_status():
- """Returns the status of all active tunnels."""
- items = []
- for key, state in _TUNNELS.items():
- if state.result is None:
- continue
- if state.task is None:
- continue
- items.append(
- {
- "udid": state.udid,
- "rsd_address": state.result.address,
- "rsd_port": state.result.port,
- "interface": state.result.interface,
- "protocol": state.result.protocol.name.lower(),
- "running": not state.task.done(),
- }
- )
- return {"tunnels": items}
-
-
-@app.get("/api/preflight", response_model=PreflightResponse)
-async def preflight():
- """Checks RSD connectivity and service port reachability."""
- state = _TUNNELS.get("")
- if state is None or state.result is None:
- udid = _get_single_device_udid()
- logger.info("Auto-starting tunnel for preflight")
- result = await _start_tunnel_internal(
- udid,
- TunnelProtocol.TCP,
- True,
- 30,
- )
- address = result.address
- port = result.port
- return await _preflight_rsd_async(
- address,
- port,
- result.interface,
- result.protocol.name.lower(),
- )
- address = state.result.address
- port = state.result.port
- return await _preflight_rsd_async(
- address,
+import random
+import sys
+import socketio
+import tempfile
+from contextlib import nullcontext
+from functools import partial
+from pathlib import Path
+from typing import Annotated, Optional, TextIO
+
+import typer
+from typer_injector import InjectingTyper
+
+from pymobiledevice3.bonjour import DEFAULT_BONJOUR_TIMEOUT, browse_remotepairing_manual_pairing
+from pymobiledevice3.cli.cli_common import (
+ RSDServiceProviderDep,
+ async_command,
+ print_json,
+ prompt_device_list,
+ sudo_required,
+ user_requested_colored_output,
+)
+from pymobiledevice3.common import get_home_folder
+from pymobiledevice3.exceptions import NoDeviceConnectedError
+from pymobiledevice3.pair_records import PAIRING_RECORD_EXT, get_remote_pairing_record_filename
+from pymobiledevice3.remote.common import ConnectionType, TunnelProtocol
+from pymobiledevice3.remote.module_imports import MAX_IDLE_TIMEOUT, start_tunnel, verify_tunnel_imports
+from pymobiledevice3.remote.remote_service_discovery import RSD_PORT
+from pymobiledevice3.remote.tunnel_service import (
+ RemotePairingManualPairingService,
+ get_core_device_tunnel_services,
+ get_remote_pairing_tunnel_services,
+)
+from pymobiledevice3.remote.utils import get_rsds
+from pymobiledevice3.tunneld.api import TUNNELD_DEFAULT_ADDRESS
+from pymobiledevice3.utils import run_in_loop
+from server import TunneldRunnerSio, LocationSimulationState, logger
+
+
+def main():
+ cli_tunneld(host="0.0.0.0", port=8000)
+
+
+def cli_tunneld(
+ host: Annotated[str, typer.Option(help="Address to bind the tunneld server to.")] = TUNNELD_DEFAULT_ADDRESS[0],
+ port: Annotated[int, typer.Option(help="Port to bind the tunneld server to.")] = TUNNELD_DEFAULT_ADDRESS[1],
+ daemonize: Annotated[bool, typer.Option("--daemonize", "-d", help="Run tunneld in the background.")] = False,
+ protocol: Annotated[
+ TunnelProtocol,
+ typer.Option(
+ "--protocol",
+ "-p",
+ case_sensitive=False,
+ help="Transport protocol for tunneld (default: TCP on Python >=3.13, otherwise QUIC).",
+ ),
+ ] = TunnelProtocol.DEFAULT,
+ usb: Annotated[bool, typer.Option(help="Enable USB monitoring")] = True,
+ wifi: Annotated[bool, typer.Option(help="Enable WiFi monitoring")] = True,
+ usbmux: Annotated[bool, typer.Option(help="Enable usbmux monitoring")] = True,
+ mobdev2: Annotated[bool, typer.Option(help="Enable mobdev2 monitoring")] = True,
+ context: Annotated[LocationSimulationState, typer.Option(
+ help="Location simulation context to use for the server.")] = LocationSimulationState(),
+) -> None:
+ """Start Tunneld service for remote tunneling"""
+ if not verify_tunnel_imports():
+ return
+ tunneld_runner = partial(
+ TunneldRunnerSio.create,
+ host,
port,
- state.result.interface,
- state.result.protocol.name.lower(),
+ protocol=protocol,
+ usb_monitor=usb,
+ wifi_monitor=wifi,
+ usbmux_monitor=usbmux,
+ mobdev2_monitor=mobdev2,
+ context=context,
)
-
-
-@app.post("/api/simulate-location/clear")
-async def clear_location():
- """Stops location simulation by closing the DVT session."""
- global _location_sim_process, _dvt_session, _current_rsd
-
- logger.info("Clearing location simulation")
-
- # Close DVT session if it exists
- async with _dvt_session_lock:
- if _dvt_session is not None:
- try:
- # Clear the location first
- def _clear():
- LocationSimulation(_dvt_session).clear()
-
- await asyncio.to_thread(_clear)
- except:
- pass
-
- try:
- _dvt_session.service.close()
- except:
- pass
-
- _dvt_session = None
- _current_rsd = None
-
- # Also kill CLI process if running
- if _location_sim_process is not None:
+ if daemonize:
try:
- _location_sim_process.terminate()
- await asyncio.wait_for(_location_sim_process.wait(), timeout=2.0)
- except:
- try:
- _location_sim_process.kill()
- await _location_sim_process.wait()
- except:
- pass
- _location_sim_process = None
-
- logger.info("Location simulation cleared")
- return {"status": "cleared"}
-
-
-@app.post("/api/simulate-location")
-async def set_location(data: LocationUpdate):
- """Sets a simulated GPS location on the device."""
- try:
- logger.info("Simulate location request lat=%s lon=%s", data.latitude, data.longitude)
- rsd_address = data.rsd_address
- rsd_port = data.rsd_port
-
- if rsd_address is None and rsd_port is None:
- state = _TUNNELS.get("")
- if state and state.result:
- rsd_address = state.result.address
- rsd_port = state.result.port
-
- if rsd_address is None and rsd_port is None:
- udid = _get_single_device_udid()
- logger.info("Auto-starting tunnel for simulate-location")
- result = await _start_tunnel_internal(
- udid,
- TunnelProtocol.TCP,
- True,
- 30,
- )
- rsd_address = result.address
- rsd_port = result.port
-
- if rsd_address is not None and rsd_port is not None:
- # Use CLI approach - library DVT handshake has unresolved issues
- # Note: Location will briefly bounce back to real location when changing
- await _simulate_location_via_cli(rsd_address, rsd_port, data.latitude, data.longitude)
- else:
- raise HTTPException(status_code=400, detail="RSD address/port required")
-
- logger.info("Simulate location success")
- return {"status": "success", "location": {"lat": data.latitude, "lon": data.longitude}}
- except HTTPException:
- raise
- except Exception as e:
- logger.exception("Simulate location failed")
- raise HTTPException(status_code=500, detail=f"Failed to set location: {str(e)}")
+ from daemonize import Daemonize
+ except ImportError as e:
+ raise NotImplementedError("daemonizing is only supported on unix platforms") from e
+ with tempfile.NamedTemporaryFile("wt") as pid_file:
+ daemon = Daemonize(app=f"Tunneld {host}:{port}", pid=pid_file.name, action=tunneld_runner)
+ logger.info(f"starting Tunneld {host}:{port}")
+ daemon.start()
+ else:
+ tunneld_runner()
+# 4. Entry point (always last)
if __name__ == "__main__":
- uvicorn.run(app, host="0.0.0.0", port=8000)
+ main()
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index d04ebdf..ef070ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,9 +5,8 @@ requires-python = ">=3.14"
dependencies = [
"fastapi==0.135.1",
"pydantic==2.12.5",
- "flask==3.1.3",
- "flask-cors==6.0.2",
- "pymobiledevice3==7.8.3",
+ "pymobiledevice3==9.0.0",
+ "python-socketio==5.16.1",
"typing==3.10.0.0",
"uvicorn==0.41.0",
]
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..7fde647
--- /dev/null
+++ b/server.py
@@ -0,0 +1,568 @@
+import asyncio
+import dataclasses
+import json
+import logging
+import os
+import signal
+import traceback
+import warnings
+import fastapi
+import random
+from fastapi import FastAPI
+from typing import Optional
+import socketio
+from contextlib import asynccontextmanager, suppress
+from ssl import SSLEOFError
+from typing import Optional, Union
+
+import construct
+
+from pymobiledevice3.bonjour import browse_remoted
+from pymobiledevice3.cli.cli_common import print_json
+
+with warnings.catch_warnings():
+ # Ignore: "Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater."
+ warnings.simplefilter("ignore", category=UserWarning)
+ import fastapi
+
+import uvicorn
+from construct import StreamError
+from fastapi import FastAPI
+from packaging.version import Version
+
+from pymobiledevice3 import usbmux
+from pymobiledevice3.exceptions import (
+ ConnectionFailedError,
+ ConnectionFailedToUsbmuxdError,
+ ConnectionTerminatedError,
+ DeviceNotFoundError,
+ GetProhibitedError,
+ IncorrectModeError,
+ InvalidServiceError,
+ LockdownError,
+ MuxException,
+ PairingError,
+ QuicProtocolNotSupportedError,
+ StreamClosedError,
+ TunneldConnectionError
+)
+from pymobiledevice3.lockdown import create_using_usbmux, get_mobdev2_lockdowns
+from pymobiledevice3.osu.os_utils import get_os_utils
+from pymobiledevice3.remote.common import TunnelProtocol
+from pymobiledevice3.remote.module_imports import start_tunnel
+from pymobiledevice3.remote.remote_service_discovery import RSD_PORT, RemoteServiceDiscoveryService
+from pymobiledevice3.remote.tunnel_service import (
+ CoreDeviceTunnelProxy,
+ RemotePairingProtocol,
+ TunnelResult,
+ create_core_device_tunnel_service_using_rsd,
+ get_remote_pairing_tunnel_services,
+)
+from pymobiledevice3.remote.utils import get_rsds, stop_remoted
+from pymobiledevice3.utils import asyncio_print_traceback
+from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
+from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
+from pymobiledevice3.tunneld.server import TunneldCore, TunnelTask
+from pymobiledevice3.tunneld.api import (
+ TUNNELD_DEFAULT_ADDRESS,
+ get_tunneld_device_by_udid,
+ get_tunneld_devices,
+)
+
+
+class JsonFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ payload = {
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
+ "level": record.levelname,
+ "logger": record.name,
+ "message": record.getMessage(),
+ }
+ if record.exc_info:
+ payload["exc_info"] = self.formatException(record.exc_info)
+ return json.dumps(payload, ensure_ascii=True)
+
+
+handler = logging.StreamHandler()
+handler.setFormatter(JsonFormatter())
+root_logger = logging.getLogger()
+root_logger.handlers = [handler]
+root_logger.setLevel(logging.INFO)
+logger = logging.getLogger("ios-api")
+
+# bugfix: after the device reboots, it might take some time for remoted to start answering the bonjour queries
+REATTEMPT_INTERVAL = 5
+REATTEMPT_COUNT = 5
+
+REMOTEPAIRING_INTERVAL = 5
+MOBDEV2_INTERVAL = 5
+
+# USB monitor will periodically forget what interfaces it has seen
+# and force a full rescan. The value is number of iterations of the
+# inner loop (which sleeps one second each) before blowing away the
+# `previous_ips` cache.
+USB_MONITOR_RESCAN_INTERVAL = 30
+
+USBMUX_INTERVAL = 2
+OSUTILS = get_os_utils()
+TUNNEL_ACQUIRE_TIMEOUT_SECONDS = 15
+DVT_CONNECT_TIMEOUT_SECONDS = 20
+
+
+class LocationSimulationState:
+ def __init__(self):
+ self.latitude: Optional[float] = None
+ self.longitude: Optional[float] = None
+ self.udid: Optional[str] = None
+ self.simulation_active: bool = False
+ self.queue: asyncio.Queue = asyncio.Queue()
+ self.simulation_task: Optional[asyncio.Task] = None
+ self.sio: socketio.AsyncServer = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
+
+
+class TunneldRunnerSio:
+ """TunneldRunner orchestrate between the webserver and TunneldCore"""
+
+ @classmethod
+ def create(
+ cls,
+ host: str,
+ port: int,
+ context: LocationSimulationState = LocationSimulationState(),
+ protocol: TunnelProtocol = TunnelProtocol.QUIC,
+ usb_monitor: bool = True,
+ wifi_monitor: bool = True,
+ usbmux_monitor: bool = True,
+ mobdev2_monitor: bool = True,
+ ) -> None:
+ cls(
+ host,
+ port,
+ protocol=protocol,
+ usb_monitor=usb_monitor,
+ wifi_monitor=wifi_monitor,
+ usbmux_monitor=usbmux_monitor,
+ mobdev2_monitor=mobdev2_monitor,
+ context=context,
+ )._run_app()
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ context: LocationSimulationState = LocationSimulationState(),
+ protocol: TunnelProtocol = TunnelProtocol.QUIC,
+ usb_monitor: bool = True,
+ wifi_monitor: bool = True,
+ usbmux_monitor: bool = True,
+ mobdev2_monitor: bool = True,
+ ):
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ self._tunneld_core.start()
+ yield
+ logger.info("Closing tunneld tasks...")
+ await empty_queue()
+ await self._tunneld_core.close()
+ await self.context.sio.shutdown()
+
+ self.host = host
+ self.port = port
+ self.protocol = protocol
+ self.context = context
+ self._tunneld_api_address = ("127.0.0.1" if host in ("0.0.0.0", "::") else host, port)
+ self._app = FastAPI(title="iOS Device Management API", lifespan=lifespan, cors_allowed_origins="*")
+ self._asgi_app = socketio.ASGIApp(self.context.sio, self._app)
+ self._tunneld_core = TunneldCore(
+ protocol=protocol,
+ wifi_monitor=wifi_monitor,
+ usb_monitor=usb_monitor,
+ usbmux_monitor=usbmux_monitor,
+ mobdev2_monitor=mobdev2_monitor,
+ )
+
+ async def get_tun(
+ udid: Optional[str] = None, max_retries: int = 10, retry_delay: float = 0.5
+ ) -> RemoteServiceDiscoveryService:
+ """Try to connect to tunneld with retries to handle startup delay."""
+ for attempt in range(max_retries):
+ try:
+ if udid:
+ rsd = await get_tunneld_device_by_udid(udid, self._tunneld_api_address)
+ if rsd is not None:
+ logger.info("Connected to tunnel for udid %s after %s retries", udid, attempt)
+ return rsd
+
+ rsds = await get_tunneld_devices(self._tunneld_api_address)
+ if rsds:
+ if udid:
+ logger.warning("Tunnel for udid %s not found; using first available tunnel", udid)
+ logger.info("Connected to tunneld after %s retries", attempt)
+ return rsds[0]
+ except TunneldConnectionError:
+ if attempt < max_retries - 1:
+ logger.info("Waiting for tunneld to be ready... (attempt %s/%s)", attempt + 1, max_retries)
+ await asyncio.sleep(retry_delay)
+ else:
+ logger.error("Failed to connect to tunneld after max retries")
+ raise
+ raise TunneldConnectionError()
+
+ async def empty_queue():
+ """Empties all items from an asyncio.Queue."""
+ logger.info("Clearing location simulation queue... resetting ios location")
+ q = self.context.queue
+ while not q.empty():
+ try:
+ q.get_nowait()
+ q.task_done()
+ await q.join()
+ except asyncio.QueueEmpty:
+ break
+ tun = await get_tun(self.context.udid)
+ if tun is not None:
+ async with DvtProvider(tun) as dvt, LocationSimulationQueue(dvt, self.context) as locate_simulation:
+ await locate_simulation.clear()
+ self.context.simulation_active = False
+
+ async def start_queue():
+ logger.info("Starting location simulation worker...")
+ self.context.simulation_active = True
+ try:
+ if self.context.udid is None:
+ active_udids = sorted(
+ {t.udid for t in self._tunneld_core.tunnel_tasks.values() if t.udid is not None and t.tunnel is not None}
+ )
+ if len(active_udids) == 1:
+ self.context.udid = active_udids[0]
+ logger.info("Simulation worker: auto-selected udid=%s from active tunnel", self.context.udid)
+ elif len(active_udids) == 0:
+ logger.error("Simulation worker: no active tunnel with udid available")
+ await self.context.sio.emit(
+ "error",
+ {"type": "simulation_no_tunnel", "message": "No active tunnel found. Start tunnel first."},
+ namespace="/",
+ )
+ return
+ else:
+ logger.error("Simulation worker: multiple active tunnels; explicit udid required: %s", active_udids)
+ await self.context.sio.emit(
+ "error",
+ {
+ "type": "simulation_udid_required",
+ "message": "Multiple active tunnels found; provide udid in start_simulate_location.",
+ "udids": active_udids,
+ },
+ namespace="/",
+ )
+ return
+
+ logger.info("Simulation worker: acquiring tunnel (udid=%s)", self.context.udid)
+ tun = await asyncio.wait_for(
+ get_tun(self.context.udid),
+ timeout=TUNNEL_ACQUIRE_TIMEOUT_SECONDS,
+ )
+ logger.info("Simulation worker: tunnel acquired, connecting DVT provider")
+ dvt_provider = DvtProvider(tun)
+ dvt = await asyncio.wait_for(dvt_provider.__aenter__(), timeout=DVT_CONNECT_TIMEOUT_SECONDS)
+ logger.info("Simulation worker: DVT provider connected, starting queue playback")
+ try:
+ async with LocationSimulationQueue(dvt, self.context) as locate_simulation:
+ await locate_simulation.play_queue()
+ finally:
+ logger.info("Simulation worker: closing DVT provider")
+ await dvt_provider.__aexit__(None, None, None)
+ logger.info("Simulation worker: DVT provider closed")
+ except TimeoutError:
+ logger.error(
+ "Simulation worker timeout. tunnel_timeout=%ss dvt_timeout=%ss udid=%s",
+ TUNNEL_ACQUIRE_TIMEOUT_SECONDS,
+ DVT_CONNECT_TIMEOUT_SECONDS,
+ self.context.udid,
+ )
+ await self.context.sio.emit(
+ "error",
+ {
+ "type": "simulation_timeout",
+ "udid": self.context.udid,
+ "tunnel_timeout_seconds": TUNNEL_ACQUIRE_TIMEOUT_SECONDS,
+ "dvt_timeout_seconds": DVT_CONNECT_TIMEOUT_SECONDS,
+ },
+ namespace="/",
+ )
+ except Exception:
+ logger.exception("Simulation worker crashed")
+ await self.context.sio.emit(
+ "error",
+ {"type": "simulation_crash", "udid": self.context.udid},
+ namespace="/",
+ )
+ finally:
+ self.context.simulation_active = False
+ self.context.simulation_task = None
+
+ def iterate_multidim(d):
+ mydict = {}
+ for key, value in d.items():
+ if isinstance(value, dict):
+ iterate_multidim(value)
+ elif isinstance(value, str) or isinstance(value, int) or isinstance(value, float) or isinstance(value, bool) or isinstance(value, list):
+ mydict[key] = value
+ else:
+ mydict[key] = ''
+ return mydict
+
+ @self._app.get("/")
+ @self.context.sio.event
+ async def list_tunnels() -> dict[str, list[dict]]:
+ """Retrieve the available tunnels and format them as {UUID: TUNNEL_ADDRESS}"""
+ tunnels = {}
+ for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
+ if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
+ continue
+ if active_tunnel.udid not in tunnels:
+ tunnels[active_tunnel.udid] = []
+ tunnels[active_tunnel.udid].append({
+ "tunnel-address": active_tunnel.tunnel.address,
+ "tunnel-port": active_tunnel.tunnel.port,
+ "interface": ip,
+ })
+ return tunnels
+
+ @self._app.get("/device_info")
+ async def device_info():
+ """Get device information"""
+ tunnels = {}
+ for ip, active_tunnel in self._tunneld_core.tunnel_tasks.items():
+ if (active_tunnel.udid is None) or (active_tunnel.tunnel is None):
+ continue
+ if active_tunnel.udid not in tunnels:
+ tunnels[active_tunnel.udid] = {}
+ try:
+ lockdown = await create_using_usbmux(serial=active_tunnel.udid, autopair=False)
+ tunnels[active_tunnel.udid] = iterate_multidim(lockdown.all_values)
+ except Exception as e:
+ logger.error(f"Failed to create lockdown session for device {active_tunnel.udid}: {e}")
+ continue
+ return tunnels
+
+ @self._app.get("/shutdown")
+ async def shutdown() -> fastapi.Response:
+ """Shutdown Tunneld"""
+ os.kill(os.getpid(), signal.SIGINT)
+ data = {"operation": "shutdown", "data": True, "message": "Server shutting down..."}
+ return generate_http_response(data)
+
+ @self._app.get("/clear_tunnels")
+ async def clear_tunnels() -> fastapi.Response:
+ self._tunneld_core.clear()
+ data = {"operation": "clear_tunnels", "data": True, "message": "Cleared tunnels..."}
+ return generate_http_response(data)
+
+ @self._app.get("/cancel")
+ async def cancel_tunnel(udid: str) -> fastapi.Response:
+ self._tunneld_core.cancel(udid=udid)
+ data = {"operation": "cancel", "udid": udid, "data": True, "message": f"tunnel {udid} Canceled ..."}
+ return generate_http_response(data)
+
+ @self._app.get("/hello")
+ async def hello() -> fastapi.Response:
+ data = {"message": "Hello, I'm alive"}
+ return generate_http_response(data)
+
+ def generate_http_response(
+ data: dict, status_code: int = 200, media_type: str = "application/json"
+ ) -> fastapi.Response:
+ return fastapi.Response(status_code=status_code, media_type=media_type, content=json.dumps(data))
+
+ @self._app.get("/start-tunnel")
+ @self.context.sio.event
+ async def start_tunnel(
+ udid: str, ip: Optional[str] = None, connection_type: Optional[str] = None
+ ) -> fastapi.Response:
+ udid_tunnels = [
+ t.tunnel for t in self._tunneld_core.tunnel_tasks.values() if t.udid == udid and t.tunnel is not None
+ ]
+ if len(udid_tunnels) > 0:
+ self.context.udid = udid
+ data = {
+ "interface": udid_tunnels[0].interface,
+ "port": udid_tunnels[0].port,
+ "address": udid_tunnels[0].address,
+ }
+ return generate_http_response(data)
+
+ queue = asyncio.Queue()
+ created_task = False
+
+ try:
+ if not created_task and connection_type in ("usbmux", None):
+ task_identifier = f"usbmux-{udid}"
+ try:
+ async with await create_using_usbmux(udid) as lockdown:
+ service = await CoreDeviceTunnelProxy.create(lockdown)
+ task = asyncio.create_task(
+ self._tunneld_core.start_tunnel_task(
+ task_identifier, service, protocol=TunnelProtocol.TCP, queue=queue
+ ),
+ name=f"start-tunnel-task-{task_identifier}",
+ )
+ self._tunneld_core.tunnel_tasks[task_identifier] = TunnelTask(task=task, udid=udid)
+ created_task = True
+ except (ConnectionFailedError, InvalidServiceError, MuxException):
+ pass
+ if connection_type in ("usb", None):
+ for rsd in await get_rsds(udid=udid):
+ rsd_ip = rsd.service.address[0]
+ if ip is not None and rsd_ip != ip:
+ await rsd.close()
+ continue
+ task = asyncio.create_task(
+ self._tunneld_core.start_tunnel_task(
+ rsd_ip, await create_core_device_tunnel_service_using_rsd(rsd), queue=queue
+ ),
+ name=f"start-tunnel-usb-{rsd_ip}",
+ )
+ self._tunneld_core.tunnel_tasks[rsd_ip] = TunnelTask(task=task, udid=rsd.udid)
+ created_task = True
+ if not created_task and connection_type in ("wifi", None):
+ for remotepairing in await get_remote_pairing_tunnel_services(udid=udid):
+ remotepairing_ip = remotepairing.hostname
+ if ip is not None and remotepairing_ip != ip:
+ await remotepairing.close()
+ continue
+ task = asyncio.create_task(
+ self._tunneld_core.start_tunnel_task(remotepairing_ip, remotepairing, queue=queue),
+ name=f"start-tunnel-wifi-{remotepairing_ip}",
+ )
+ self._tunneld_core.tunnel_tasks[remotepairing_ip] = TunnelTask(
+ task=task, udid=remotepairing.remote_identifier
+ )
+ created_task = True
+ except Exception as e:
+ return fastapi.Response(
+ status_code=501,
+ content=json.dumps({
+ "error": {
+ "exception": e.__class__.__name__,
+ "traceback": traceback.format_exc(),
+ }
+ }),
+ )
+
+ if not created_task:
+ return fastapi.Response(status_code=501, content=json.dumps({"error": "task not created"}))
+
+ tunnel: Optional[TunnelResult] = await queue.get()
+ if tunnel is not None:
+ self.context.udid = udid
+ data = {"interface": tunnel.interface, "port": tunnel.port, "address": tunnel.address}
+ return generate_http_response(data)
+ else:
+ return fastapi.Response(
+ status_code=404, content=json.dumps({"error": "something went wrong during tunnel creation"})
+ )
+
+ @self.context.sio.event
+ async def connect(sid, environ):
+ logger.info("Client connected: %s", sid)
+ await self.context.sio.emit("connect", sid, namespace="/")
+
+ @self.context.sio.event
+ async def request_update(sid, data):
+ logger.info("Update request from %s", sid)
+
+ # await self.context.sio.emit("status_update", await get_status())
+
+ @self.context.sio.event
+ async def message(sid, data):
+ logger.info("Received message from %s: %s", data, sid)
+ await self.context.sio.emit("message", f"Received message from {sid}: {data}", namespace="/")
+
+ @self.context.sio.event
+ async def simulate_location(sid, data):
+ logger.info("Simulate location request from %s: %s", sid, data)
+ latitude = data.get("latitude") if isinstance(data, dict) else getattr(data, "latitude", None)
+ longitude = data.get("longitude") if isinstance(data, dict) else getattr(data, "longitude", None)
+ delay = data.get("delay", 0) if isinstance(data, dict) else getattr(data, "delay", 0)
+ if latitude is not None and longitude is not None:
+ logger.info("Adding location %s, %s with %ss delay to the queue", latitude, longitude, delay)
+ await self.context.queue.put((latitude, longitude, delay))
+ await self.context.sio.emit("simulation", {"status": self.context.simulation_active,
+ "data": {"latitude": self.context.latitude,
+ "cur_longitude": longitude, "next_move": delay}},
+ namespace="/")
+ else:
+ logger.warning("Invalid location data received from %s: %s", sid, data)
+ await self.context.sio.emit("error", "Invalid location data", namespace="/")
+
+ @self.context.sio.event
+ async def start_simulate_location(sid, data):
+ logger.info("Start location simulation request from %s", sid)
+ if isinstance(data, dict) and data.get("udid"):
+ self.context.udid = data["udid"]
+ if self.context.simulation_task is None or self.context.simulation_task.done():
+ self.context.simulation_active = True
+ self.context.simulation_task = asyncio.create_task(
+ start_queue(),
+ name="location-simulation-worker",
+ )
+ await self.context.sio.emit("simulation", {"status": self.context.simulation_active, "data": None},
+ namespace="/")
+
+ @self.context.sio.event
+ async def end_simulate_location(sid, data):
+ logger.info("End location simulation request from %s", sid)
+ if self.context.simulation_task is not None and not self.context.simulation_task.done():
+ await self.context.queue.put((None, None, None))
+ with suppress(asyncio.CancelledError):
+ await self.context.simulation_task
+ await empty_queue()
+ await self.context.sio.emit("simulation", {"status": self.context.simulation_active, "data": None},
+ namespace="/")
+
+ @self.context.sio.event
+ async def disconnect(sid):
+ logger.info("Client disconnected: %s", sid)
+
+ @self.context.sio.event
+ async def start_tunneld(sid, data):
+ logger.info("Start tunneld request from %s: %s", sid, data)
+ try:
+ self._tunneld_core.start()
+ logger.info("Tunneld started successfully")
+ except Exception as e:
+ logger.error("Error starting tunneld: %s", e)
+
+ def _run_app(self) -> None:
+ uvicorn.run(self._asgi_app, host=self.host, port=self.port, loop="asyncio", workers=1)
+
+
+class LocationSimulationQueue(LocationSimulation):
+ def __init__(self, dvt, context: LocationSimulationState):
+ super().__init__(dvt)
+ self.context = context
+
+ async def play_queue(self, disable_sleep: bool = False, timing_randomness_range: int = 0) -> None:
+ while True:
+ latitude, longitude, delay = await self.context.queue.get()
+ if (latitude, longitude, delay) == (None, None, None):
+ break
+ if delay > 0 and not disable_sleep:
+ if timing_randomness_range > 0:
+ delay = delay + random.uniform(-timing_randomness_range, timing_randomness_range)
+ for i in range(delay, 0, -1):
+ await self.context.sio.emit("simulation", {"status": self.context.simulation_active,
+ "data": {"latitude": self.context.latitude,
+ "longitude": self.context.longitude,
+ "next_move": i}}, namespace="/")
+ await asyncio.sleep(1)
+ await self.set(latitude, longitude)
+ self.context.latitude = latitude
+ self.context.longitude = longitude
+ await self.context.sio.emit("simulation", {"status": self.context.simulation_active,
+ "data": {"latitude": self.context.latitude,
+ "longitude": self.context.longitude,
+ "next_move": None}}, namespace="/")
+ logger.info("Set simulated location to %s, %s after %ss delay", latitude, longitude, delay)
+ self.context.queue.task_done()
diff --git a/socktest.py b/socktest.py
new file mode 100644
index 0000000..d344f39
--- /dev/null
+++ b/socktest.py
@@ -0,0 +1,75 @@
+import socketio
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse
+
+# 1. Create the FastAPI app
+app = FastAPI()
+
+# 2. Create the Socket.IO server (Async mode for FastAPI)
+sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*')
+
+# 3. Wrap FastAPI with Socket.IO
+# This allows 'socket_app' to handle socket traffic and pass
+# everything else to 'app'
+socket_app = socketio.ASGIApp(sio, app)
+
+# --- Socket.IO Events ---
+
+@sio.event
+async def connect(sid, environ):
+ print(f"Client connected: {sid}")
+ await sio.emit('response', {'data': 'Connected to Server!'})
+
+@sio.event
+async def message(sid, data):
+ print(f"Received from {sid}: {data}")
+ # Broadcast to everyone (including sender)
+ await sio.emit('response', {'data': f"Echo: {data}"})
+
+@sio.event
+async def disconnect(sid):
+ print(f"Client disconnected: {sid}")
+
+# --- FastAPI Routes ---
+
+html_client = """
+
+
+
+ FastAPI Socket.IO Test
+
+
+
+
+
+ Socket.IO Test
+ Disconnected
+
+
+
+
+
+"""
+
+@app.get("/")
+async def index():
+ return HTMLResponse(html_client)
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 0b420cb..00b54ba 100644
--- a/uv.lock
+++ b/uv.lock
@@ -94,10 +94,9 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
- { name = "flask" },
- { name = "flask-cors" },
{ name = "pydantic" },
{ name = "pymobiledevice3" },
+ { name = "python-socketio" },
{ name = "typing" },
{ name = "uvicorn" },
]
@@ -105,14 +104,22 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = "==0.135.1" },
- { name = "flask", specifier = "==3.1.3" },
- { name = "flask-cors", specifier = "==6.0.2" },
{ name = "pydantic", specifier = "==2.12.5" },
- { name = "pymobiledevice3", specifier = "==7.8.3" },
+ { name = "pymobiledevice3", specifier = "==9.0.0" },
+ { name = "python-socketio", specifier = "==5.16.1" },
{ name = "typing", specifier = "==3.10.0.0" },
{ name = "uvicorn", specifier = "==0.41.0" },
]
+[[package]]
+name = "bidict"
+version = "0.23.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
+]
+
[[package]]
name = "blessed"
version = "1.32.0"
@@ -126,15 +133,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/47/de8f185a1f537fdb5117fcde7050472b8cde3561179e9a68e1a566a6e6c6/blessed-1.32.0-py3-none-any.whl", hash = "sha256:c6fdc18838491ebc7f0460234917eff4e172074934f5f80e82672417bd74be70", size = 111172, upload-time = "2026-02-28T20:58:58.59Z" },
]
-[[package]]
-name = "blinker"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
-]
-
[[package]]
name = "bpylist2"
version = "4.1.1"
@@ -346,6 +344,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "developer-disk-image"
version = "0.2.0"
@@ -405,36 +412,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
]
-[[package]]
-name = "flask"
-version = "3.1.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "blinker" },
- { name = "click" },
- { name = "itsdangerous" },
- { name = "jinja2" },
- { name = "markupsafe" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
-]
-
-[[package]]
-name = "flask-cors"
-version = "6.0.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "flask" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
-]
-
[[package]]
name = "gpxpy"
version = "1.6.2"
@@ -564,15 +541,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
-[[package]]
-name = "itsdangerous"
-version = "2.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
-]
-
[[package]]
name = "jedi"
version = "0.19.2"
@@ -585,18 +553,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
[[package]]
name = "jinxed"
version = "1.3.0"
@@ -654,36 +610,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
-]
-
[[package]]
name = "matplotlib-inline"
version = "0.2.1"
@@ -705,15 +631,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
-[[package]]
-name = "nest-asyncio"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
-]
-
[[package]]
name = "opack2"
version = "0.0.1"
@@ -1020,7 +937,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/9ae75ede398b7adf5
[[package]]
name = "pymobiledevice3"
-version = "7.8.3"
+version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asn1" },
@@ -1031,6 +948,7 @@ dependencies = [
{ name = "construct-typing" },
{ name = "cryptography" },
{ name = "daemonize" },
+ { name = "defusedxml" },
{ name = "developer-disk-image" },
{ name = "fastapi" },
{ name = "gpxpy" },
@@ -1040,7 +958,6 @@ dependencies = [
{ name = "inquirer3" },
{ name = "ipsw-parser" },
{ name = "ipython" },
- { name = "nest-asyncio" },
{ name = "opack2" },
{ name = "packaging" },
{ name = "parameter-decorators" },
@@ -1066,9 +983,9 @@ dependencies = [
{ name = "wsproto" },
{ name = "xonsh" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/16/6290b69b2ab04b01184536d93a605305749e09e2056ecacc768ec600bb3f/pymobiledevice3-7.8.3.tar.gz", hash = "sha256:3d88af218dea373249318412a08de999978a3a8f502d5929e2cd8e7820e8c6c6", size = 658504, upload-time = "2026-03-04T07:16:50.629Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/89/aed5abbb6a4ece29bed2b9e85ccfbc28a8197658a4923e8cd2d5193796d8/pymobiledevice3-9.0.0.tar.gz", hash = "sha256:e85c169d67cf17d1dcf4ce26e3a84a801e86d13a0144ff7fb57eb532745ddcfb", size = 735101, upload-time = "2026-03-11T08:37:05.172Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/51/e83eeab57581319bbce7e7516c871cf9bb07cb7aabe93f70b18831875701/pymobiledevice3-7.8.3-py3-none-any.whl", hash = "sha256:93aa53611ebfe7a10819661abe16b7a83b02134cfbc4cbb7dc5ad5b4c0e3bd7b", size = 709133, upload-time = "2026-03-04T07:16:48.152Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ae/aaff1375b383c78729b6b25abac1458e9fd5c0c84b8da364bf204559dc16/pymobiledevice3-9.0.0-py3-none-any.whl", hash = "sha256:7366533cc8807299ef0b88c6c56a77120f7d984914ec6436191a7cc2991d3ae7", size = 789609, upload-time = "2026-03-11T08:37:01.731Z" },
]
[[package]]
@@ -1092,6 +1009,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-engineio"
+version = "4.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "simple-websocket" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
+]
+
[[package]]
name = "python-pcapng"
version = "2.1.1"
@@ -1101,6 +1030,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/a4/141a5fbb51c8e3dee10445d02785d44f8a66a150af6916b4e1776e5065c6/python_pcapng-2.1.1-py3-none-any.whl", hash = "sha256:2c83e9f9f60d61cbb6c86f80fa9e3d722f1bb606a59a64a96d6ba0179d97ffcf", size = 33503, upload-time = "2022-08-23T18:59:08.754Z" },
]
+[[package]]
+name = "python-socketio"
+version = "5.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "bidict" },
+ { name = "python-engineio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
+]
+
[[package]]
name = "pytun-pmd3"
version = "3.0.3"
@@ -1244,6 +1186,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
+[[package]]
+name = "simple-websocket"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -1430,18 +1384,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
]
-[[package]]
-name = "werkzeug"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
-]
-
[[package]]
name = "wsproto"
version = "1.3.2"