|  | 
|  | 1 | +import sys | 
|  | 2 | +import os | 
|  | 3 | +from textwrap import dedent | 
|  | 4 | +import requests | 
|  | 5 | +import json | 
|  | 6 | +from configobj import ConfigObj | 
|  | 7 | +from pathlib import Path | 
|  | 8 | +import traceback | 
|  | 9 | +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) | 
|  | 10 | + | 
|  | 11 | +print(f"Python Version: {sys.version}") | 
|  | 12 | +print(f"Python Path: {sys.executable}") | 
|  | 13 | +print(f"Current file path: {os.path.abspath(__file__)}") | 
|  | 14 | + | 
|  | 15 | +from rich import print as rich_print | 
|  | 16 | +from rich.text import Text | 
|  | 17 | +from rich.tree import Tree | 
|  | 18 | +from colorama import Fore, init as colorama_init | 
|  | 19 | +colorama_init(autoreset=True) | 
|  | 20 | + | 
|  | 21 | +import ctypes | 
|  | 22 | +import objc | 
|  | 23 | +from Foundation import NSObject | 
|  | 24 | +from Quartz import ( | 
|  | 25 | +    CoreGraphics,  | 
|  | 26 | +    CGEventSourceFlagsState,  | 
|  | 27 | +    kCGEventSourceStateHIDSystemState,  | 
|  | 28 | +    kCGEventFlagMaskControl,  | 
|  | 29 | +    CGWindowListCopyWindowInfo,  | 
|  | 30 | +    kCGWindowListOptionOnScreenOnly, | 
|  | 31 | +    kCGNullWindowID | 
|  | 32 | +) | 
|  | 33 | +from AppKit import NSEvent, NSControlKeyMask | 
|  | 34 | +import time | 
|  | 35 | + | 
|  | 36 | +import xml.etree.ElementTree as ET | 
|  | 37 | + | 
|  | 38 | +# AXUIElement types | 
|  | 39 | +AXUIElementRef = objc.objc_object | 
|  | 40 | + | 
|  | 41 | +# Load the AX API | 
|  | 42 | +ApplicationServices = objc.loadBundle("ApplicationServices", | 
|  | 43 | +    globals(), | 
|  | 44 | +    bundle_path="/System/Library/Frameworks/ApplicationServices.framework" | 
|  | 45 | +) | 
|  | 46 | +# AX, _ = objc.loadBundleFunctions(ApplicationServices, globals(), [ | 
|  | 47 | +#     ("AXUIElementCreateApplication", b"^{__AXUIElement=}(i)") | 
|  | 48 | +# ]) | 
|  | 49 | + | 
|  | 50 | +AX = objc.loadBundleFunctions(ApplicationServices, globals(), [ | 
|  | 51 | +    ("AXUIElementCreateSystemWide", b"^{__AXUIElement=}"), | 
|  | 52 | +    ("kAXFocusedUIElementAttribute", b"^{__CFString=}"), | 
|  | 53 | +    ("AXUIElementCopyAttributeValue", b"i^{__AXUIElement=}^{__CFString=}^@"), | 
|  | 54 | +    ("AXUIElementCopyAttributeNames", b"i^{__AXUIElement=}^{__CFArray=}"), | 
|  | 55 | +    ("AXUIElementCopyElementAtPosition", b"i^{__AXUIElement=}dd^@"), | 
|  | 56 | +    ("AXUIElementCreateApplication", b"^{__AXUIElement=}" + b"i") | 
|  | 57 | +]) | 
|  | 58 | + | 
|  | 59 | +settings_conf_path = str(Path(__file__).parent.parent.parent / "Framework" / "settings.conf") | 
|  | 60 | +print(f"Settings config path: {settings_conf_path}") | 
|  | 61 | + | 
|  | 62 | +def get_mouse_position(): | 
|  | 63 | +    event = CoreGraphics.CGEventCreate(None) | 
|  | 64 | +    loc = CoreGraphics.CGEventGetLocation(event) | 
|  | 65 | +    x, y = round(loc.x), round(loc.y) | 
|  | 66 | +    return x, y | 
|  | 67 | + | 
|  | 68 | + | 
|  | 69 | +class App: | 
|  | 70 | +    def __init__(self, name: str, bundle_id: str, pid: int, window_title: str): | 
|  | 71 | +        self.name = name | 
|  | 72 | +        self.bundle_id = bundle_id | 
|  | 73 | +        self.pid = pid | 
|  | 74 | +        self.window_title = window_title | 
|  | 75 | +     | 
|  | 76 | +    def __str__(self): | 
|  | 77 | +        return Fore.GREEN + dedent(f""" | 
|  | 78 | +        App( | 
|  | 79 | +            name={self.name}, | 
|  | 80 | +            bundle_id={self.bundle_id}, | 
|  | 81 | +            pid={self.pid}, | 
|  | 82 | +            window_title={self.window_title}, | 
|  | 83 | +        )""") | 
|  | 84 | + | 
|  | 85 | +class Inspector: | 
|  | 86 | +    def __init__(self): | 
|  | 87 | +        self.x: int = -1 | 
|  | 88 | +        self.y: int = -1 | 
|  | 89 | +        self.app: App = App(name="", bundle_id="", pid=-1, window_title="") | 
|  | 90 | +        self.xml_str: str = "" | 
|  | 91 | +        self.xml_tree: ET.ElementTree = None | 
|  | 92 | + | 
|  | 93 | +        self.server_address: str = "http://127.0.0.1" | 
|  | 94 | +        self.server_path: str = "/api/v1/mac/dump/driver" | 
|  | 95 | +        self.server_port: int = 18100 | 
|  | 96 | +        self.page_src: str = "" | 
|  | 97 | +    def wait_for_control_press(self): | 
|  | 98 | +        print("Hover over the element and press ⌃ Control key...") | 
|  | 99 | +        while True: | 
|  | 100 | +            flags = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState) | 
|  | 101 | +            if flags & kCGEventFlagMaskControl: | 
|  | 102 | +                point = NSEvent.mouseLocation() | 
|  | 103 | +                height = NSScreen.mainScreen().frame().size.height | 
|  | 104 | +                x = round(point.x) | 
|  | 105 | +                y = round(height - point.y) | 
|  | 106 | +                rich_print(f"Captured at x={x}, y={y}") | 
|  | 107 | +                self.x, self.y = x, y | 
|  | 108 | +                return | 
|  | 109 | +            time.sleep(0.1) | 
|  | 110 | + | 
|  | 111 | +    def get_frontmost_app(self): | 
|  | 112 | +        window_list = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID) | 
|  | 113 | +        for window in window_list: | 
|  | 114 | +            if window.get("kCGWindowLayer") == 0 and window.get("kCGWindowOwnerName"): | 
|  | 115 | +                app_name = window["kCGWindowOwnerName"] | 
|  | 116 | +                pid = window["kCGWindowOwnerPID"] | 
|  | 117 | +                app = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid) | 
|  | 118 | +                bundle_id = app.bundleIdentifier() | 
|  | 119 | +                window_title = window.get("kCGWindowName", "") | 
|  | 120 | +                self.app = App(name=app_name, bundle_id=bundle_id, pid=pid, window_title=window_title) | 
|  | 121 | +                print(self.app) | 
|  | 122 | +                break | 
|  | 123 | +     | 
|  | 124 | +    def get_server_port(self): | 
|  | 125 | +        config = ConfigObj(settings_conf_path) | 
|  | 126 | +        self.server_port =  config["server"]["port"] | 
|  | 127 | +     | 
|  | 128 | +    def get_dump(self): | 
|  | 129 | +        url = f"{self.server_address}:{self.server_port}{self.server_path}" | 
|  | 130 | +        try: | 
|  | 131 | +            response = requests.get(url).json() | 
|  | 132 | +        except requests.exceptions.ConnectionError: | 
|  | 133 | +            print(Fore.RED + "Failed to connect to the server. Please launch the Zeuz Node first and launch an app") | 
|  | 134 | +            return | 
|  | 135 | +        if response["status"] == "ok": | 
|  | 136 | +            self.page_src = response["ui_xml"] | 
|  | 137 | +            print(Fore.GREEN + f"Successfully got dump from appium driver") | 
|  | 138 | +        elif response["status"] == "not_found": | 
|  | 139 | +            print(Fore.GREEN + f"You have not launched any app yet. Launch app with the following action:") | 
|  | 140 | +            action = [ | 
|  | 141 | +                { | 
|  | 142 | +                    "action_name":f"Launch {self.app.name}", | 
|  | 143 | +                    "action_disabled":"true", | 
|  | 144 | +                    "step_actions":[ | 
|  | 145 | +                        ["macos app bundle id","element parameter",self.app.bundle_id], | 
|  | 146 | +                        ["launch","appium action","launch"] | 
|  | 147 | +                    ] | 
|  | 148 | +                } | 
|  | 149 | +            ] | 
|  | 150 | +            print(Fore.CYAN + json.dumps(action, indent=4)) | 
|  | 151 | +            self.page_src = "" | 
|  | 152 | +        else: | 
|  | 153 | +            print(Fore.RED + f"Error: {response['error']}") | 
|  | 154 | +            self.page_src = "" | 
|  | 155 | + | 
|  | 156 | +    def render_tree(self): | 
|  | 157 | +        print('rendertree') | 
|  | 158 | +        if not self.page_src: | 
|  | 159 | +            return | 
|  | 160 | + | 
|  | 161 | +        root = ET.fromstring(self.page_src) | 
|  | 162 | +        tree = Tree(f"[bold green]{self.app.name} ({self.app.bundle_id})") | 
|  | 163 | +        self.xml_tree = tree | 
|  | 164 | + | 
|  | 165 | +        def check_bounding_box(element): | 
|  | 166 | +            if element.attrib.get('x') and element.attrib.get('y') and element.attrib.get('width') and element.attrib.get('height'): | 
|  | 167 | +                x = int(element.attrib.get('x')) | 
|  | 168 | +                y = int(element.attrib.get('y')) | 
|  | 169 | +                width = int(element.attrib.get('width')) | 
|  | 170 | +                height = int(element.attrib.get('height')) | 
|  | 171 | +                if (self.x >= x and  | 
|  | 172 | +                    self.x <= x + width and  | 
|  | 173 | +                    self.y >= y and  | 
|  | 174 | +                    self.y <= y + height | 
|  | 175 | +                ): | 
|  | 176 | +                    return True | 
|  | 177 | +            return False | 
|  | 178 | + | 
|  | 179 | +        def get_attribute_string(element): | 
|  | 180 | +            ignore = ['x', 'y', 'width', 'height'] | 
|  | 181 | +            return " ".join([f'{k}="{v}"' for k, v in element.attrib.items() if k not in ignore]) | 
|  | 182 | + | 
|  | 183 | +        def set_single_zeuz_apiplugin(root): | 
|  | 184 | +            elements = root.findall(".//*[@zeuz='aiplugin']") | 
|  | 185 | +            if len(elements) > 1: | 
|  | 186 | +                element_areas = [] | 
|  | 187 | +                for element in elements: | 
|  | 188 | +                    width = int(element.attrib.get('width', 0)) | 
|  | 189 | +                    height = int(element.attrib.get('height', 0)) | 
|  | 190 | +                    area = width * height | 
|  | 191 | +                    element_areas.append((element, area)) | 
|  | 192 | +                 | 
|  | 193 | +                element_areas.sort(key=lambda x: x[1]) | 
|  | 194 | +                for element, _ in element_areas[1:]: | 
|  | 195 | +                    del element.attrib['zeuz'] | 
|  | 196 | +         | 
|  | 197 | +        def remove_coordinates(node): | 
|  | 198 | +            remove = ['x', 'y', 'width', 'height'] | 
|  | 199 | +            for child in node: | 
|  | 200 | +                for attrib in list(child.attrib): | 
|  | 201 | +                    if attrib in remove: | 
|  | 202 | +                        del child.attrib[attrib] | 
|  | 203 | +                remove_coordinates(child) | 
|  | 204 | + | 
|  | 205 | +        def build_tree(element, parent_tree): | 
|  | 206 | +            element_tag = element.tag | 
|  | 207 | +            ignore = ['x', 'y', 'width', 'height'] | 
|  | 208 | +            element_attribs = get_attribute_string(element) | 
|  | 209 | +            element_coords = f"x={element.attrib.get('x', '')}, y={element.attrib.get('y', '')}, w={element.attrib.get('width', '')}, h={element.attrib.get('height', '')}" | 
|  | 210 | +            recorded_coords = f"self.x={self.x}, self.y={self.y}" | 
|  | 211 | + | 
|  | 212 | +            if check_bounding_box(element): | 
|  | 213 | +                if not any(check_bounding_box(child) for child in element): | 
|  | 214 | +                    area = int(element.attrib.get('width', '1')) * int(element.attrib.get('height', '1')) | 
|  | 215 | +                    label = f"[bold blue]{element_tag}: [green]{element_attribs} [dim]({element_coords} Area: {area} {recorded_coords})" | 
|  | 216 | +                    element.set('zeuz', 'aiplugin') | 
|  | 217 | +                else: | 
|  | 218 | +                    label = f"[bold blue]{element_tag}: [yellow]{element_attribs}" | 
|  | 219 | + | 
|  | 220 | +            else: | 
|  | 221 | +                label = f"[bold]{element_tag}: {element_attribs}" | 
|  | 222 | +            node = parent_tree.add(label) | 
|  | 223 | + | 
|  | 224 | +            for child in element: | 
|  | 225 | +                if check_bounding_box(child): | 
|  | 226 | +                    build_tree(child, node) | 
|  | 227 | +                else: | 
|  | 228 | +                    node.add(f"[bold]{child.tag}: {get_attribute_string(child)}") | 
|  | 229 | + | 
|  | 230 | +        build_tree(root, tree) | 
|  | 231 | +        set_single_zeuz_apiplugin(root) | 
|  | 232 | +        rich_print(tree) | 
|  | 233 | +        remove_coordinates(root) | 
|  | 234 | +        self.xml_str = ET.tostring(root).decode().encode('ascii', 'ignore').decode() | 
|  | 235 | + | 
|  | 236 | + | 
|  | 237 | +        ''' Comment out the below code to check if tree contains single zeuz apiplugin ''' | 
|  | 238 | +        # tree2 = Tree(f"[bold green]{self.app.name} ({self.app.bundle_id})") | 
|  | 239 | +        # build_tree(root, tree2) | 
|  | 240 | +        # rich_print(tree2) | 
|  | 241 | +    def send_to_server(self): | 
|  | 242 | +        config = ConfigObj(settings_conf_path) | 
|  | 243 | +        api_key = config["Authentication"]["api-key"].strip() | 
|  | 244 | +        server = config["Authentication"]["server_address"].strip() | 
|  | 245 | + | 
|  | 246 | +        if not api_key or not server: | 
|  | 247 | +            print(Fore.RED + "API key or server address is not set. Please launch the Zeuz Node first and login") | 
|  | 248 | +            return | 
|  | 249 | +        url = f"{self.server_address}:{self.server_port}{self.server_path}" | 
|  | 250 | +        try: | 
|  | 251 | +            url = server + "/" if server[-1] != "/" else server | 
|  | 252 | +            url += "ai_record_single_action/" | 
|  | 253 | +            print(url) | 
|  | 254 | +            content = json.dumps({ | 
|  | 255 | +                'page_src': self.xml_str, | 
|  | 256 | +                "action_type": "android", | 
|  | 257 | +            }) | 
|  | 258 | +            headers = { | 
|  | 259 | +                "X-Api-Key": api_key, | 
|  | 260 | +            } | 
|  | 261 | + | 
|  | 262 | +            r = requests.request("POST", url, headers=headers, data=content, verify=False) | 
|  | 263 | +            response = r.json() | 
|  | 264 | +            if response["info"] == "success": | 
|  | 265 | +                r.ok and print("Element sent. You can " + Fore.GREEN + "'Add by AI' " + Fore.RESET + "from server") | 
|  | 266 | +            else: | 
|  | 267 | +                print(Fore.RED + response["info"]) | 
|  | 268 | +        except: | 
|  | 269 | +            traceback.print_exc() | 
|  | 270 | +            print(Fore.RED + "Failed to send content to AI Engine") | 
|  | 271 | +            return | 
|  | 272 | + | 
|  | 273 | +    def run(self): | 
|  | 274 | +        while True: | 
|  | 275 | +            input("Press any key to start capturing...") | 
|  | 276 | +            self.wait_for_control_press() | 
|  | 277 | +            self.get_frontmost_app() | 
|  | 278 | +            self.get_server_port() | 
|  | 279 | +            if self.server_port == 0: | 
|  | 280 | +                print(Fore.RED + "Server port is not set. Please launch the Zeuz Node first and launch an app") | 
|  | 281 | +                continue | 
|  | 282 | +            self.get_dump() | 
|  | 283 | +            if not self.page_src: | 
|  | 284 | +                continue | 
|  | 285 | +            self.render_tree() | 
|  | 286 | +            self.send_to_server() | 
|  | 287 | + | 
|  | 288 | +            time.sleep(0.2) | 
|  | 289 | +                 | 
|  | 290 | + | 
|  | 291 | +def main(): | 
|  | 292 | +    inspector = Inspector() | 
|  | 293 | +    inspector.run() | 
|  | 294 | + | 
|  | 295 | +if __name__ == "__main__": | 
|  | 296 | +    main() | 
0 commit comments