• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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