Source code for batools.iossim

# Released under the MIT License. See LICENSE for details.
"""Build a Ballistica Xcode target for a simulator and run it there.

Powers ``make ios`` / ``make tvos`` -- the simulator analogue of ``make mac``.
Unlike a macOS app (a runnable binary we can ``subprocess.run`` directly), an
iOS/tvOS ``.app`` must be installed into a Simulator and launched via
``simctl``. This handles the whole lifecycle: build-for-sim, pick + boot a
device (foolproof defaults), install, launch, and stream the engine's os_log.
"""

from __future__ import annotations  # Docs-generation hack.

import os
import json
import subprocess
from typing import TYPE_CHECKING

from efro.error import CleanError
from efro.terminal import Clr

if TYPE_CHECKING:
    from typing import Any, Sequence

# The engine's os_log subsystem (see EmitPlatformLog on Apple). Overridable via
# env for spinoffs.
DEFAULT_LOG_SUBSYSTEM = 'net.froemling.ballistica'

# Per-platform Simulator knobs.
_PLATFORM_INFO = {
    'ios': {
        'destination': 'generic/platform=iOS Simulator',
        'runtime_tag': '.iOS-',
        'preferred_name_prefix': 'iPhone',
    },
    'tvos': {
        'destination': 'generic/platform=tvOS Simulator',
        'runtime_tag': '.tvOS-',
        'preferred_name_prefix': 'Apple TV',
    },
}


