44import os
55import shutil
66import subprocess
7+ import base64
8+ import json
79from typing import Literal
10+ import asyncio
11+ import socket
12+ import xml .etree .ElementTree as ET
813
914import requests
1015from androguard .core .apk import APK
1116from fastapi import APIRouter , UploadFile , File
1217from pydantic import BaseModel
1318
19+ import sys
20+ import logging
1421from Framework .Utilities import CommonUtil , ConfigModule
1522from settings import ZEUZ_NODE_DOWNLOADS_DIR
1623
24+ sys .path .append (os .path .join (os .path .dirname (__file__ ), '..' , 'Framework' , 'Built_In_Automation' , 'Mobile' , 'CrossPlatform' , 'Appium' ))
1725ADB_PATH = "adb" # Ensure ADB is in PATH
1826UI_XML_PATH = "ui.xml"
1927SCREENSHOT_PATH = "screen.png"
28+ IOS_SCREENSHOT_PATH = "ios_screen.png"
29+ IOS_XML_PATH = "ios_ui.xml"
2030
2131router = APIRouter (prefix = "/mobile" , tags = ["mobile" ])
2232
2333
34+ def is_wda_running (port : int ) -> bool :
35+ """Check if WebDriverAgent is running on given port."""
36+ try :
37+ response = requests .get (f"http://localhost:{ port } /status" , timeout = 1 )
38+ return response .status_code == 200
39+ except :
40+ return False
41+
42+
2443class InspectorResponse (BaseModel ):
2544 """Response model for the /inspector endpoint."""
26-
2745 status : Literal ["ok" , "error" ] = "ok"
2846 ui_xml : str | None = None
2947 screenshot : str | None = None # Base64 encoded image
48+ bundle_identifier : str | None = None
3049 error : str | None = None
3150
3251
@@ -36,8 +55,15 @@ class DeviceInfo(BaseModel):
3655 serial : str
3756 status : str
3857 name : str | None = None
39- # model: str | None = None
40- # product: str | None = None
58+
59+
60+ class IOSDeviceInfo (BaseModel ):
61+ """Model for iOS device information."""
62+ udid : str
63+ name : str
64+ state : str
65+ runtime : str
66+ device_type : str
4167
4268
4369@router .get ("/devices" , response_model = list [DeviceInfo ])
@@ -68,6 +94,35 @@ def get_devices():
6894 return []
6995
7096
97+ @router .get ("/ios/devices" , response_model = list [IOSDeviceInfo ])
98+ def get_ios_devices ():
99+ """Get list of booted iOS simulators only."""
100+ try :
101+ result = subprocess .run (
102+ ["xcrun" , "simctl" , "list" , "devices" , "-j" ],
103+ capture_output = True , text = True , check = True
104+ )
105+
106+ devices_data = json .loads (result .stdout )
107+ ios_devices = []
108+
109+ for runtime , devices in devices_data .get ("devices" , {}).items ():
110+ for device in devices :
111+ # Only return booted devices
112+ if device .get ("isAvailable" , False ) and device .get ("state" ) == "Booted" :
113+ ios_devices .append (IOSDeviceInfo (
114+ udid = device ["udid" ],
115+ name = device ["name" ],
116+ state = device ["state" ],
117+ runtime = runtime ,
118+ device_type = device .get ("deviceTypeIdentifier" , "Unknown" )
119+ ))
120+
121+ return ios_devices
122+ except Exception as e :
123+ return []
124+
125+
71126@router .get ("/inspect" )
72127def inspect (device_serial : str | None = None ):
73128 """Get the Mobile DOM and screenshot."""
@@ -92,6 +147,98 @@ def inspect(device_serial: str | None = None):
92147 return InspectorResponse (status = "error" , error = str (e ))
93148
94149
150+
151+ @router .post ("/ios/start-services" )
152+ def start_ios_services ():
153+ try :
154+ ios_devices = get_ios_devices ()
155+ if not ios_devices :
156+ return {"status" : "error" , "error" : "No booted iOS simulators" }
157+
158+ device_udid = ios_devices [0 ].udid
159+
160+ # Check if WDA is already running
161+ wda_port = 8100
162+ tries = 0
163+ while tries < 20 :
164+ if not is_wda_running (wda_port ):
165+ break
166+ wda_port += 2
167+ tries += 1
168+
169+ if tries >= 20 :
170+ return {"status" : "error" , "error" : "No available WDA ports" }
171+
172+ result = subprocess .run (
173+ ["xcrun" , "simctl" , "launch" , device_udid , "com.facebook.WebDriverAgentRunner.xctrunner" ],
174+ capture_output = True , text = True
175+ )
176+
177+ if result .returncode != 0 :
178+ return {"status" : "error" , "error" : f"Failed to launch WDA." }
179+
180+ return {"status" : "ok" , "port" : wda_port }
181+
182+ except Exception as e :
183+ logging .exception ("Failed to start iOS services" )
184+ return {"status" : "error" , "error" : "Failed to start iOS services" }
185+
186+
187+ def extract_bundle_id_from_xml (xml_content : str ) -> str | None :
188+ try :
189+ root = ET .fromstring (xml_content )
190+ return root .get ('bundleId' )
191+ except Exception :
192+ return None
193+
194+
195+ @router .get ("/ios/inspect" )
196+ def inspect_ios (device_udid : str | None = None ):
197+ """Get iOS simulator screenshot and XML hierarchy."""
198+ try :
199+ if not device_udid :
200+ ios_devices = get_ios_devices ()
201+ if not ios_devices :
202+ return InspectorResponse (
203+ status = "error" ,
204+ error = "No iOS simulators available"
205+ )
206+
207+ # Find first booted device
208+ booted_devices = [d for d in ios_devices if d .state == "Booted" ]
209+ if not booted_devices :
210+ return InspectorResponse (
211+ status = "error" ,
212+ error = "No booted iOS simulators found. Please start an iOS simulator."
213+ )
214+ device_udid = booted_devices [0 ].udid
215+
216+ capture_ios_ui_dump (device_udid )
217+ capture_ios_screenshot (device_udid )
218+
219+ with open (IOS_XML_PATH , 'r' , encoding = 'utf-8' ) as xml_file :
220+ xml_content = xml_file .read ()
221+
222+ # Extract bundle identifier from XML content
223+ bundle_id = extract_bundle_id_from_xml (xml_content )
224+
225+ with open (IOS_SCREENSHOT_PATH , 'rb' ) as img_file :
226+ screenshot_bytes = img_file .read ()
227+ screenshot_base64 = base64 .b64encode (screenshot_bytes ).decode ('utf-8' )
228+
229+ return InspectorResponse (
230+ status = "ok" ,
231+ ui_xml = xml_content ,
232+ screenshot = screenshot_base64 ,
233+ bundle_identifier = bundle_id
234+ )
235+ except Exception as e :
236+ return InspectorResponse (
237+ status = "error" ,
238+ error = str (e )
239+ )
240+
241+
95242@router .get ("/dump/driver" )
96243def dump_driver ():
97244 """Dump the current driver."""
@@ -167,6 +314,101 @@ def capture_screenshot(device_serial: str | None = None):
167314 return
168315
169316
317+ def capture_ios_screenshot (device_udid : str ):
318+ try :
319+ screenshot_path = os .path .abspath (IOS_SCREENSHOT_PATH )
320+
321+ if os .path .exists (screenshot_path ):
322+ os .remove (screenshot_path )
323+
324+ result = subprocess .run (
325+ ["xcrun" , "simctl" , "io" , device_udid , "screenshot" , "--type=png" , screenshot_path ],
326+ capture_output = True , text = True , check = True
327+ )
328+
329+ if not os .path .exists (screenshot_path ):
330+ raise Exception ("Screenshot file was not created" )
331+
332+ return True
333+ except subprocess .CalledProcessError as e :
334+ raise Exception (f"Failed to capture iOS screenshot: { e .stderr } " )
335+ except Exception as e :
336+ raise Exception (f"Failed to capture iOS screenshot: { str (e )} " )
337+
338+
339+ def get_real_ios_hierarchy (device_udid : str ):
340+ try :
341+ import requests
342+
343+ wda_port = 8100
344+ tries = 0
345+
346+ while tries < 20 :
347+ try :
348+ wda_url = f"http://localhost:{ wda_port } "
349+
350+ # Quick status check
351+ status_response = requests .get (f"{ wda_url } /status" , timeout = 1 )
352+ if status_response .status_code != 200 :
353+ wda_port += 2
354+ tries += 1
355+ continue
356+
357+ # existing sessions first
358+ sessions_response = requests .get (f"{ wda_url } /sessions" , timeout = 1 )
359+ if sessions_response .status_code == 200 :
360+ sessions = sessions_response .json ()
361+ if sessions and len (sessions ) > 0 :
362+ session_id = sessions [0 ]['id' ]
363+ source_response = requests .get (f"{ wda_url } /session/{ session_id } /source" , timeout = 3 )
364+ if source_response .status_code == 200 :
365+ return source_response .text
366+
367+ # direct source
368+ source_response = requests .get (f"{ wda_url } /source" , timeout = 2 )
369+ if source_response .status_code == 200 :
370+ return source_response .text
371+
372+ except :
373+ wda_port += 2
374+ tries += 1
375+ continue
376+
377+ except :
378+ pass
379+
380+ return None
381+
382+
383+ def capture_ios_ui_dump (device_udid : str ):
384+ real_hierarchy = get_real_ios_hierarchy (device_udid )
385+ if real_hierarchy :
386+ try :
387+ import json
388+ json_data = json .loads (real_hierarchy )
389+ xml_content = json_data .get ("value" , real_hierarchy )
390+ except :
391+ xml_content = real_hierarchy
392+
393+ with open (IOS_XML_PATH , 'w' , encoding = 'utf-8' ) as xml_file :
394+ xml_file .write (xml_content )
395+ return
396+
397+ # Fallback to Appium driver
398+ try :
399+ from Framework .Built_In_Automation .Mobile .CrossPlatform .Appium .BuiltInFunctions import appium_driver
400+ if appium_driver is not None :
401+ page_src = appium_driver .page_source
402+ with open (IOS_XML_PATH , 'w' , encoding = 'utf-8' ) as xml_file :
403+ xml_file .write (page_src )
404+ return
405+ except :
406+ pass
407+
408+ # No real source available
409+ raise Exception ("iOS service error. Make sure simulator is running." )
410+
411+
170412async def upload_android_ui_dump ():
171413 prev_xml_hash = ""
172414 while True :
@@ -214,6 +456,49 @@ async def upload_android_ui_dump():
214456 await asyncio .sleep (5 )
215457
216458
459+ async def upload_ios_ui_dump ():
460+ prev_xml_hash = ""
461+ while True :
462+ try :
463+ ios_devices = get_ios_devices ()
464+ if not ios_devices :
465+ await asyncio .sleep (5 )
466+ continue
467+
468+ device_udid = ios_devices [0 ].udid
469+ capture_ios_ui_dump (device_udid )
470+
471+ try :
472+ with open (IOS_XML_PATH , 'r' , encoding = 'utf-8' ) as xml_file :
473+ xml_content = xml_file .read ()
474+ xml_content = xml_content .replace ("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>" , "" , 1 )
475+ new_xml_hash = hashlib .sha256 (xml_content .encode ('utf-8' )).hexdigest ()
476+ # Don't upload if the content hasn't changed
477+ if prev_xml_hash == new_xml_hash :
478+ await asyncio .sleep (5 )
479+ continue
480+ prev_xml_hash = new_xml_hash
481+
482+ except FileNotFoundError :
483+ await asyncio .sleep (5 )
484+ continue
485+
486+ url = ConfigModule .get_config_value ("Authentication" , "server_address" ).strip () + "/node_ai_contents/"
487+ apiKey = ConfigModule .get_config_value ("Authentication" , "api-key" ).strip ()
488+ res = requests .post (
489+ url ,
490+ headers = {"X-Api-Key" : apiKey },
491+ json = {
492+ "dom_mob" : {"dom" : xml_content },
493+ "node_id" : CommonUtil .MachineInfo ().getLocalUser ().lower ()
494+ })
495+ if res .ok :
496+ CommonUtil .ExecLog ("" , "UI dump uploaded successfully" , iLogLevel = 1 )
497+ except Exception as e :
498+ CommonUtil .ExecLog ("" , f"Error uploading iOS UI dump: { str (e )} " , iLogLevel = 3 )
499+ await asyncio .sleep (5 )
500+
501+
217502@router .post ("/apk-upload" )
218503def handle_apk_upload (file : UploadFile = File (...)):
219504 dir_path = f"{ ZEUZ_NODE_DOWNLOADS_DIR } /apk"
0 commit comments