#!/usr/bin/env python3 import argparse import json import plistlib import re import shlex import subprocess import sys import tempfile from pathlib import Path from typing import Any, Dict, List, Optional, Tuple IGNORED_DIRS = {'.git', 'Pods', 'Carthage', 'DerivedData', 'build', '.build', 'node_modules'} IOS_PLATFORMS = {'iOS', 'iPadOS'} def eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) def slugify(value: str) -> str: value = re.sub(r'[^A-Za-z0-9._-]+', '-', value.strip()) value = value.strip('-') return value or 'ios-device-build' def rel(path: Path, root: Path) -> str: try: return str(path.relative_to(root)) except ValueError: return str(path) def find_container_candidates(root: Path, suffix: str) -> List[Path]: matches: List[Path] = [] for path in root.rglob(suffix): parts = path.relative_to(root).parts if any(part in IGNORED_DIRS for part in parts): continue if path.suffix == '.xcworkspace' and path.parent.suffix == '.xcodeproj': # Ignore Xcode's internal workspace inside a standalone .xcodeproj. continue if len(parts) > 4: continue matches.append(path) return sorted(matches) def resolve_container(root: Path, project_arg: Optional[str], workspace_arg: Optional[str]) -> Tuple[str, Path]: if project_arg and workspace_arg: raise SystemExit('Only one of --project or --workspace can be used.') if workspace_arg: container = (root / workspace_arg).resolve() if not Path(workspace_arg).is_absolute() else Path(workspace_arg) if not container.exists(): raise SystemExit(f'Workspace not found: {container}') return 'workspace', container if project_arg: container = (root / project_arg).resolve() if not Path(project_arg).is_absolute() else Path(project_arg) if not container.exists(): raise SystemExit(f'Project not found: {container}') return 'project', container workspaces = find_container_candidates(root, '*.xcworkspace') projects = find_container_candidates(root, '*.xcodeproj') if len(workspaces) == 1: return 'workspace', workspaces[0] if len(workspaces) > 1: top_level = [p for p in workspaces if p.parent == root] if len(top_level) == 1: return 'workspace', top_level[0] choices = '\n'.join(f'- {rel(p, root)}' for p in workspaces) raise SystemExit(f'Multiple workspaces found. Pass --workspace explicitly:\n{choices}') if len(projects) == 1: return 'project', projects[0] if len(projects) > 1: top_level = [p for p in projects if p.parent == root] if len(top_level) == 1: return 'project', top_level[0] choices = '\n'.join(f'- {rel(p, root)}' for p in projects) raise SystemExit(f'Multiple projects found. Pass --project explicitly:\n{choices}') raise SystemExit(f'No .xcodeproj or .xcworkspace found under {root}') def xcodebuild_list(container_kind: str, container: Path) -> Dict[str, Any]: cmd = ['xcodebuild', f'-{container_kind}', str(container), '-list', '-json'] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise SystemExit(proc.stderr.strip() or proc.stdout.strip() or 'xcodebuild -list failed') try: return json.loads(proc.stdout) except json.JSONDecodeError as exc: raise SystemExit(f'Failed to parse xcodebuild -list JSON: {exc}') def list_schemes(container_kind: str, container: Path) -> List[str]: payload = xcodebuild_list(container_kind, container) bucket = payload.get(container_kind, {}) schemes = bucket.get('schemes') or [] return sorted(schemes) def resolve_scheme(container_kind: str, container: Path, scheme_arg: Optional[str]) -> Tuple[str, List[str]]: schemes = list_schemes(container_kind, container) if not schemes: raise SystemExit(f'No schemes found in {container.name}') if scheme_arg: if scheme_arg not in schemes: raise SystemExit(f'Scheme not found: {scheme_arg}. Available: {", ".join(schemes)}') return scheme_arg, schemes if len(schemes) == 1: return schemes[0], schemes basename = container.stem if basename in schemes: return basename, schemes raise SystemExit('Multiple schemes found. Pass --scheme explicitly:\n' + '\n'.join(f'- {name}' for name in schemes)) def run_devicectl_json(args: List[str]) -> Dict[str, Any]: with tempfile.NamedTemporaryFile(prefix='devicectl-', suffix='.json', delete=False) as handle: json_path = Path(handle.name) cmd = ['xcrun', 'devicectl', *args, '--json-output', str(json_path)] proc = subprocess.run(cmd, capture_output=True, text=True) data: Optional[Dict[str, Any]] = None if json_path.exists(): try: data = json.loads(json_path.read_text()) finally: json_path.unlink(missing_ok=True) if proc.returncode != 0: detail = proc.stderr.strip() or proc.stdout.strip() if data and data.get('info', {}).get('outcome') != 'success': detail = detail or json.dumps(data, ensure_ascii=False) raise SystemExit(detail or 'devicectl command failed') return data or {} def physical_ios_devices() -> List[Dict[str, Any]]: payload = run_devicectl_json(['list', 'devices']) devices = payload.get('result', {}).get('devices', []) result: List[Dict[str, Any]] = [] for device in devices: hardware = device.get('hardwareProperties', {}) device_props = device.get('deviceProperties', {}) connection = device.get('connectionProperties', {}) platform = hardware.get('platform') reality = hardware.get('reality') if platform not in IOS_PLATFORMS: continue if reality != 'physical': continue result.append({ 'name': device_props.get('name') or hardware.get('marketingName') or device.get('identifier'), 'device_type': hardware.get('deviceType'), 'platform': platform, 'udid': hardware.get('udid'), 'identifier': device.get('identifier'), 'serial': hardware.get('serialNumber'), 'ecid': str(hardware.get('ecid')) if hardware.get('ecid') is not None else None, 'transport': connection.get('transportType'), 'pairing_state': connection.get('pairingState'), 'developer_mode': device_props.get('developerModeStatus'), 'boot_state': device_props.get('bootState'), 'raw': device, }) return result def choose_device(devices: List[Dict[str, Any]], device_id: Optional[str] = None, device_name: Optional[str] = None) -> Dict[str, Any]: if not devices: raise SystemExit('No physical iOS devices found via devicectl.') def matches(device: Dict[str, Any], token: str) -> bool: token_lower = token.casefold() fields = [device.get('udid'), device.get('identifier'), device.get('serial'), device.get('ecid'), device.get('name')] for field in fields: if field and token_lower == str(field).casefold(): return True return False if device_id: for device in devices: if matches(device, device_id): return device raise SystemExit(f'Device not found for --device-id {device_id}') if device_name: exact = [device for device in devices if device.get('name', '').casefold() == device_name.casefold()] if len(exact) == 1: return exact[0] partial = [device for device in devices if device_name.casefold() in device.get('name', '').casefold()] if len(partial) == 1: return partial[0] raise SystemExit('Device name is ambiguous. Candidates:\n' + '\n'.join(f"- {device['name']} ({device['udid']})" for device in devices)) paired = [d for d in devices if d.get('pairing_state') == 'paired'] if len(paired) == 1: return paired[0] iphones = [d for d in paired if d.get('device_type') == 'iPhone'] if len(iphones) == 1: return iphones[0] wired_iphones = [d for d in iphones if d.get('transport') == 'wired'] if len(wired_iphones) == 1: return wired_iphones[0] booted_iphones = [d for d in wired_iphones if d.get('boot_state') == 'booted'] if len(booted_iphones) == 1: return booted_iphones[0] candidates = paired if paired else devices raise SystemExit( 'Multiple physical iOS devices are available. Pass --device-id or --device-name explicitly:\n' + '\n'.join(f"- {device['name']} | {device['device_type']} | {device['transport']} | {device['udid']}" for device in candidates) ) def resolve_device(device_id: Optional[str], device_name: Optional[str]) -> Dict[str, Any]: if device_id: try: return choose_device(physical_ios_devices(), device_id, device_name) except SystemExit as exc: detail = str(exc) discovery_failed = any(token in detail for token in [ 'CoreDeviceService', 'devicectl error', 'No physical iOS devices found', ]) if discovery_failed: eprint(f'warning: device discovery failed ({detail}). Falling back to explicit --device-id {device_id}.') return { 'name': device_name or device_id, 'device_type': 'unknown', 'platform': 'iOS', 'udid': device_id, 'identifier': device_id, 'serial': None, 'ecid': None, 'transport': 'unknown', 'pairing_state': 'unknown', 'developer_mode': 'unknown', 'boot_state': 'unknown', 'raw': None, } raise return choose_device(physical_ios_devices(), device_id, device_name) def warn_if_xcode_running() -> None: proc = subprocess.run(['pgrep', '-x', 'Xcode'], capture_output=True, text=True) if proc.returncode == 0: eprint('warning: Xcode is running. If project.pbxproj or Info.plist have been getting rewritten, close Xcode before rebuilding.') def stream_command(cmd: List[str], cwd: Optional[Path] = None) -> None: print('$', ' '.join(shlex.quote(part) for part in cmd)) proc = subprocess.Popen(cmd, cwd=str(cwd) if cwd else None, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) assert proc.stdout is not None for line in proc.stdout: print(line, end='') proc.wait() if proc.returncode != 0: raise SystemExit(proc.returncode) def find_app_bundle(derived_data: Path, configuration: str, scheme: str, bundle_id: Optional[str]) -> Path: products_dir = derived_data / 'Build' / 'Products' / f'{configuration}-iphoneos' if not products_dir.exists(): raise SystemExit(f'Products directory not found: {products_dir}') apps = sorted(products_dir.glob('*.app')) if not apps: raise SystemExit(f'No .app bundle found under {products_dir}') if bundle_id: matches = [app for app in apps if read_info_plist(app / 'Info.plist').get('CFBundleIdentifier') == bundle_id] if len(matches) != 1: raise SystemExit(f'Expected exactly one built app with bundle ID {bundle_id}; found {len(matches)}. Installation stopped.') return matches[0] if len(apps) == 1: return apps[0] preferred = products_dir / f'{scheme}.app' if preferred.exists(): return preferred raise SystemExit('Multiple .app bundles found. Pass --bundle-id to disambiguate:\n' + '\n'.join(f'- {app.name}' for app in apps)) def read_info_plist(path: Path) -> Dict[str, Any]: if not path.exists(): raise SystemExit(f'Info.plist not found: {path}') with path.open('rb') as handle: return plistlib.load(handle) def verify_required_plist_keys(info: Dict[str, Any], keys: List[str]) -> None: missing = [key for key in keys if key not in info] if missing: raise SystemExit('Built app is missing required Info.plist keys: ' + ', '.join(missing)) def has_modern_launch_screen(info: Dict[str, Any]) -> bool: if 'UILaunchScreen' in info: return True launch_storyboard = info.get('UILaunchStoryboardName') return isinstance(launch_storyboard, str) and bool(launch_storyboard.strip()) def verify_modern_launch_screen(info: Dict[str, Any]) -> None: if has_modern_launch_screen(info): return raise SystemExit( 'Built app is missing a modern launch screen definition (`UILaunchScreen` or a non-empty ' '`UILaunchStoryboardName`). Installing this build may trigger compatibility-mode letterboxing ' 'with top/bottom black bars on iPhone. Check the real metadata source of truth such as ' 'project.yml, Tuist manifests, build settings, or the source Info.plist, or rerun with ' '--allow-missing-modern-launch-screen if this omission is intentional.' ) def main() -> None: parser = argparse.ArgumentParser(description='Build, install, and optionally launch an iOS app on a connected physical device.') parser.add_argument('--project-root', default='.', help='Repository root to inspect. Defaults to the current working directory.') parser.add_argument('--project', help='Explicit .xcodeproj path, relative to --project-root or absolute.') parser.add_argument('--workspace', help='Explicit .xcworkspace path, relative to --project-root or absolute.') parser.add_argument('--scheme', help='Explicit scheme name.') parser.add_argument('--configuration', default='Debug', help='Build configuration. Defaults to Debug.') parser.add_argument('--device-id', help='Explicit device UDID, identifier, serial, ECID, or exact devicectl device token.') parser.add_argument('--device-name', help='Explicit device name.') parser.add_argument('--bundle-id', help='Expected bundle identifier; mismatch stops before installation.') parser.add_argument('--derived-data', help='Override derived data path. Default: a unique temporary directory for this run.') parser.add_argument('--require-plist-key', action='append', default=[], help='Repeat to require specific keys in the built app Info.plist.') parser.add_argument( '--allow-missing-modern-launch-screen', action='store_true', help='Allow install even if the built app lacks UILaunchScreen and UILaunchStoryboardName.', ) parser.add_argument('--list-devices', action='store_true', help='List available physical iOS devices and exit.') parser.add_argument('--list-schemes', action='store_true', help='List resolved schemes and exit.') parser.add_argument('--launch', action='store_true', help='Launch the app after install.') parser.add_argument('--skip-clean', action='store_true', help='Skip the clean step before build.') parser.add_argument('--dry-run', action='store_true', help='Print the resolved commands but do not execute them.') args = parser.parse_args() root = Path(args.project_root).expanduser().resolve() if not root.exists(): raise SystemExit(f'Project root not found: {root}') if args.list_devices: devices = physical_ios_devices() if not devices: print('No physical iOS devices found.') return for device in devices: print( f"- {device['name']} | {device['device_type']} | transport={device['transport']} | paired={device['pairing_state']} | developer_mode={device['developer_mode']} | udid={device['udid']}" ) return container_kind, container = resolve_container(root, args.project, args.workspace) scheme, schemes = resolve_scheme(container_kind, container, args.scheme) if args.list_schemes: for item in schemes: marker = ' *' if item == scheme else '' print(f'- {item}{marker}') return device = resolve_device(args.device_id, args.device_name) derived_data = Path(args.derived_data).expanduser().resolve() if args.derived_data else Path(tempfile.mkdtemp(prefix=f'codex-ios-{slugify(root.name)}-{slugify(scheme)}-')) build_cmd = [ 'xcodebuild', f'-{container_kind}', str(container), '-scheme', scheme, '-configuration', args.configuration, '-destination', f'id={device["udid"]}', '-derivedDataPath', str(derived_data), ] if not args.skip_clean: build_cmd.append('clean') build_cmd.append('build') warn_if_xcode_running() print(f'project root: {root}') print(f'container: {container_kind} {container}') print(f'scheme: {scheme}') print(f'device: {device["name"]} ({device["udid"]})') print(f'derived data: {derived_data}') if args.dry_run: print('$', ' '.join(shlex.quote(part) for part in build_cmd)) return stream_command(build_cmd, cwd=root) app_bundle = find_app_bundle(derived_data, args.configuration, scheme, args.bundle_id) info = read_info_plist(app_bundle / 'Info.plist') bundle_id = info.get('CFBundleIdentifier') if not isinstance(bundle_id, str) or not bundle_id.strip(): raise SystemExit('Built app has no valid bundle identifier. Installation stopped.') if args.bundle_id and bundle_id != args.bundle_id: raise SystemExit('Built app bundle identifier differs from the requested identifier. Installation stopped.') verify_required_plist_keys(info, args.require_plist_key) if not args.allow_missing_modern_launch_screen: verify_modern_launch_screen(info) install_cmd = ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', device['udid'], str(app_bundle)] stream_command(install_cmd, cwd=root) if args.launch: launch_cmd = ['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', device['udid'], '--terminate-existing', bundle_id] stream_command(launch_cmd, cwd=root) print('done: build and install completed successfully.') print(f'version: {info.get("CFBundleShortVersionString", "unknown")} ({info.get("CFBundleVersion", "unknown")})') if args.launch: print(f'launched: {bundle_id}') if __name__ == '__main__': try: main() except KeyboardInterrupt: raise SystemExit(130)