#!/usr/bin/python3 import datetime import glob import json import os import sys import time import traceback # Ordered the same as we order platforms in the generated Xcode project, except # macOS is last _DEVICE_PLATFORMS = { "iphoneos": None, "appletvos": None, "watchos": None, "macosx": None, } _SIMULATOR_PLATFORMS = { "iphonesimulator": None, "appletvsimulator": None, "watchsimulator": None, "macosx": None, } def _wait_for_value(calculate_value, value_name): wait_counter = 0 while True: value = calculate_value() if value: break if wait_counter == 0: now = datetime.datetime.now().strftime('%H:%M:%S') print( f"note: ({now}) {value_name} not updated yet, waiting...", file = sys.stderr, flush = True, ) if wait_counter == 10: now = datetime.datetime.now().strftime('%H:%M:%S') print( f"""\ warning: ({now}) {value_name} still not updated after 10 seconds. If happens \ frequently, or the cache is never created, please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, flush = True, ) time.sleep(1) wait_counter += 1 if wait_counter > 0: now = datetime.datetime.now().strftime('%H:%M:%S') print( f"""\ note: ({now}) {value_name} updated after {wait_counter} seconds.""", file = sys.stderr, flush = True, ) return value def _get_build_request( xcode_version, objroot, build_request_min_ctime): if xcode_version < 1430: # Before Xcode 14.3 def wait_for_description(): build_description_cache = max( glob.iglob( f"{objroot}/XCBuildData/BuildDescriptionCacheIndex-*", ), key = os.path.getctime, ) if (os.path.getctime(build_description_cache) >= build_request_min_ctime): return build_description_cache return None build_description_cache = ( _wait_for_value(wait_for_description, "BuildDescriptionCacheIndex") ) with open(build_description_cache, 'rb') as f: f.seek(-32, os.SEEK_END) build_request_id = f.read().decode('ASCII') build_request_file = ( f"{objroot}/XCBuildData/{build_request_id}-buildRequest.json" ) def wait_for_build_request_file(): if os.path.exists(build_request_file): with open(build_request_file, encoding = "utf-8") as f: # Parse the build-request.json file try: return json.load(f) except Exception as error: print( f"""\ error: Failed to parse '{build_request_file}': {type(error).__name__}: {error}. Please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, ) exit(1) return None return _wait_for_value( wait_for_build_request_file, f"\"{build_request_file}\"", ) def wait_for_build_request(): try: xcbuilddata = max( glob.iglob(f"{objroot}/XCBuildData/*.xcbuilddata"), key = os.path.getctime, ) if os.path.getctime(xcbuilddata) < build_request_min_ctime: return None except (ValueError, FileNotFoundError, OSError): # ValueError: no .xcbuilddata files yet (empty glob) # FileNotFoundError/OSError: TOCTOU race - file deleted between glob and getctime return None build_request_file = f"{xcbuilddata}/build-request.json" if os.path.exists(build_request_file): with open(build_request_file, encoding = "utf-8") as f: # Parse the build-request.json file try: return json.load(f) except Exception as error: print( f"""\ error: Failed to parse '{build_request_file}': {type(error).__name__}: {error}. Please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, ) sys.exit(1) return None return _wait_for_value( wait_for_build_request, "newest 'buildRequest.json' file", ) def _calculate_label_and_target_ids( build_request, guid_labels, guid_target_ids): # Xcode gets "stuck" in the `buildFiles` or `build` command for # top-level targets, so we can't reliably change commands here. Leaving # the code in place in case this is fixed in the future, or we want to # do something similar in an XCBBuildService proxy. # # command = ( # build_request.get("_buildCommand2", {}).get("command", "build") # ) command = "build" parameters = build_request["parameters"] platform = ( parameters["activeRunDestination"]["platform"] ) configuration_name = parameters["configurationName"] labels_and_target_ids = [] for target in build_request["configuredTargets"]: label = guid_labels.get(target["guid"]) if not label: # `BazelDependency` and the like continue full_target_target_ids = guid_target_ids[target["guid"]] target_target_ids = ( full_target_target_ids.get(command) or # Will only be `null` if `command == "buildFiles"` and there # isn't a different compile target id full_target_target_ids["build"] ) target_ids = _select_target_ids( target_target_ids[configuration_name], platform, ) for target_id in target_ids: labels_and_target_ids.append((label, target_id)) if not labels_and_target_ids: print( """\ error: Failed to determine labels and targets. Note, currently `.xcworkspace`s \ aren't supported. Please make sure you are opening the generated \ `.xcodeproj` file bundle directly. If you are, try using the "Clean Build \ Folder" command instead (⇧ ⌘ K). If you still get this error after that, then \ please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, ) exit(1) return labels_and_target_ids def _calculate_guid_labels_and_target_ids(base_objroot): pif_cache = f"{base_objroot}/XCBuildData/PIFCache" project_cache = f"{pif_cache}/project" target_cache = f"{pif_cache}/target" # The PIF cache will only be created before the `SetSessionUserInfo` # command, which normally happens when a project is opened. If Derived Data # is cleared while the project is open if not (os.path.exists(project_cache) and os.path.exists(target_cache)): print( f"""\ error: PIFCache ({pif_cache}) doesn't exist. If you manually cleared Derived \ Data, you need to close and re-open the project for the PIFCache to be created \ again. Using the "Clean Build Folder" command instead (⇧ ⌘ K) won't trigger \ this error. If this error still happens after re-opening the project, please \ file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, ) sys.exit(1) project_pif = max( glob.iglob(f"{project_cache}/*"), key = os.path.getctime, ) guid_payload_parent = f"{base_objroot}/guid_payload" guid_payload_path = f"""\ {guid_payload_parent}/{os.path.basename(project_pif)}_v2.json""" if os.path.exists(guid_payload_path): with open(guid_payload_path, encoding = "utf-8") as f: payload = json.load(f) return payload["labels"], payload["targetIds"] with open(project_pif, encoding = "utf-8") as f: project_pif = json.load(f) targets = project_pif["targets"] guid_labels = {} guid_target_ids = {} for target_name in targets: target_file = f"{target_cache}/{target_name}-json" with open(target_file, encoding = "utf-8") as f: target_pif = json.load(f) label = None build_target_ids = {} compile_target_ids = {} for configuration in target_pif["buildConfigurations"]: config_build_target_ids = {"key": "BAZEL_TARGET_ID"} config_compile_target_ids = {"key": "BAZEL_COMPILE_TARGET_IDS"} for key, value in configuration["buildSettings"].items(): if key.startswith("BAZEL_TARGET_ID"): # This uses a list in the case where the value isn't meant to # be inherited. Otherwise, `$(BAZEL_TARGET_ID)` is used later # in this processing to pull from an inherited setting. if value == "$(BAZEL_TARGET_ID)": target_ids = value else: # This is only a single value but the later parsing of # these target ids assumes a list. target_ids = [value] config_build_target_ids[_platform_from_build_key(key)] = ( target_ids ) elif key.startswith("BAZEL_COMPILE_TARGET_IDS"): # This uses a list in the case where the value isn't meant to # be inherited. Otherwise, `$(BAZEL_COMPILE_TARGET_IDS)` is # used later in this processing to pull from an inherited # setting. if value == "$(BAZEL_COMPILE_TARGET_IDS)": target_ids = "$(BAZEL_COMPILE_TARGET_IDS)" else: # Target identifiers contain a space but are space separated. # Split on all spaces then rejoin across the identifier pairs. target_ids = value.split(" ") target_ids = [ " ".join(target_ids[i:i+2]) for i in range(0, len(target_ids), 2) ] config_compile_target_ids[_platform_from_compile_key(key)] = ( target_ids ) elif key == "BAZEL_LABEL": label = value configuration_name = configuration["name"] build_target_ids[configuration_name] = config_build_target_ids compile_target_ids[configuration_name] = config_compile_target_ids if not label: # `BazelDependency` and the like continue target_ids = { "build": build_target_ids, } if len(compile_target_ids) > 1: target_ids["buildFiles"] = compile_target_ids guid = target_pif["guid"] guid_labels[guid] = label guid_target_ids[guid] = target_ids os.makedirs(guid_payload_parent, exist_ok = True) with open(guid_payload_path, "w", encoding = "utf-8") as f: payload = { "labels": guid_labels, "targetIds": guid_target_ids, } json.dump(payload, f) return guid_labels, guid_target_ids def _platform_from_build_key(key): if key.startswith("BAZEL_TARGET_ID[sdk="): return key[20:-2] return "" def _platform_from_compile_key(key): if key.startswith("BAZEL_COMPILE_TARGET_IDS[sdk="): return key[29:-2] return "" def _select_target_ids(target_ids, platform): key = target_ids["key"] platforms = {platform: None} # We need to try other similar platforms (i.e. other simulator platforms if # `platform`` is for a simulator). This is to support schemes with targets # of multiple platforms in them. Because `dict` is insertion ordered, # `platform` will be checked first. platforms.update(_similar_platforms(platform)) for platform in platforms: platform_target_ids = target_ids.get(platform) if platform_target_ids: if platform_target_ids == f"$({key})": return target_ids[""] return platform_target_ids return target_ids[""] def _similar_platforms(platform): if platform == "macosx" or "simulator" in platform: return _SIMULATOR_PLATFORMS return _DEVICE_PLATFORMS def _main( xcode_version, objroot, base_objroot, marker_file, prefixes_str): if not os.path.exists(marker_file): return build_request_min_ctime = os.path.getctime(marker_file) try: xcode_version = int(xcode_version) except ValueError: print( """ warning: xcode_version was not an integer. Please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md """, file = sys.stderr, ) xcode_version = 9999 prefixes = prefixes_str.split(",") try: build_request = _get_build_request( xcode_version, objroot, build_request_min_ctime, ) guid_labels, guid_target_ids = ( _calculate_guid_labels_and_target_ids(base_objroot) ) except Exception: print( f"""\ error: Failed to calculate labels and target ids from PIFCache: {traceback.format_exc()} Please file a bug report here: \ https://github.com/MobileNativeFoundation/rules_xcodeproj/issues/new?template=bug.md""", file = sys.stderr, ) sys.exit(1) labels_and_target_ids = _calculate_label_and_target_ids( build_request, guid_labels, guid_target_ids, ) print("\n".join( [ f"{label}\n{prefix} {id}" for label, id in labels_and_target_ids for prefix in prefixes ], )) if __name__ == "__main__": _main( # XCODE_VERSION_ACTUAL sys.argv[1], # non_preview_objroot sys.argv[2], # base_objroot sys.argv[3], # build_marker_file sys.argv[4], # output_group_prefixes sys.argv[5], )