66import subprocess
77import base64
88import json
9- from typing import Literal
9+ from typing import Literal , Optional
1010import asyncio
1111import socket
1212import xml .etree .ElementTree as ET
13+ import zipfile
14+ import plistlib
1315
1416import requests
1517from androguard .core .apk import APK
@@ -404,9 +406,6 @@ def capture_ios_ui_dump(device_udid: str):
404406 return
405407 except :
406408 pass
407-
408- # No real source available
409- raise Exception ("iOS service error. Make sure simulator is running." )
410409
411410
412411async def upload_android_ui_dump ():
@@ -533,3 +532,187 @@ def handle_apk_install(filename: str, serial: str):
533532 except Exception as e :
534533 return {"message" : f"Error installing APK: { str (e )} " , "filename" : filename , "package_name" : package_name }
535534
535+
536+ @router .get ("/package-installed" )
537+ def check_package_installed (package_name : str , serial : str | None = None ):
538+ """Check if an android package is installed on the device."""
539+ try :
540+ device_flag = f"-s { serial } " if serial else ""
541+ result = run_adb_command (
542+ f"{ ADB_PATH } { device_flag } shell pm list packages { package_name } " .strip ()
543+ )
544+ if result .startswith ("Error:" ):
545+ return {"installed" : False , "package_name" : package_name , "error" : result }
546+
547+ # pm list packages returns lines like "package:com.example.app"
548+ # Check if the exact package is in the output
549+ installed_packages = [line .replace ("package:" , "" ) for line in result .split ("\n " ) if line .startswith ("package:" )]
550+ is_installed = package_name in installed_packages
551+
552+ return {"installed" : is_installed , "package_name" : package_name }
553+ except Exception as e :
554+ return {"installed" : False , "package_name" : package_name , "error" : str (e )}
555+
556+
557+ @router .post ("/ios/app-upload" )
558+ def handle_ios_app_upload (file : UploadFile = File (...)):
559+ dir_path = f"{ ZEUZ_NODE_DOWNLOADS_DIR } /ios-app"
560+ if not os .path .exists (dir_path ):
561+ os .makedirs (dir_path )
562+
563+ filename = file .filename or "uploaded.app"
564+ filepath = os .path .join (dir_path , filename )
565+ with open (filepath , "wb" ) as buffer :
566+ shutil .copyfileobj (file .file , buffer )
567+
568+ return {"message" : "App uploaded successfully" , "filename" : filename }
569+
570+
571+ def normalized_ios_app_path (file_path : str ) -> Optional [str ]:
572+ """
573+ ensure that we ended up with a .app directory even if user provided .ipa
574+ """
575+ if not os .path .exists (file_path ):
576+ return None
577+
578+ # if already .app
579+ if file_path .endswith (".app" ) and os .path .isdir (file_path ):
580+ return file_path
581+
582+ # .ipa, so we have to extract to get .app
583+ if file_path .endswith (".ipa" ):
584+ extract_dir = file_path .replace (".ipa" , "_extracted" )
585+ zip_path = file_path .replace (".ipa" , ".zip" )
586+
587+ # copy instead of rename to avoid breaking original file
588+ shutil .copy (file_path , zip_path )
589+
590+ with zipfile .ZipFile (zip_path , "r" ) as zip_ref :
591+ zip_ref .extractall (extract_dir )
592+
593+ payload_dir = os .path .join (extract_dir , "Payload" )
594+ if not os .path .isdir (payload_dir ):
595+ return None
596+
597+ for item in os .listdir (payload_dir ):
598+ if item .endswith (".app" ):
599+ return os .path .join (payload_dir , item )
600+
601+ return None
602+
603+ # extract zip because 'test.app' is not a file, so browser zip that before send to me
604+ if file_path .endswith (".app.zip" ) or file_path .endswith (".zip" ):
605+ extract_dir = file_path + "_extracted"
606+ os .makedirs (extract_dir , exist_ok = True )
607+
608+ with zipfile .ZipFile (file_path , "r" ) as zip_ref :
609+ zip_ref .extractall (extract_dir )
610+
611+ # search recursively for .app folder so it will pick very first .app folder
612+ for root , dirs , files in os .walk (extract_dir ):
613+ for d in dirs :
614+ if d .endswith (".app" ) and os .path .isdir (os .path .join (root , d )):
615+ return os .path .join (root , d )
616+
617+ return None
618+
619+ return None
620+
621+
622+ def extract_bundle_id_from_app (app_path : str ) -> Optional [str ]:
623+ """
624+ Reads CFBundleIdentifier from Info.plist inside .app bundle
625+ """
626+ info_plist_path = os .path .join (app_path , "Info.plist" )
627+
628+ if not os .path .exists (info_plist_path ):
629+ return None
630+
631+ try :
632+ with open (info_plist_path , "rb" ) as f :
633+ plist_data = plistlib .load (f )
634+ return plist_data .get ("CFBundleIdentifier" )
635+ except Exception :
636+ return None
637+
638+
639+ @router .post ("/ios/app-install" )
640+ def handle_ios_app_install (filename : str , sim_udid : str ):
641+ """
642+ handling the ios app installation in the simolator
643+ """
644+ dirpath = f"{ ZEUZ_NODE_DOWNLOADS_DIR } /ios-app"
645+ filepath = os .path .join (dirpath , filename )
646+ if not os .path .exists (filepath ):
647+ return {"message" : "App not found" , "filename" : filename }
648+
649+ app_path = normalized_ios_app_path (filepath )
650+ if not app_path :
651+ return {"message" : "Failed to normalize .app" , "filename" : filename }
652+
653+ bundle_id = extract_bundle_id_from_app (app_path )
654+
655+ try :
656+ # uninstall if already exists
657+ try :
658+ subprocess .run (
659+ ["xcrun" , "simctl" , "uninstall" , sim_udid , bundle_id ],
660+ capture_output = True , text = True , timeout = 30
661+ )
662+ except :
663+ pass # Ignore uninstall errors
664+
665+ # Install the app
666+ result = subprocess .run (
667+ ["xcrun" , "simctl" , "install" , sim_udid , app_path ],
668+ capture_output = True , text = True , check = True , timeout = 120
669+ )
670+
671+ # Verify installation
672+ verify_result = subprocess .run (
673+ ["xcrun" , "simctl" , "get_app_container" , sim_udid , bundle_id ],
674+ capture_output = True , text = True , timeout = 30
675+ )
676+
677+ if verify_result .returncode != 0 :
678+ return {
679+ "message" : f"App installed but verification failed: { verify_result .stderr } " ,
680+ "filename" : filename ,
681+ "bundle_id" : bundle_id ,
682+ }
683+
684+ return {
685+ "message" : "App installed successfully" ,
686+ "filename" : filename ,
687+ "bundle_id" : bundle_id ,
688+ }
689+ except subprocess .CalledProcessError as e :
690+ return {
691+ "message" : f"Error installing app: { e .stderr or str (e )} " ,
692+ "filename" : filename ,
693+ "bundle_id" : bundle_id ,
694+ }
695+
696+
697+ @router .get ("/ios/bundle-installed" )
698+ def is_ios_app_installed (sim_udid : str , bundle_id : str ):
699+ try :
700+ result = subprocess .run (
701+ ["xcrun" , "simctl" , "get_app_container" , sim_udid , bundle_id ],
702+ capture_output = True , text = True , timeout = 10
703+ )
704+
705+ if result .returncode == 0 and result .stdout .strip ():
706+ list_result = subprocess .run (
707+ ["xcrun" , "simctl" , "listapps" , sim_udid ],
708+ capture_output = True , text = True , timeout = 10
709+ )
710+
711+ if list_result .returncode == 0 :
712+ return {"installed" : bundle_id in list_result .stdout }
713+
714+ return {"installed" : True }
715+
716+ return {"installed" : False }
717+ except Exception as e :
718+ return {"installed" : False , "error" : str (e )}
0 commit comments