tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 3884dcda47f1a71125ee101416a5901f8dfd8740
parent 34b2f98faa68a2049a8f6d6cc57f2d0f94670805
Author: Nika Layzell <nika@thelayzells.com>
Date:   Tue, 16 Dec 2025 04:53:45 +0000

Bug 1908693 - Part 6: Add some framework pieces for interacting with iOS devices, r=mach-reviewers,mozbase-reviewers,ahal,jmaher,firefox-build-system-reviewers,ahochheiden

This is roughly modeled on the framework used by Android devices, but is
slightly different. This definitely requires a lot of cleanup before it can be
more generally used.

The code is split between mozdevice and mozrunner. While there is some outline
code for running on a real iOS device, most of the core logic is missing, and
would need to be added.

Differential Revision: https://phabricator.services.mozilla.com/D217137

Diffstat:
Mpython/mozbuild/mozbuild/base.py | 14++++++++++++++
Mpython/mozbuild/mozbuild/mach_commands.py | 36++++++++++++++++++++++++++++++++++++
Atesting/mozbase/mozdevice/mozdevice/ios.py | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/mozbase/mozrunner/mozrunner/devices/ios_device.py | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 362 insertions(+), 0 deletions(-)

diff --git a/python/mozbuild/mozbuild/base.py b/python/mozbuild/mozbuild/base.py @@ -998,6 +998,20 @@ class MachCommandConditions: return False @staticmethod + def is_ios(build_obj): + """Must have an iOS build.""" + if hasattr(build_obj, "substs"): + return build_obj.substs.get("TARGET_OS") == "iOS" + return False + + @staticmethod + def is_ios_simulator(build_obj): + """Must have an iOS simulator build.""" + if hasattr(build_obj, "substs"): + return build_obj.substs.get("IPHONEOS_IS_SIMULATOR", False) + return False + + @staticmethod def is_android_cpu(build_obj): """Targeting Android CPU.""" if hasattr(build_obj, "substs"): diff --git a/python/mozbuild/mozbuild/mach_commands.py b/python/mozbuild/mozbuild/mach_commands.py @@ -1514,6 +1514,10 @@ def install(command_context, **kwargs): return 0 + elif conditions.is_ios(command_context): + from mozrunner.devices.ios_device import verify_ios_device + + ret = verify_ios_device(command_context, install=True, **kwargs) == 0 else: ret = command_context._run_make( directory=".", target="install", ensure_exit_code=False @@ -2183,6 +2187,38 @@ process attach {continue_flag}-p {pid!s} device.shell("am clear-debug-app") +def _run_ios(command_context, no_install=None, debug=False): + from mozdevice.ios import IosDevice + from mozrunner.devices.ios_device import ( + verify_ios_device, + ) + + app = "org.mozilla.ios.GeckoTestBrowser" + + # `verify_ios_device` respects sets `DEVICE_UUID` + verify_ios_device( + command_context, + app=app, + install=not no_install, + ) + device_serial = os.environ.get("DEVICE_UUID") + if not device_serial: + print("No iOS devices connected.") + return 1 + + device = IosDevice.select_device(conditions.is_ios_simulator(command_context)) + if debug: + print("Application will pause after starting until a debugger is connected...") + proc = device.launch_process( + app, + wait_for_debugger=debug, + stdout=None, + stderr=None, + ) + proc.run() + proc.wait() + + def _run_jsshell(command_context, params, debug, debugger, debugger_args): try: binpath = command_context.get_binary_path("app") diff --git a/testing/mozbase/mozdevice/mozdevice/ios.py b/testing/mozbase/mozdevice/mozdevice/ios.py @@ -0,0 +1,235 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +"""mozdevice.ios is an experimental, early stages abstraction layer for running +tests with Gecko on iOS devices. Currently this package only provides support +for running tests on simulator, and is not ready for general-purpose use.""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +from mozprocess import processhandler + + +class IosDevice: + def __init__(self, uuid, name, is_simulator): + self.uuid = uuid + self.name = name + self.is_simulator = is_simulator + + @staticmethod + def all_devices(is_simulator): + if is_simulator: + return IosDeviceSimulator.all_devices() + else: + return IosDeviceReal.all_devices() + + @staticmethod + def select_device(is_simulator, device_uuid=None): + if device_uuid is None: + device_uuid = os.environ.get("DEVICE_UUID", None) + + all_devices = IosDevice.all_devices(is_simulator) + for device in all_devices: + if device_uuid is not None: + if device.uuid == device_uuid: + return device + elif not device.is_simulator or device.state == "Booted": + return device + raise Exception( + "Couldn't find a booted and connected iOS device with the correct platform" + ) + + +# FIXME: Does not have support for the features of IosDeviceSimulator, nor does it +# have the same API yet... +class IosDeviceReal(IosDevice): + def __init__(self, uuid, name): + super().__init__(uuid, name, False) + + def install(self, app_bundle): + subprocess.check_call( + [ + "xcrun", + "devicectl", + "device", + "install", + "app", + "--device", + self.uuid, + app_bundle, + ] + ) + + @staticmethod + def all_devices(): + with tempfile.NamedTemporaryFile() as tmpfile: + subprocess.check_call( + ["xcrun", "devicectl", "list", "devices", "-j", tmpfile.name], + stdout=subprocess.DEVNULL, + ) + output = json.load(tmpfile) + + if output["info"]["outcome"] != "success": + sys.stderr.write("Failed to read device list") + return [] + + return [ + IosDeviceReal(device["identifier"], device["deviceProperties"]["name"]) + for device in output["result"]["devices"] + ] + + +class IosDeviceSimulator(IosDevice): + def __init__(self, uuid, name, runtime, datapath, logpath, state): + super().__init__(uuid, name, True) + self.runtime = runtime + self.datapath = datapath + self.logpath = logpath + self.state = state + + def install(self, app_bundle): + subprocess.check_call(["xcrun", "simctl", "install", self.uuid, app_bundle]) + + def xcode_destination_specifier(self): + return "platform=iOS simulator,id=" + self.uuid + + def launch_process( + self, + bundle_id, + args=[], + env=None, + wait_for_debugger=False, + terminate_running_process=True, + **kwargs + ): + # Put provided environment variables in `SIMCTL_CHILD_` so they + # propagate into the simulator. + kwargs["env"] = os.environ.copy() + if env: + for name, value in env.items(): + kwargs["env"]["SIMCTL_CHILD_" + name] = value + + # Specify provided flags + extra_args = [] + if wait_for_debugger: + extra_args += ["--wait-for-debugger"] + if terminate_running_process: + extra_args += ["--terminate-running-process"] + + # XXX: this should perhaps capture stdout/stderr with + # `--stdout/--stderr` rather than `--console`? + + # FIXME: the ProcessHandlerMixin will have the pid for the xcrun + # command, not the actual app, which isn't great for debugging. + + return processhandler.ProcessHandlerMixin( + [ + "xcrun", + "simctl", + "launch", + *extra_args, + "--console", + self.uuid, + bundle_id, + *args, + ], + **kwargs + ) + + def test_root(self, bundle_id): + container_path = subprocess.check_output( + [ + "xcrun", + "simctl", + "get_app_container", + self.uuid, + bundle_id, + "data", + ], + text=True, + ) + return os.path.join(container_path.strip(), "test_root") + + def rm(self, path, force=False, recursive=False): + try: + if recursive: + shutil.rmtree(path, ignore_errors=True) + else: + os.remove(path) + except Exception: + pass + + def mkdir(self, path, parents=False): + if parents: + os.makedirs(path, exist_ok=True) + else: + os.mkdir(path) + + def push(self, local, remote): + shutil.copytree(local, remote, dirs_exist_ok=True) + + def pull(self, remote, local): + shutil.copytree(remote, local, dirs_exist_ok=True) + + def chmod(self, path, recursive=False, mask="777"): + if recursive: + for root, dirs, files in os.walk(path): + for d in dirs: + os.chmod(os.path.join(root, d), int(mask, 8)) + for f in files: + os.chmod(os.path.join(root, f), int(mask, 8)) + else: + os.chmod(path, int(mask, 8)) + + def is_file(self, path): + return os.path.isfile(path) + + def is_dir(self, path): + return os.path.isdir(path) + + def get_file(self, path, offset=None, length=None): + with open(path, mode="rb") as f: + if offset is not None and length is not None: + f.seek(offset) + return f.read(length) + if offset is not None: + f.seek(offset) + return f.read() + return f.read() + + def stop_application(self, bundle_id): + try: + subprocess.check_call( + ["xcrun", "simctl", "terminate", self.uuid, bundle_id] + ) + except subprocess.CalledProcessError: + pass + + @staticmethod + def all_devices(): + output = json.loads( + subprocess.check_output(["xcrun", "simctl", "list", "devices", "-j"]) + ) + + result = [] + for runtime, devices in output["devices"].items(): + for device in devices: + if not device["isAvailable"]: + continue + result.append( + IosDeviceSimulator( + device["udid"], + device["name"], + runtime, + device["dataPath"], + device["logPath"], + device["state"], + ) + ) + return result diff --git a/testing/mozbase/mozrunner/mozrunner/devices/ios_device.py b/testing/mozbase/mozrunner/mozrunner/devices/ios_device.py @@ -0,0 +1,77 @@ +import os +import subprocess + +from mozbuild.base import MachCommandConditions as conditions +from mozdevice.ios import IosDevice + +from .host_utils import ensure_host_utils + + +def verify_ios_device( + build_obj, + install=False, + xre=False, + verbose=False, + app=None, +): + is_simulator = conditions.is_ios_simulator(build_obj) + + device_verified = False + + devices = IosDevice.all_devices(is_simulator) + for device in devices: + if not device.is_simulator or device.state == "Booted": + device_verified = True + # FIXME: This roughly mimics how `verify_android_device` works but + # seems kinda jank - should we be copying this? + os.environ["DEVICE_UUID"] = device.uuid + break + + if is_simulator and not device_verified: + # FIXME: Offer to launch a simulator here. + print("No iOS simulator started.") + return + + if device_verified and install: + if not app: + app = "org.mozilla.ios.GeckoTestBrowser" + + device = IosDevice.select_device(is_simulator) + if app == "org.mozilla.ios.GeckoTestBrowser": + # FIXME: This should probably be happening as a build step, rather + # than happening during verify_ios_device! + print("Packaging GeckoTestBrowser...") + subprocess.check_call( + [ + "xcodebuild", + "-project", + os.path.join( + build_obj.topsrcdir, + "mobile/ios/GeckoTestBrowser/GeckoTestBrowser.xcodeproj", + ), + "-scheme", + "GeckoTestBrowser", + "-destination", + device.xcode_destination_specifier(), + "install", + "DSTROOT=" + build_obj.distdir, + "TOPOBJDIR=" + build_obj.topobjdir, + ] + ) + + print("Installing GeckoTestBrowser...") + device.install( + os.path.join(build_obj.distdir, "Applications/GeckoTestBrowser.app") + ) + else: + # FIXME: If the app is already installed, don't prompt the user here + # to align with verify_android_device. + input( + "Application %s cannot be automatically installed\n" + "Install it now, then hit Enter " % app + ) + + if device_verified and xre: + ensure_host_utils(build_obj, verbose) + + return device_verified