diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..b1ebd9a Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ diff --git a/__pycache__/server.cpython-314.pyc b/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000..396ca74 Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ diff --git a/server.py b/server.py index 7fde647..75ba07c 100644 --- a/server.py +++ b/server.py @@ -1,9 +1,11 @@ import asyncio import dataclasses +from http.cookies import BaseCookie import json import logging import os import signal +from sys import float_repr_style import traceback import warnings import fastapi @@ -44,13 +46,16 @@ from pymobiledevice3.exceptions import ( PairingError, QuicProtocolNotSupportedError, StreamClosedError, - TunneldConnectionError + 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.remote_service_discovery import ( + RSD_PORT, + RemoteServiceDiscoveryService, +) from pymobiledevice3.remote.tunnel_service import ( CoreDeviceTunnelProxy, RemotePairingProtocol, @@ -60,7 +65,9 @@ from pymobiledevice3.remote.tunnel_service import ( ) 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.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 ( @@ -109,15 +116,44 @@ TUNNEL_ACQUIRE_TIMEOUT_SECONDS = 15 DVT_CONNECT_TIMEOUT_SECONDS = 20 +class SimulationStatusData: + latitude: float + longitude: float + start: float + end: Optional[float] + next_move: Optional[float] + + +class SimulationStatus: + status: bool + data: Optional[SimulationStatusData] + + +class SimulationRequestData: + latitude: float + longitude: float + delay: int + start: str + end: Optional[str] + + +class SimulationRequest: + status: bool + data: Optional[SimulationRequestData] + + class LocationSimulationState: def __init__(self): self.latitude: Optional[float] = None self.longitude: Optional[float] = None + self.next_move: 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="*") + self.sio: socketio.AsyncServer = socketio.AsyncServer( + async_mode="asgi", cors_allowed_origins="*" + ) class TunneldRunnerSio: @@ -125,15 +161,15 @@ class TunneldRunnerSio: @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, + 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, @@ -147,15 +183,15 @@ class TunneldRunnerSio: )._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, + 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): @@ -170,8 +206,15 @@ class TunneldRunnerSio: 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._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, @@ -182,26 +225,39 @@ class TunneldRunnerSio: ) async def get_tun( - udid: Optional[str] = None, max_retries: int = 10, retry_delay: float = 0.5 + 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) + 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) + 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.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) + 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") @@ -221,7 +277,10 @@ class TunneldRunnerSio: 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: + async with ( + DvtProvider(tun) as dvt, + LocationSimulationQueue(dvt, self.context) as locate_simulation, + ): await locate_simulation.clear() self.context.simulation_active = False @@ -231,21 +290,36 @@ class TunneldRunnerSio: 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} + { + 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) + 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") + 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."}, + { + "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) + logger.error( + "Simulation worker: multiple active tunnels; explicit udid required: %s", + active_udids, + ) await self.context.sio.emit( "error", { @@ -257,17 +331,27 @@ class TunneldRunnerSio: ) return - logger.info("Simulation worker: acquiring tunnel (udid=%s)", self.context.udid) + 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") + 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") + 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: + async with LocationSimulationQueue( + dvt, self.context + ) as locate_simulation: await locate_simulation.play_queue() finally: logger.info("Simulation worker: closing DVT provider") @@ -306,10 +390,16 @@ class TunneldRunnerSio: 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): + 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] = '' + mydict[key] = "" return mydict @self._app.get("/") @@ -322,11 +412,13 @@ class TunneldRunnerSio: 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, - }) + 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") @@ -339,10 +431,14 @@ class TunneldRunnerSio: if active_tunnel.udid not in tunnels: tunnels[active_tunnel.udid] = {} try: - lockdown = await create_using_usbmux(serial=active_tunnel.udid, autopair=False) + 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}") + logger.error( + f"Failed to create lockdown session for device {active_tunnel.udid}: {e}" + ) continue return tunnels @@ -350,19 +446,32 @@ class TunneldRunnerSio: async def shutdown() -> fastapi.Response: """Shutdown Tunneld""" os.kill(os.getpid(), signal.SIGINT) - data = {"operation": "shutdown", "data": True, "message": "Server shutting down..."} + 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..."} + 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 ..."} + data = { + "operation": "cancel", + "udid": udid, + "data": True, + "message": f"tunnel {udid} Canceled ...", + } return generate_http_response(data) @self._app.get("/hello") @@ -371,17 +480,21 @@ class TunneldRunnerSio: return generate_http_response(data) def generate_http_response( - data: dict, status_code: int = 200, media_type: str = "application/json" + 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)) + 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 + 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 + 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 @@ -403,13 +516,18 @@ class TunneldRunnerSio: service = await CoreDeviceTunnelProxy.create(lockdown) task = asyncio.create_task( self._tunneld_core.start_tunnel_task( - task_identifier, service, protocol=TunnelProtocol.TCP, queue=queue + 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) + self._tunneld_core.tunnel_tasks[task_identifier] = TunnelTask( + task=task, udid=udid + ) created_task = True - except (ConnectionFailedError, InvalidServiceError, MuxException): + except ConnectionFailedError, InvalidServiceError, MuxException: pass if connection_type in ("usb", None): for rsd in await get_rsds(udid=udid): @@ -419,20 +537,28 @@ class TunneldRunnerSio: continue task = asyncio.create_task( self._tunneld_core.start_tunnel_task( - rsd_ip, await create_core_device_tunnel_service_using_rsd(rsd), queue=queue + 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) + 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): + 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), + 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( @@ -442,25 +568,36 @@ class TunneldRunnerSio: except Exception as e: return fastapi.Response( status_code=501, - content=json.dumps({ - "error": { - "exception": e.__class__.__name__, - "traceback": traceback.format_exc(), + 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"})) + 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} + 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"}) + status_code=404, + content=json.dumps( + {"error": "something went wrong during tunnel creation"} + ), ) @self.context.sio.event @@ -477,49 +614,90 @@ class TunneldRunnerSio: @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="/") + 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) + 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) + 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="/") + 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="/") + 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(): + 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="/") + 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(): + 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="/") + await self.context.sio.emit( + "simulation", + {"status": self.context.simulation_active, "data": None}, + namespace="/", + ) @self.context.sio.event async def disconnect(sid): @@ -535,7 +713,9 @@ class TunneldRunnerSio: 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) + uvicorn.run( + self._asgi_app, host=self.host, port=self.port, loop="asyncio", workers=1 + ) class LocationSimulationQueue(LocationSimulation): @@ -543,26 +723,52 @@ class LocationSimulationQueue(LocationSimulation): super().__init__(dvt) self.context = context - async def play_queue(self, disable_sleep: bool = False, timing_randomness_range: int = 0) -> None: + 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) + 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="/") + self.context.next_move = i + 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) + 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()