[docs] def run( *, project: str, scheme: str, configuration: str, platform: str ) -> None: """Build the scheme for the simulator, then install + launch + log it.""" app_path, bundle_id = build_for_sim( project=project, scheme=scheme, configuration=configuration, platform=platform, ) udid = ensure_booted_device(platform) print( f'{Clr.BLU}Installing {os.path.basename(app_path)}...{Clr.RST}', flush=True, ) _simctl(['install', udid, app_path]) print(f'{Clr.BLU}Launching {bundle_id}...{Clr.RST}', flush=True) _simctl(['launch', udid, bundle_id]) subsystem = os.environ.get('IOS_LOG_SUBSYSTEM', DEFAULT_LOG_SUBSYSTEM) print( f'{Clr.GRN}Running. Streaming os_log (subsystem={subsystem}); ' f'Ctrl-C to detach (app keeps running).{Clr.RST}', flush=True, ) stream_log(udid, subsystem)
[docs] def build_for_sim( *, project: str, scheme: str, configuration: str, platform: str, extra_cc_defines: Sequence[str] | None = None, ) -> tuple[str, str]: """Build the scheme for the simulator. Returns ``(app_path, bundle_id)`` for the built product. Also used by external drivers (e.g. test_game_run's ios leg) that manage their own install/launch lifecycle. ``extra_cc_defines`` entries (e.g. ``FOO=1``) are appended to the build's ``GCC_PREPROCESSOR_DEFINITIONS`` on top of the project's own (C-family only; Swift compilation conditions are separate). """ if platform not in _PLATFORM_INFO: raise CleanError(f"Unknown platform '{platform}'.") info = _PLATFORM_INFO[platform] # Resolve product (.app) + bundle-id, then build. settings = _build_settings( project, scheme, configuration, info, extra_cc_defines ) app_path = os.path.join( settings['TARGET_BUILD_DIR'], settings['FULL_PRODUCT_NAME'] ) bundle_id = settings['PRODUCT_BUNDLE_IDENTIFIER'] print( f'{Clr.BLU}Building {scheme} ({configuration}) for the ' f'{platform} simulator...{Clr.RST}', flush=True, ) _build(project, scheme, configuration, info, extra_cc_defines) return app_path, bundle_id
[docs] def ensure_booted_device(platform: str) -> str: """Return a booted sim udid for a platform, picking/booting if needed. See ``_ensure_booted_device`` for the selection rules. """ if platform not in _PLATFORM_INFO: raise CleanError(f"Unknown platform '{platform}'.") return _ensure_booted_device(platform, _PLATFORM_INFO[platform])
[docs] def install_app(udid: str, app_path: str) -> None: """Install a built .app onto a booted sim.""" _simctl(['install', udid, app_path])
[docs] def stream_log(udid: str, subsystem: str) -> None: """Stream the engine's os_log for a booted sim until interrupted.""" try: subprocess.run( [ 'xcrun', 'simctl', 'spawn', udid, 'log', 'stream', '--level', 'debug', '--predicate', f'subsystem == "{subsystem}"', ], check=False, ) except KeyboardInterrupt: pass
def _xcodebuild_base( project: str, scheme: str, configuration: str, info: dict[str, str], extra_cc_defines: Sequence[str] | None = None, ) -> list[str]: cmd = [ 'xcodebuild', '-project', project, '-scheme', scheme, '-configuration', configuration, '-destination', info['destination'], 'CODE_SIGNING_ALLOWED=NO', ] if extra_cc_defines: # A command-line build setting overrides the project's, so pull # the project-level values back in via $(inherited). defs = ' '.join(extra_cc_defines) cmd.append(f'GCC_PREPROCESSOR_DEFINITIONS=$(inherited) {defs}') return cmd def _build_settings( project: str, scheme: str, configuration: str, info: dict[str, str], extra_cc_defines: Sequence[str] | None = None, ) -> dict[str, str]: """Resolve build settings (product path, bundle id) for the sim build.""" out = subprocess.run( _xcodebuild_base(project, scheme, configuration, info, extra_cc_defines) + ['-showBuildSettings', '-json'], capture_output=True, check=True, ).stdout.decode() # simctl/xcodebuild json is untyped third-party output, hence Any. data: list[dict[str, Any]] = json.loads(out) # -showBuildSettings -json is a list of {target, action, buildSettings}. for entry in data: bsraw = entry.get('buildSettings', {}) if all( k in bsraw for k in ( 'TARGET_BUILD_DIR', 'FULL_PRODUCT_NAME', 'PRODUCT_BUNDLE_IDENTIFIER', ) ): return {k: str(v) for k, v in bsraw.items()} raise CleanError('Unable to resolve build settings for the sim build.') def _build( project: str, scheme: str, configuration: str, info: dict[str, str], extra_cc_defines: Sequence[str] | None = None, ) -> None: subprocess.run( _xcodebuild_base(project, scheme, configuration, info, extra_cc_defines) + ['-quiet', 'build'], check=True, ) def _runtime_version(runtime_id: str) -> tuple[int, ...]: """Parse e.g. '...SimRuntime.iOS-26-5' -> (26, 5) for sorting.""" tail = runtime_id.rsplit('.', 1)[-1] # 'iOS-26-5' parts = tail.split('-')[1:] # ['26', '5'] return tuple(int(p) for p in parts if p.isdigit()) def _ensure_booted_device(platform: str, info: dict[str, str]) -> str: """Return a booted sim udid, picking + booting one if needed. Selection order: an IOS_SIM_DEVICE override, else any already-booted device of this platform, else the newest available device (preferring a standard device for the platform). """ out = subprocess.run( ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], capture_output=True, check=True, ).stdout.decode() # Untyped third-party json, hence Any. devices_by_runtime: dict[str, list[dict[str, Any]]] = json.loads(out)[ 'devices' ] # Flatten this platform's devices, tagging each with its runtime version. tag = info['runtime_tag'] candidates: list[tuple[tuple[int, ...], dict[str, Any]]] = [] booted: list[dict[str, Any]] = [] for runtime_id, devices in devices_by_runtime.items(): if tag not in runtime_id: continue ver = _runtime_version(runtime_id) for dev in devices: candidates.append((ver, dev)) if dev.get('state') == 'Booted': booted.append(dev) override = os.environ.get('IOS_SIM_DEVICE', '').strip() if override: for _ver, dev in candidates: if override in (dev.get('name'), dev.get('udid')): return _boot(dev) raise CleanError( f"IOS_SIM_DEVICE '{override}' not found among available" f' {platform} simulators.' ) # Reuse an already-booted device rather than spinning up a new one (don't # disrupt a running sim). if booted: return _boot(booted[0]) if not candidates: raise CleanError( f'No available {platform} simulators found. Install a' f' {platform} runtime via Xcode and retry.' ) # Newest runtime first; within that, prefer a standard device by name. prefix = info['preferred_name_prefix'] candidates.sort( key=lambda c: (c[0], str(c[1].get('name', '')).startswith(prefix)), reverse=True, ) return _boot(candidates[0][1]) def _boot(dev: dict[str, Any]) -> str: """Boot a device (if needed), wait until ready, show it in DeviceHub.""" udid = str(dev['udid']) if dev.get('state') != 'Booted': name = dev.get('name', udid) print( f'{Clr.BLU}Booting simulator {name}...{Clr.RST}', flush=True, ) # Boot (if needed) and wait until fully ready before bringing up the # viewer, so it attaches to a settled device. We use 'bootstatus -b' # (boot if needed, then wait) rather than 'simctl boot': it's a clean # no-op on an already-booted device, whereas 'simctl boot' prints a # scary 'Unable to boot device in current state: Booted' error to # stderr on a re-run. subprocess.run(['xcrun', 'simctl', 'bootstatus', udid, '-b'], check=False) # Show the sim. Xcode 27 replaced Simulator.app with DeviceHub, which # displays running sims. Always do this, even for an already-booted # device: a booted sim with no window keeps running (and playing # audio) with nothing on screen, and that goes unnoticed for hours. if subprocess.run(['open', '-a', 'DeviceHub'], check=False).returncode: print( f'{Clr.YLW}Warning: could not show the Simulator window;' f' the sim is running without one. Run' f" 'open -a DeviceHub' (unsandboxed) to show it.{Clr.RST}", flush=True, ) return udid def _simctl(args: list[str]) -> None: subprocess.run(['xcrun', 'simctl'] + args, check=True) # Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self, TypeIs) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum