1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import abc 8import json 9import logging 10import os 11import subprocess 12from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple 13 14import psutil 15 16from crossbench import helper, plt 17from crossbench.browsers.browser import Browser 18from crossbench.env import HostEnvironment, ValidationError 19 20if TYPE_CHECKING: 21 import datetime as dt 22 23 from crossbench.path import AnyPath 24 from crossbench.runner.groups.session import BrowserSessionRunGroup 25 26 27class AppleScript: 28 29 @classmethod 30 def with_args(cls, app_path: AnyPath, apple_script: str, 31 **kwargs) -> Tuple[str, List[str]]: 32 variables = [] 33 replacements = {} 34 args: List[str] = [] 35 for variable, value in kwargs.items(): 36 args.append(value) 37 unique_variable = f"cb_input_{variable}" 38 replacements[variable] = unique_variable 39 variables.append(f"set {unique_variable} to (item {len(args)} of argv)") 40 variables_str = "\n".join(variables) 41 formatted_script = apple_script.strip() % replacements 42 wrapper = f""" 43 {variables_str} 44 tell application "{app_path}" 45 {formatted_script} 46 end tell 47 """ 48 return wrapper.strip(), args 49 50 @classmethod 51 def js_script_with_args(cls, script: str, args: Sequence[object]) -> str: 52 """Create a script that returns [JSON.stringify(result), true] on success, 53 and [exception.toString(), false] when failing.""" 54 args_str: str = json.dumps(args) 55 script = """JSON.stringify((function exceptionWrapper(){ 56 try { 57 return [(function(...arguments){%(script)s}).apply(window, %(args_str)s), true] 58 } catch(e) { 59 return [e + "", false] 60 } 61 })())""" % { 62 "script": script, 63 "args_str": args_str 64 } 65 return script.strip() 66 67 class JavaScriptFromAppleScriptException(ValueError): 68 pass 69 70 71def try_get_parent_app_name(platform: plt.Platform) -> str: 72 if platform.is_remote: 73 return "" 74 launched_apps: Dict[str, str] = {} 75 try: 76 for line in platform.sh_stdout("launchctl", "list").splitlines(): 77 parts = line.split() 78 if len(parts) == 3: 79 pid, _, label = parts 80 # Input: "application.com.google.Chrome.46262139.72133274" 81 # Output: "Chrome" 82 label_parts = label.split(".") 83 if len(label_parts) <= 3: 84 continue 85 launched_apps[pid] = label_parts[3] 86 except Exception as e: # pylint: disable=broad-except 87 logging.debug("Could not list all parents: %s", e) 88 return "" 89 if not launched_apps: 90 logging.debug("Could not find any apps") 91 return "" 92 try: 93 for parent in psutil.Process(os.getpid()).parents(): 94 if label := launched_apps.get(str(parent.pid), ""): 95 return label 96 except Exception as e: # pylint: disable=broad-except 97 logging.debug("Could not find parent parent app process: %s", e) 98 return "" 99 100 101SYSTEM_EVENTS_CHECK = ( 102 'tell application "System Events" to log (count of windows)') 103 104class AppleScriptBrowser(Browser, metaclass=abc.ABCMeta): 105 APPLE_SCRIPT_ALLOW_JS_MENU: str = "" 106 APPLE_SCRIPT_JS_COMMAND: str = "" 107 APPLE_SCRIPT_SET_URL: str = "" 108 109 _browser_process: subprocess.Popen 110 111 def _exec_apple_script(self, apple_script: str, **kwargs) -> Any: 112 assert self.platform.is_macos, ( 113 f"Sorry, f{self.__class__} is only supported on MacOS for now") 114 wrapper_script, args = AppleScript.with_args(self.app_path, apple_script, 115 **kwargs) 116 return self.platform.exec_apple_script(wrapper_script, *args) 117 118 def validate_env(self, env: HostEnvironment) -> None: 119 super().validate_env(env) 120 self._check_system_events_allowed(env) 121 122 def start(self, session: BrowserSessionRunGroup) -> None: 123 assert not self._is_running 124 # Start process directly 125 startup_flags = self._get_browser_flags_for_session(session) 126 self._log_browser_start(startup_flags) 127 self._browser_process = self.platform.popen( 128 self.path, *startup_flags, shell=False) 129 if self._browser_process.poll(): 130 raise ValueError("Could not start browser process.") 131 self._pid = self._browser_process.pid 132 self.platform.sleep(3) 133 self._exec_apple_script("activate") 134 self._setup_window() 135 self._check_js_from_apple_script_allowed(session.env) 136 137 def _check_system_events_allowed(self, env: HostEnvironment) -> None: 138 try: 139 self._exec_apple_script(SYSTEM_EVENTS_CHECK) 140 except plt.SubprocessError as e: 141 logging.error("Not allowed to run AppleScript and send System Events!") 142 logging.debug(" SubprocessError: %s", e) 143 app_name = try_get_parent_app_name(self.platform) or "parent" 144 env.handle_warning( 145 f"Enable the 'System Events' permission for the {app_name} App. \n" 146 " See 'System Settings' > 'Privacy & Security' > 'Automation'.\n") 147 try: 148 self._exec_apple_script(SYSTEM_EVENTS_CHECK) 149 except plt.SubprocessError as e: 150 raise ValidationError( 151 " Not allowed to run AppleScript and send System Events!") from e 152 153 def _check_js_from_apple_script_allowed(self, env: HostEnvironment) -> None: 154 try: 155 self.js("return 1") 156 except plt.SubprocessError as e: 157 logging.error("Browser does not allow JS from AppleScript!") 158 logging.debug(" SubprocessError: %s", e) 159 env.handle_warning("Enable JavaScript from Apple Script Events: " 160 f"'{self.APPLE_SCRIPT_ALLOW_JS_MENU}'") 161 try: 162 self.js("return 1;") 163 except plt.SubprocessError as e: 164 raise ValidationError( 165 " JavaScript from Apple Script Events was not enabled") from e 166 self._is_running = True 167 168 @abc.abstractmethod 169 def _setup_window(self) -> None: 170 pass 171 172 def js( 173 self, 174 script: str, 175 timeout: Optional[dt.timedelta] = None, 176 arguments: Sequence[object] = () 177 ) -> Any: 178 del timeout 179 js_script = AppleScript.js_script_with_args(script, arguments) 180 json_result: str = self._exec_apple_script( 181 self.APPLE_SCRIPT_JS_COMMAND.strip(), js_script=js_script).rstrip() 182 result, is_success = json.loads(json_result) 183 if not is_success: 184 raise AppleScript.JavaScriptFromAppleScriptException(result) 185 return result 186 187 def show_url(self, url: str, target: Optional[str] = None) -> None: 188 if target not in (None, "_self"): 189 raise NotImplementedError( 190 f"AppleScriptBrowser show_url does not support target {target}") 191 self._exec_apple_script(self.APPLE_SCRIPT_SET_URL, url=url) 192 self.platform.sleep(0.5) 193 194 def quit(self) -> None: 195 self._exec_apple_script("quit") 196 helper.wait_and_kill(self._browser_process) 197