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 ctypes 8import functools 9import json 10import logging 11import plistlib 12import re 13import traceback as tb 14from subprocess import SubprocessError 15from typing import Any, Dict, Optional, Tuple 16 17import psutil 18 19from crossbench import path as pth 20from crossbench.plt.posix import PosixPlatform 21 22 23class MacOSPlatform(PosixPlatform): 24 SEARCH_PATHS: Tuple[pth.AnyPath, ...] = ( 25 pth.AnyPosixPath("."), 26 pth.AnyPosixPath("/Applications"), 27 # TODO: support remote platforms 28 pth.LocalPath.home() / "Applications", 29 ) 30 31 LSAPPINFO_IN_FRONT_LINE_RE = r".*\(in front\)\s*" 32 LSAPPINFO_PID_LINE_RE = r"\s*pid = ([0-9]+).*" 33 34 @property 35 def is_macos(self) -> bool: 36 return True 37 38 @property 39 def name(self) -> str: 40 return "macos" 41 42 @functools.cached_property 43 def version(self) -> str: 44 return self.sh_stdout("sw_vers", "-productVersion").strip() 45 46 @functools.cached_property 47 def device(self) -> str: #pylint: disable=invalid-overridden-method 48 return self.sh_stdout("sysctl", "hw.model").strip().split(maxsplit=1)[1] 49 50 @functools.cached_property 51 def cpu(self) -> str: #pylint: disable=invalid-overridden-method 52 brand = self.sh_stdout("sysctl", "-n", "machdep.cpu.brand_string").strip() 53 cores_info = self._get_cpu_cores_info() 54 return f"{brand} {cores_info}" 55 56 def _get_cpu_cores_info(self): 57 cores = self.sh_stdout("sysctl", "-n", "machdep.cpu.core_count").strip() 58 return f"{cores} cores" 59 60 @property 61 def is_battery_powered(self) -> bool: 62 if self.is_local: 63 return super().is_battery_powered 64 return "Battery Power" in self.sh_stdout("pmset", "-g", "batt") 65 66 def _find_app_binary_path(self, app_path: pth.AnyPath) -> pth.AnyPath: 67 assert app_path.suffix == ".app", f"Expected .app but got {app_path}" 68 bin_path = app_path / "Contents" / "MacOS" / app_path.stem 69 if self.exists(bin_path): 70 return bin_path 71 if not self.exists(bin_path.parent): 72 raise ValueError(f"Binary does not exist: {bin_path}") 73 self.assert_is_local() 74 binaries = [ 75 path for path in self.iterdir(bin_path.parent) if self.is_file(path) 76 ] 77 if len(binaries) == 1: 78 return binaries[0] 79 # Fallback to read plist 80 plist_path = app_path / "Contents" / "Info.plist" 81 if not self.is_file(plist_path): 82 raise ValueError(f"Could not find Info.plist in app bundle: {app_path}") 83 # TODO: support remote platform 84 with self.local_path(plist_path).open("rb") as f: 85 plist = plistlib.load(f) 86 bin_path = ( 87 app_path / "Contents" / "MacOS" / 88 plist.get("CFBundleExecutable", app_path.stem)) 89 if self.is_file(bin_path): 90 return bin_path 91 if not binaries: 92 raise ValueError(f"No binaries found in {app_path}") 93 raise ValueError(f"Invalid number of binaries found: {binaries}") 94 95 def search_binary(self, app_or_bin: pth.AnyPathLike) -> Optional[pth.AnyPath]: 96 app_or_bin_path: pth.AnyPath = self.path(app_or_bin) 97 if not app_or_bin_path.parts: 98 raise ValueError("Got empty path") 99 is_app = app_or_bin_path.suffix == ".app" 100 if not is_app: 101 # Look up basic binaries with `which` if possible. 102 if result_path := self.which(app_or_bin_path): 103 assert self.exists(result_path), f"{result_path} does not exist." 104 return result_path 105 if app_path := self.lookup_binary_override(app_or_bin_path): 106 if app_path := self._validate_search_binary_candidate(is_app, app_path): 107 return app_path 108 for search_path in self.SEARCH_PATHS: 109 # Recreate Path object for easier pyfakefs testing 110 result_path = self.path(search_path) / app_or_bin_path 111 if app_path := self._validate_search_binary_candidate( 112 is_app, result_path): 113 return app_path 114 return None 115 116 def _validate_search_binary_candidate( 117 self, is_app: bool, result_path: pth.AnyPath) -> Optional[pth.AnyPath]: 118 if not is_app: 119 if self.is_file(result_path): 120 return result_path 121 return None 122 if not self.is_dir(result_path): 123 return None 124 result_path = self._find_app_binary_path(result_path) 125 if self.exists(result_path): 126 return result_path 127 return None 128 129 def search_app(self, app_or_bin: pth.AnyPathLike) -> Optional[pth.AnyPath]: 130 app_or_bin_path: pth.AnyPath = self.path(app_or_bin) 131 if not app_or_bin_path.parts: 132 raise ValueError("Got empty path") 133 self.assert_is_local() 134 if app_or_bin_path.suffix != ".app": 135 raise ValueError("Expected app name with '.app' suffix, " 136 f"but got: '{app_or_bin_path.name}'") 137 binary = self.search_binary(app_or_bin_path) 138 if not binary: 139 return None 140 # input: /Applications/Safari.app/Contents/MacOS/Safari 141 # output: /Applications/Safari.app 142 app_path = binary.parents[2] 143 assert app_path.suffix == ".app", f"Expected .app but got {app_path}" 144 assert self.is_dir(app_path) 145 return app_path 146 147 def app_version(self, app_or_bin: pth.AnyPathLike) -> str: 148 app_or_bin = self.path(app_or_bin) 149 if not self.exists(app_or_bin): 150 raise ValueError(f"Binary {app_or_bin} does not exist.") 151 152 app_path = None 153 for current in (app_or_bin, *app_or_bin.parents): 154 if current.suffix == ".app" and current.stem == app_or_bin.stem: 155 app_path = current 156 break 157 if not app_path: 158 # Most likely just a cli tool" 159 return self.sh_stdout(app_or_bin, "--version").strip() 160 info_plist = app_path / "Contents/Info.plist" 161 if self.exists(info_plist): 162 plist = plistlib.loads(self.cat_bytes(info_plist)) 163 if version_string := plist.get("CFBundleShortVersionString"): 164 return version_string 165 166 # Backup solution use the binary (not the .app bundle) with --version. 167 maybe_bin_path: Optional[pth.AnyPath] = app_or_bin 168 if app_or_bin.suffix == ".app": 169 maybe_bin_path = self.search_binary(app_or_bin) 170 if not maybe_bin_path: 171 raise ValueError(f"Could not extract app version: {app_or_bin}") 172 try: 173 return self.sh_stdout(maybe_bin_path, "--version").strip() 174 except SubprocessError as e: 175 raise ValueError(f"Could not extract app version: {app_or_bin}") from e 176 177 def exec_apple_script(self, script: str, *args: str) -> str: 178 if args: 179 script = f"""on run argv 180 {script.strip()} 181 end run""" 182 return self.sh_stdout("/usr/bin/osascript", "-e", script, *args) 183 184 def foreground_process(self) -> Optional[Dict[str, Any]]: 185 foreground_process_info = self.sh_stdout("lsappinfo", "front").strip() 186 if not foreground_process_info: 187 return None 188 foreground_info = self.sh_stdout("lsappinfo", "info", "-only", "pid", 189 foreground_process_info).strip() 190 foreground_info_split = foreground_info.split("=") 191 192 pid = None 193 194 if len(foreground_info_split) == 2: 195 pid = foreground_info_split[1] 196 else: 197 # On macOS 14.0 Beta, "lsappinfo info" returns an empty result. Fall back 198 # to parsing the output of "lsappinfo list" to obtain the front app's 199 # info. 200 app_list = self.sh_stdout("lsappinfo", "list") 201 found_front_app = False 202 for app_list_line in app_list.splitlines(): 203 if re.match(self.LSAPPINFO_IN_FRONT_LINE_RE, app_list_line): 204 found_front_app = True 205 elif found_front_app: 206 match = re.match(self.LSAPPINFO_PID_LINE_RE, app_list_line) 207 if match: 208 pid = match.group(1) 209 break 210 211 if pid and pid.isdigit(): 212 return psutil.Process(int(pid)).as_dict() 213 214 return None 215 216 def get_relative_cpu_speed(self) -> float: 217 try: 218 lines = self.sh_stdout("pmset", "-g", "therm").split() 219 for index, line in enumerate(lines): 220 if line == "CPU_Speed_Limit": 221 return int(lines[index + 2]) / 100.0 222 except SubprocessError: 223 pass 224 logging.debug("Could not get relative CPU speed: %s", tb.format_exc()) 225 return 1 226 227 def system_details(self) -> Dict[str, Any]: 228 details = super().system_details() 229 details.update({ 230 "system_profiler": 231 self.sh_stdout("system_profiler", "SPHardwareDataType"), 232 "sysctl_machdep_cpu": 233 self.sh_stdout("sysctl", "machdep.cpu"), 234 "sysctl_hw": 235 self.sh_stdout("sysctl", "hw"), 236 }) 237 return details 238 239 def check_system_monitoring(self, disable: bool = False) -> bool: 240 return self.check_crowdstrike(disable) 241 242 def check_autobrightness(self) -> bool: 243 output = self.sh_stdout("system_profiler", "SPDisplaysDataType", 244 "-json").strip() 245 data = json.loads(output) 246 if spdisplays_data := data.get("SPDisplaysDataType"): 247 for data in spdisplays_data: 248 if spdisplays_ndrvs := data.get("spdisplays_ndrvs"): 249 for display in spdisplays_ndrvs: 250 if auto_brightness := display.get("spdisplays_ambient_brightness"): 251 return auto_brightness == "spdisplays_yes" 252 raise ValueError( 253 "Could not find 'spdisplays_ndrvs' from SPDisplaysDataType") 254 raise ValueError("Could not get 'SPDisplaysDataType' form system profiler") 255 256 def check_crowdstrike(self, disable: bool = False) -> bool: 257 falconctl = self.path( 258 "/Applications/Falcon.app/Contents/Resources/falconctl") 259 if not self.exists(falconctl): 260 logging.debug("You're fine, falconctl or %s are not installed.", 261 falconctl) 262 return True 263 if not disable: 264 for process in self.processes(attrs=["exe"]): 265 exe = process["exe"] 266 if exe and exe.endswith("/com.crowdstrike.falcon.Agent"): 267 return False 268 return True 269 try: 270 logging.warning("Checking falcon sensor status:") 271 status = self.sh_stdout("sudo", falconctl, "stats", "agent_info") 272 except SubprocessError as e: 273 logging.debug("Could not probe falconctl, assuming it's not running: %s", 274 e) 275 return True 276 if "operational: true" not in status: 277 # Early return if not running, no need to disable the sensor. 278 return True 279 # Try disabling the process 280 logging.warning("Disabling crowdstrike monitoring:") 281 self.sh("sudo", falconctl, "unload") 282 return True 283 284 def _get_display_service(self) -> Tuple[ctypes.CDLL, Any]: 285 assert self.is_local, "Operation not supported on remote platforms" 286 core_graphics = ctypes.CDLL( 287 "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") 288 main_display = core_graphics.CGMainDisplayID() 289 display_services = ctypes.CDLL( 290 "/System/Library/PrivateFrameworks/DisplayServices.framework" 291 "/DisplayServices") 292 display_services.DisplayServicesSetBrightness.argtypes = [ 293 ctypes.c_int, ctypes.c_float 294 ] 295 display_services.DisplayServicesGetBrightness.argtypes = [ 296 ctypes.c_int, ctypes.POINTER(ctypes.c_float) 297 ] 298 return display_services, main_display 299 300 def set_main_display_brightness(self, brightness_level: int) -> None: 301 """Sets the main display brightness at the specified percentage by 302 brightness_level. 303 304 This function imitates the open-source "brightness" tool at 305 https://github.com/nriley/brightness. 306 Since the benchmark doesn't care about older MacOSen, multiple displays 307 or other complications that tool has to consider, setting the brightness 308 level boils down to calling this function for the main display. 309 310 Args: 311 brightness_level: Percentage at which we want to set screen brightness. 312 313 Raises: 314 AssertionError: An error occurred when we tried to set the brightness 315 """ 316 display_services, main_display = self._get_display_service() 317 ret = display_services.DisplayServicesSetBrightness(main_display, 318 brightness_level / 100) 319 assert ret == 0 320 321 def get_main_display_brightness(self) -> int: 322 """Gets the current brightness level of the main display . 323 324 This function imitates the open-source "brightness" tool at 325 https://github.com/nriley/brightness. 326 Since the benchmark doesn't care about older MacOSen, multiple displays 327 or other complications that tool has to consider, setting the brightness 328 level boils down to calling this function for the main display. 329 330 Returns: 331 An int of the current percentage value of the main screen brightness 332 333 Raises: 334 AssertionError: An error occurred when we tried to set the brightness 335 """ 336 337 display_services, main_display = self._get_display_service() 338 display_brightness = ctypes.c_float() # pylint: disable=no-value-for-parameter 339 ret = display_services.DisplayServicesGetBrightness( 340 main_display, ctypes.byref(display_brightness)) 341 assert ret == 0 342 return round(display_brightness.value * 100) 343 344 def screenshot(self, result_path: pth.AnyPath) -> None: 345 self.sh("screencapture", "-x", result_path) 346