| #!/usr/bin/env python3 | |
| # Copyright (C) 2026 Apple Inc. All rights reserved. | |
| # | |
| # Redistribution and use in source and binary forms, with or without | |
| # modification, are permitted provided that the following conditions | |
| # are met: | |
| # 1. Redistributions of source code must retain the above copyright | |
| # notice, this list of conditions and the following disclaimer. | |
| # 2. Redistributions in binary form must reproduce the above copyright | |
| # notice, this list of conditions and the following disclaimer in the | |
| # documentation and/or other materials provided with the distribution. | |
| # | |
| # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' | |
| # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, | |
| # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | |
| # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS | |
| # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | |
| # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | |
| # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | |
| # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | |
| # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) | |
| # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF | |
| # THE POSSIBILITY OF SUCH DAMAGE. | |
| """ | |
| generate-cmake-xcode-project | |
| Emits a thin Xcode project that wraps a CMake/Ninja build directory: | |
| - One PBXLegacyTarget that shells out to ninja, so Cmd-B is a ninja | |
| incremental (sub-second no-op) instead of a workspace dependency scan. | |
| - One scheme per discovered executable / .app bundle pointing directly at | |
| the ninja-built product, so Cmd-R launches under lldb. | |
| - Folder references to Source/, Tools/, and each <framework>/DerivedSources in | |
| the build dir, so the Project Navigator and Quick Open cover both checked-in | |
| and generated code. | |
| - An lldbinit that inverts the build's -fdebug-prefix-map so breakpoints | |
| set in the source editor resolve. | |
| This is not `cmake -G Xcode` -- the Xcode generator routes compilation back | |
| through Xcode's build system, reintroducing the script-phase / xcfilelist | |
| overhead this project exists to avoid. Here ninja stays the build system; | |
| Xcode is purely the debugger frontend. | |
| Usage: | |
| ninja -C <build-dir> xcode-project # builds the products, then generates | |
| Tools/Scripts/generate-cmake-xcode-project [build-dir] | |
| Neither a CMake configure nor the default build generates this project: it is | |
| a debugging aid, so it is produced only when one of the above asks for it. | |
| Output: | |
| <build-dir>/WebKit.xcodeproj | |
| <build-dir>/lldbinit | |
| """ | |
| import argparse | |
| import functools | |
| import os | |
| import plistlib | |
| import re | |
| import shutil | |
| import struct | |
| import subprocess | |
| import sys | |
| import uuid | |
| from pathlib import Path | |
| # Build-time helpers that happen to be Mach-O executables but are not useful | |
| # debug targets. Filtered out of scheme generation. | |
| BUILD_TOOL_NAMES = { | |
| "yasm", | |
| "LLIntOffsetsExtractor", | |
| "LLIntSettingsExtractor", | |
| "LayoutTestHelper", | |
| "ImageDiff", | |
| } | |
| # Mach-O magic numbers (thin + fat, both endians). | |
| MACHO_MAGICS = {0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca} | |
| def pbxid(seed=None): | |
| """Xcode 96-bit object identifier (24 hex chars). Seeded IDs are stable | |
| across regenerations so scheme -> target references survive.""" | |
| if seed: | |
| return uuid.uuid5(uuid.NAMESPACE_DNS, f"webkit.cmake.{seed}").hex[:24].upper() | |
| return uuid.uuid4().hex[:24].upper() | |
| NINJA_TARGET_ID = pbxid("ninja-target") | |
| PROJECT_NAME = "WebKit" | |
| # Top-level source-tree directories exposed as folder references in the Project | |
| # Navigator. Folder references (lastKnownFileType = folder) track the filesystem | |
| # live, so the navigator matches `ls` without enumerating ~15,000 PBXFileReference | |
| # entries the way the hand-maintained WebKit.xcodeproj does. Quick Open, gutter | |
| # breakpoints, and Open Quickly all work against folder-reference contents. | |
| # | |
| # LayoutTests is intentionally omitted: at ~200,000 files it dominates Xcode's | |
| # "Create build description" scan and turns a no-op Cmd-B into ~30 s. Tests can | |
| # still be opened via File > Open or by path; they aren't useful for setting | |
| # breakpoints anyway. | |
| SOURCE_FOLDERS = ("Source", "Tools") | |
| def find_ninja(): | |
| p = shutil.which("ninja") | |
| if p: | |
| return p | |
| for candidate in ("/opt/homebrew/bin/ninja", "/usr/local/bin/ninja"): | |
| if os.path.exists(candidate): | |
| return candidate | |
| # Xcode ships one, and Source/cmake/ninja-wrapper already falls back to | |
| # it, so a build machine whose only ninja is in Xcode builds WebKit but | |
| # could not generate this project. | |
| try: | |
| p = subprocess.run(["/usr/bin/xcrun", "-f", "ninja"], text=True, | |
| capture_output=True, check=True).stdout.strip() | |
| if p: | |
| return p | |
| except (OSError, subprocess.CalledProcessError): | |
| pass | |
| sys.exit("error: ninja not found on PATH, in /opt/homebrew, in " | |
| "/usr/local or in Xcode") | |
| @functools.lru_cache(maxsize=None) | |
| def xcode_version_tag() -> str: | |
| """LastUpgradeCheck / LastUpgradeVersion want the Xcode version as a 4-digit | |
| MMmm string (Xcode 27.0 -> "2700", 17.2 -> "1720"). Matching the running | |
| Xcode suppresses the "project was created with an older Xcode" upgrade | |
| prompt on first open.""" | |
| try: | |
| out = subprocess.check_output(["/usr/bin/xcrun", "xcodebuild", "-version"], | |
| text=True, stderr=subprocess.DEVNULL) | |
| m = re.search(r"Xcode\s+(\d+)(?:\.(\d+))?", out) | |
| if m: | |
| major, minor = int(m.group(1)), int(m.group(2) or 0) | |
| return f"{major:02d}{minor:02d}" | |
| except (OSError, subprocess.CalledProcessError): | |
| pass | |
| return "1600" | |
| @functools.lru_cache(maxsize=None) | |
| def read_build_ninja(build_dir: Path) -> str: | |
| """build.ninja is ~80-100 MB; read it once and let lru_cache hold it for | |
| the (short) lifetime of this script. Both read_isysroot and read_prefix_maps | |
| pull from it.""" | |
| try: | |
| return (build_dir / "build.ninja").read_text() | |
| except OSError: | |
| return "" | |
| @functools.lru_cache(maxsize=None) | |
| def read_cmake_cache(build_dir: Path, key: str) -> str: | |
| try: | |
| for line in (build_dir / "CMakeCache.txt").read_text().splitlines(): | |
| if line.startswith(f"{key}:"): | |
| return line.split("=", 1)[1] | |
| except OSError: | |
| pass | |
| return "" | |
| def read_isysroot(build_dir: Path) -> str: | |
| """CMAKE_OSX_SYSROOT is often empty in the cache (CMake recomputes it from | |
| env on every configure). The authoritative value is the -isysroot flag | |
| baked into build.ninja -- using that guarantees the wrapper's SDKROOT | |
| matches what the existing object files were built against.""" | |
| m = re.search(r"-isysroot\s+(\S+)", read_build_ninja(build_dir)) | |
| if m: | |
| return m.group(1) | |
| return read_cmake_cache(build_dir, "CMAKE_OSX_SYSROOT") | |
| # Variables Xcode may inject into the legacy-target environment even with | |
| # passBuildSettingsInEnvironment = 0. If any of these reach a CMake reconfigure | |
| # (triggered by ninja's build.ninja-regeneration rule whenever a CMakeLists.txt | |
| # changes), CMake bakes the new values into every compile command and the next | |
| # build is a full rebuild instead of a no-op. Scrub them. | |
| XCODE_ENV_POLLUTANTS = ( | |
| "SDKROOT", "SDK_NAME", "SDK_DIR", "PLATFORM_NAME", "PLATFORM_DIR", | |
| "MACOSX_DEPLOYMENT_TARGET", "IPHONEOS_DEPLOYMENT_TARGET", | |
| "ARCHS", "NATIVE_ARCH", "CURRENT_ARCH", "VALID_ARCHS", | |
| "LLVM_TARGET_TRIPLE_VENDOR", "LLVM_TARGET_TRIPLE_OS_VERSION", | |
| "LLVM_TARGET_TRIPLE_SUFFIX", | |
| "TOOLCHAINS", "CONFIGURATION", "CONFIGURATION_BUILD_DIR", | |
| "BUILT_PRODUCTS_DIR", "TARGET_BUILD_DIR", "OBJROOT", "SYMROOT", | |
| "CC", "CXX", "LD", "LDPLUSPLUS", | |
| ) | |
| def generate_ninja_wrapper(build_dir: Path, ninja_path: str) -> Path: | |
| """Produce a hermetic launcher for ninja. The goal is bit-identical | |
| environment to a terminal `ninja -C <dir>` so that (a) no-op builds stay | |
| no-op, (b) ccache hits, and (c) any CMake reconfigure ninja triggers sees | |
| the same toolchain it was originally configured with.""" | |
| sysroot = read_isysroot(build_dir) | |
| deployment = read_cmake_cache(build_dir, "CMAKE_OSX_DEPLOYMENT_TARGET") | |
| lines = [ | |
| "#!/bin/sh", | |
| "# Generated by Tools/Scripts/generate-cmake-xcode-project.", | |
| "# Scrub Xcode-injected variables so CMake reconfigure (if ninja", | |
| "# triggers one) produces an identical build.ninja and the existing", | |
| "# object files / ccache entries remain valid.", | |
| "unset " + " ".join(XCODE_ENV_POLLUTANTS), | |
| "", | |
| "# Keep the inherited PATH (so user-installed tools the build relies on", | |
| "# -- ccache, python, perl modules -- remain reachable) but drop entries", | |
| "# Xcode prepends from inside its .app bundle, which would shadow the", | |
| "# system clang/ld and change CMake's compiler-ID detection.", | |
| 'PATH=$(printf "%s" "$PATH" | /usr/bin/tr ":" "\\n" \\', | |
| ' | /usr/bin/grep -Ev "/Contents/Developer/|/Xcode[^/]*\\.app/" \\', | |
| ' | /usr/bin/paste -sd ":" -)', | |
| f'export PATH="{os.path.dirname(ninja_path)}:$PATH"', | |
| ] | |
| if sysroot: | |
| lines.append(f'export SDKROOT="{sysroot}"') | |
| else: | |
| lines.append('export SDKROOT="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)"') | |
| if deployment: | |
| lines.append(f'export MACOSX_DEPLOYMENT_TARGET="{deployment}"') | |
| lines.append(f'exec "{ninja_path}" "$@"') | |
| wrapper = build_dir / "ninja-xcode-wrapper" | |
| write_if_changed(wrapper, "\n".join(lines) + "\n", mode=0o755) | |
| return wrapper | |
| def is_macho_executable(path: Path) -> bool: | |
| if not (path.is_file() and os.access(path, os.X_OK)): | |
| return False | |
| try: | |
| with path.open("rb") as f: | |
| magic, = struct.unpack("<I", f.read(4)) | |
| return magic in MACHO_MAGICS | |
| except (OSError, struct.error): | |
| return False | |
| def discover_executables(build_dir: Path): | |
| """Yield (scheme_name, absolute_path, is_bundle) for every launchable | |
| product directly under build_dir.""" | |
| for app in sorted(build_dir.glob("*.app")): | |
| yield app.stem, app, True | |
| for exe in sorted(build_dir.iterdir()): | |
| if exe.name in BUILD_TOOL_NAMES or exe.suffix in (".dylib", ".a"): | |
| continue | |
| if is_macho_executable(exe): | |
| yield exe.name, exe, False | |
| def read_prefix_maps(build_dir: Path): | |
| """Extract -fdebug-prefix-map=FROM=TO pairs from build.ninja so the | |
| generated lldbinit exactly inverts whatever OptionsMac.cmake configured, | |
| rather than assuming a particular layout.""" | |
| maps = [] | |
| for from_path, to_token in set(re.findall(r"-fdebug-prefix-map=(\S+?)=(\S+)", | |
| read_build_ninja(build_dir))): | |
| maps.append((to_token, from_path)) | |
| # Longer replacement tokens first so "build" sorts before ".". | |
| maps.sort(key=lambda m: -len(m[0])) | |
| return maps | |
| def generate_pbxproj(build_dir: Path, source_dir: Path, ninja_path: str) -> str: | |
| ids = {k: pbxid(k) for k in ( | |
| "project", "root_group", "products_group", "derived_group", | |
| "config_list_project", "config_list_target", | |
| "config_project", "config_target", | |
| )} | |
| ids["legacy_target"] = NINJA_TARGET_ID | |
| folder_refs = [] | |
| folder_children = [] | |
| for name in SOURCE_FOLDERS: | |
| if not (source_dir / name).is_dir(): | |
| continue | |
| fid = pbxid(f"folder.{name}") | |
| folder_children.append(f"{fid} /* {name} */") | |
| folder_refs.append( | |
| f'\t\t{fid} /* {name} */ = {{\n' | |
| f'\t\t\tisa = PBXFileReference;\n' | |
| f'\t\t\tlastKnownFileType = folder;\n' | |
| f'\t\t\tname = {name};\n' | |
| f'\t\t\tpath = "{source_dir / name}";\n' | |
| f'\t\t\tsourceTree = "<absolute>";\n' | |
| f'\t\t}};' | |
| ) | |
| # Build-dir DerivedSources, one folder ref per framework, grouped under a | |
| # "DerivedSources" PBXGroup so the navigator reads | |
| # DerivedSources / WebKit / FooMessageReceiver.cpp | |
| # rather than exposing the whole build dir (which would drag in CMakeFiles | |
| # and *.o). Discovered at generation time so new frameworks appear after a | |
| # reconfigure. | |
| derived_children = [] | |
| for d in sorted(build_dir.glob("*/DerivedSources")): | |
| if not any(d.iterdir()): | |
| continue | |
| framework = d.parent.name | |
| fid = pbxid(f"derived.{framework}") | |
| derived_children.append(f"{fid} /* {framework} */") | |
| folder_refs.append( | |
| f'\t\t{fid} /* {framework} */ = {{\n' | |
| f'\t\t\tisa = PBXFileReference;\n' | |
| f'\t\t\tlastKnownFileType = folder;\n' | |
| f'\t\t\tname = {framework};\n' | |
| f'\t\t\tpath = "{d}";\n' | |
| f'\t\t\tsourceTree = "<absolute>";\n' | |
| f'\t\t}};' | |
| ) | |
| folder_refs_text = "\n".join(folder_refs) | |
| root_children = ", ".join( | |
| folder_children | |
| + [f'{ids["derived_group"]} /* DerivedSources */'] | |
| + [f'{ids["products_group"]} /* Products */'] | |
| ) | |
| derived_children_text = ", ".join(derived_children) | |
| # passBuildSettingsInEnvironment = 0 keeps Xcode's ~200 build-setting env | |
| # vars (SDKROOT, ARCHS, etc.) out of the ninja invocation -- they would | |
| # perturb ccache hashes and the IDL preprocessor's -target flag handling. | |
| return f"""// !$*UTF8*$! | |
| {{ | |
| \tarchiveVersion = 1; | |
| \tclasses = {{}}; | |
| \tobjectVersion = 56; | |
| \tobjects = {{ | |
| \t\t{ids["legacy_target"]} /* ninja */ = {{ | |
| \t\t\tisa = PBXLegacyTarget; | |
| \t\t\tbuildArgumentsString = "-C {build_dir} $(ACTION)"; | |
| \t\t\tbuildConfigurationList = {ids["config_list_target"]}; | |
| \t\t\tbuildPhases = (); | |
| \t\t\tbuildToolPath = "{ninja_path}"; | |
| \t\t\tbuildWorkingDirectory = "{source_dir}"; | |
| \t\t\tdependencies = (); | |
| \t\t\tname = ninja; | |
| \t\t\tpassBuildSettingsInEnvironment = 0; | |
| \t\t\tproductName = ninja; | |
| \t\t}}; | |
| \t\t{ids["project"]} /* Project */ = {{ | |
| \t\t\tisa = PBXProject; | |
| \t\t\tattributes = {{ | |
| \t\t\t\tBuildIndependentTargetsInParallel = YES; | |
| \t\t\t\tLastUpgradeCheck = {xcode_version_tag()}; | |
| \t\t\t}}; | |
| \t\t\tbuildConfigurationList = {ids["config_list_project"]}; | |
| \t\t\tcompatibilityVersion = "Xcode 14.0"; | |
| \t\t\tdevelopmentRegion = en; | |
| \t\t\thasScannedForEncodings = 0; | |
| \t\t\tknownRegions = (en, Base); | |
| \t\t\tmainGroup = {ids["root_group"]}; | |
| \t\t\tproductRefGroup = {ids["products_group"]}; | |
| \t\t\tprojectDirPath = ""; | |
| \t\t\tprojectRoot = ""; | |
| \t\t\ttargets = ({ids["legacy_target"]}); | |
| \t\t}}; | |
| \t\t{ids["root_group"]} = {{ | |
| \t\t\tisa = PBXGroup; | |
| \t\t\tchildren = ({root_children}); | |
| \t\t\tsourceTree = "<group>"; | |
| \t\t}}; | |
| \t\t{ids["products_group"]} /* Products */ = {{ | |
| \t\t\tisa = PBXGroup; | |
| \t\t\tchildren = (); | |
| \t\t\tname = Products; | |
| \t\t\tsourceTree = "<group>"; | |
| \t\t}}; | |
| \t\t{ids["derived_group"]} /* DerivedSources */ = {{ | |
| \t\t\tisa = PBXGroup; | |
| \t\t\tchildren = ({derived_children_text}); | |
| \t\t\tname = DerivedSources; | |
| \t\t\tsourceTree = "<group>"; | |
| \t\t}}; | |
| {folder_refs_text} | |
| \t\t{ids["config_project"]} /* Debug */ = {{ | |
| \t\t\tisa = XCBuildConfiguration; | |
| \t\t\tbuildSettings = {{}}; | |
| \t\t\tname = Debug; | |
| \t\t}}; | |
| \t\t{ids["config_target"]} /* Debug */ = {{ | |
| \t\t\tisa = XCBuildConfiguration; | |
| \t\t\tbuildSettings = {{}}; | |
| \t\t\tname = Debug; | |
| \t\t}}; | |
| \t\t{ids["config_list_project"]} = {{ | |
| \t\t\tisa = XCConfigurationList; | |
| \t\t\tbuildConfigurations = ({ids["config_project"]}); | |
| \t\t\tdefaultConfigurationIsVisible = 0; | |
| \t\t\tdefaultConfigurationName = Debug; | |
| \t\t}}; | |
| \t\t{ids["config_list_target"]} = {{ | |
| \t\t\tisa = XCConfigurationList; | |
| \t\t\tbuildConfigurations = ({ids["config_target"]}); | |
| \t\t\tdefaultConfigurationIsVisible = 0; | |
| \t\t\tdefaultConfigurationName = Debug; | |
| \t\t}}; | |
| \t}}; | |
| \trootObject = {ids["project"]}; | |
| }} | |
| """ | |
| def asan_options(build_dir: Path) -> str: | |
| """ASAN_OPTIONS for any process built with -fsanitize=address. | |
| allocator_may_return_null=1 matches Tools/Scripts/webkitpy/port/driver.py. | |
| The sandbox profiles allow ASan's sigaltstack/socketpair calls when | |
| preprocessed with -fsanitize=address (see PlatformMac.cmake), so no | |
| use_sigaltstack workaround is needed.""" | |
| if "address" in read_cmake_cache(build_dir, "ENABLE_SANITIZERS"): | |
| return "allocator_may_return_null=1" | |
| return "" | |
| def tsan_options(build_dir: Path) -> str: | |
| """TSAN_OPTIONS for any process built with -fsanitize=thread. | |
| Points at the in-tree suppressions file (JSC parallel-GC, JIT, allocator | |
| internals -- see Tools/Scripts/tsan_suppressions.txt). second_deadlock_stack | |
| gives both lock-order stacks; halt_on_error=0 lets a test surface multiple | |
| races in one run.""" | |
| if "thread" not in read_cmake_cache(build_dir, "ENABLE_SANITIZERS"): | |
| return "" | |
| source_dir = Path(read_cmake_cache(build_dir, "CMAKE_HOME_DIRECTORY")) | |
| supp = source_dir / "Tools" / "Scripts" / "tsan_suppressions.txt" | |
| opts = "halt_on_error=0:second_deadlock_stack=1" | |
| if supp.exists(): | |
| opts += f":suppressions={supp}" | |
| return opts | |
| def bundle_identifier(app_path: Path, fallback_name: str) -> str: | |
| try: | |
| with (app_path / "Contents" / "Info.plist").open("rb") as f: | |
| return plistlib.load(f)["CFBundleIdentifier"] | |
| except (OSError, KeyError, plistlib.InvalidFileException): | |
| return f"org.WebKit.{fallback_name}" | |
| def generate_scheme(name: str, executable_path: Path, is_bundle: bool, | |
| build_dir: Path) -> str: | |
| if is_bundle: | |
| runnable = f""" | |
| <PathRunnable | |
| runnableDebuggingMode = "0" | |
| BundleIdentifier = "{bundle_identifier(executable_path, name)}" | |
| FilePath = "{executable_path}"> | |
| </PathRunnable>""" | |
| else: | |
| runnable = f""" | |
| <PathRunnable | |
| runnableDebuggingMode = "0" | |
| FilePath = "{executable_path}"> | |
| </PathRunnable>""" | |
| framework_path = f"{build_dir}:{build_dir}/lib" | |
| asan = asan_options(build_dir) | |
| tsan = tsan_options(build_dir) | |
| # Plain executables (jsc, testapi, ...) find their frameworks via LC_RPATH; | |
| # setting DYLD_FRAMEWORK_PATH on them would only redirect Xcode's injected | |
| # liblldbViewDebuggerSupport.dylib (which links system WebKit) to the | |
| # just-built one, which is pointless for binaries that don't use WebKit. | |
| # | |
| # App bundles need the full set, matching Tools/Scripts/run-minibrowser: | |
| # DYLD_FRAMEWORK_PATH for the UI process and __XPC_-prefixed copies that | |
| # launchd forwards (with the prefix stripped) to WebContent/Networking/GPU | |
| # children -- SIP would otherwise strip DYLD_* on the way to them. The | |
| # view-debugger redirect this causes is harmless because PlatformMac.cmake | |
| # makes WebKit.framework re-export WebKitLegacy, matching the system ABI. | |
| env = [] | |
| if is_bundle: | |
| env += [ | |
| ("DYLD_FRAMEWORK_PATH", framework_path), | |
| ("DYLD_LIBRARY_PATH", framework_path), | |
| ("__XPC_DYLD_FRAMEWORK_PATH", framework_path), | |
| ("__XPC_DYLD_LIBRARY_PATH", framework_path), | |
| ] | |
| if asan: | |
| env.append(("ASAN_OPTIONS", asan)) | |
| if is_bundle: | |
| env.append(("__XPC_ASAN_OPTIONS", asan)) | |
| if tsan: | |
| env.append(("TSAN_OPTIONS", tsan)) | |
| if is_bundle: | |
| env.append(("__XPC_TSAN_OPTIONS", tsan)) | |
| if env: | |
| entries = "".join( | |
| f""" | |
| <EnvironmentVariable | |
| key = "{k}" | |
| value = "{v}" | |
| isEnabled = "YES"> | |
| </EnvironmentVariable>""" for k, v in env) | |
| env_vars = f""" | |
| <EnvironmentVariables>{entries} | |
| </EnvironmentVariables>""" | |
| else: | |
| env_vars = "" | |
| return f"""<?xml version="1.0" encoding="UTF-8"?> | |
| <Scheme | |
| LastUpgradeVersion = "{xcode_version_tag()}" | |
| version = "1.7"> | |
| <BuildAction | |
| parallelizeBuildables = "YES" | |
| buildImplicitDependencies = "YES"> | |
| <BuildActionEntries> | |
| <BuildActionEntry | |
| buildForTesting = "YES" | |
| buildForRunning = "YES" | |
| buildForProfiling = "YES" | |
| buildForArchiving = "NO" | |
| buildForAnalyzing = "NO"> | |
| <BuildableReference | |
| BuildableIdentifier = "primary" | |
| BlueprintIdentifier = "{NINJA_TARGET_ID}" | |
| BuildableName = "ninja" | |
| BlueprintName = "ninja" | |
| ReferencedContainer = "container:{PROJECT_NAME}.xcodeproj"> | |
| </BuildableReference> | |
| </BuildActionEntry> | |
| </BuildActionEntries> | |
| </BuildAction> | |
| <TestAction | |
| buildConfiguration = "Debug" | |
| selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | |
| selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | |
| shouldUseLaunchSchemeArgsEnv = "YES"> | |
| <Testables/> | |
| </TestAction> | |
| <LaunchAction | |
| buildConfiguration = "Debug" | |
| selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" | |
| selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" | |
| launchStyle = "0" | |
| useCustomWorkingDirectory = "NO" | |
| ignoresPersistentStateOnLaunch = "NO" | |
| debugDocumentVersioning = "NO" | |
| debugServiceExtension = "internal" | |
| allowLocationSimulation = "NO" | |
| customLLDBInitFile = "{build_dir}/lldbinit">{runnable}{env_vars} | |
| </LaunchAction> | |
| <ProfileAction | |
| buildConfiguration = "Debug" | |
| shouldUseLaunchSchemeArgsEnv = "YES" | |
| savedToolIdentifier = "" | |
| useCustomWorkingDirectory = "NO" | |
| debugDocumentVersioning = "NO">{runnable} | |
| </ProfileAction> | |
| <AnalyzeAction buildConfiguration = "Debug"/> | |
| <ArchiveAction buildConfiguration = "Debug" revealArchiveInOrganizer = "YES"/> | |
| </Scheme> | |
| """ | |
| def generate_lldbinit(source_dir: Path, build_dir: Path) -> str: | |
| lines = ["# Generated by Tools/Scripts/generate-cmake-xcode-project"] | |
| for token, real_path in read_prefix_maps(build_dir): | |
| lines.append(f"settings append target.source-map {token} {real_path}") | |
| if not any("source-map" in l for l in lines): | |
| # Fall back to the conventional mapping if build.ninja was unreadable. | |
| lines.append(f"settings append target.source-map . {source_dir}") | |
| lines.append(f"settings append target.source-map build {build_dir}") | |
| formatters = source_dir / "Tools" / "lldb" / "lldb_webkit.py" | |
| if formatters.exists(): | |
| lines.append("") | |
| lines.append(f"command script import {formatters}") | |
| return "\n".join(lines) + "\n" | |
| def write_if_changed(path: Path, text: str, mode: int = 0o644) -> None: | |
| """Write only what moved, and through a rename. A build regenerates these | |
| files from inside itself, so rewriting ninja-xcode-wrapper in place would | |
| truncate the script the shell is still executing, and a fresh mtime would | |
| make an open Xcode reload the project for nothing. The pid in the | |
| temporary name keeps concurrent writers apart.""" | |
| try: | |
| if path.read_text() == text: | |
| return | |
| except OSError: | |
| pass | |
| tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") | |
| tmp.write_text(text) | |
| tmp.chmod(mode) | |
| os.replace(tmp, path) | |
| def default_build_dir(source_dir: Path) -> Path: | |
| for cfg in ("ASan", "Debug", "RelWithDebInfo", "TSan", "Release"): | |
| candidate = source_dir / "WebKitBuild" / "cmake-mac" / cfg | |
| if (candidate / "CMakeCache.txt").exists(): | |
| return candidate | |
| return source_dir / "WebKitBuild" / "cmake-mac" / "Release" | |
| def main(): | |
| script_dir = Path(__file__).resolve().parent | |
| source_dir = script_dir.parent.parent # Tools/Scripts -> repo root | |
| ap = argparse.ArgumentParser(description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("build_dir", nargs="?", | |
| help="CMake binary dir (default: first configured " | |
| "WebKitBuild/cmake-mac/{ASan,Debug,RelWithDebInfo," | |
| "TSan,Release})") | |
| ap.add_argument("--quiet", action="store_true", | |
| help="Report in one line, for the build-time invocation") | |
| args = ap.parse_args() | |
| if args.build_dir: | |
| build_dir = Path(args.build_dir) | |
| if not build_dir.is_absolute(): | |
| build_dir = (source_dir / build_dir).resolve() | |
| else: | |
| build_dir = default_build_dir(source_dir) | |
| if not (build_dir / "CMakeCache.txt").exists(): | |
| sys.exit(f"error: {build_dir}/CMakeCache.txt not found -- run " | |
| f"'cmake --preset <preset>' (see 'cmake --list-presets') first") | |
| proj = build_dir / f"{PROJECT_NAME}.xcodeproj" | |
| schemes_dir = proj / "xcshareddata" / "xcschemes" | |
| schemes = list(discover_executables(build_dir)) | |
| wrapper = generate_ninja_wrapper(build_dir, find_ninja()) | |
| proj.mkdir(exist_ok=True) | |
| write_if_changed(proj / "project.pbxproj", | |
| generate_pbxproj(build_dir, source_dir, str(wrapper))) | |
| schemes_dir.mkdir(parents=True, exist_ok=True) | |
| for name, path, is_bundle in schemes: | |
| write_if_changed(schemes_dir / f"{name}.xcscheme", | |
| generate_scheme(name, path, is_bundle, build_dir)) | |
| # Xcode selects the alphabetically-first scheme by default; pin MiniBrowser | |
| # (or the first .app bundle) to orderHint 0 so a fresh open lands on the | |
| # thing most people want to debug. The management plist is per-user, which | |
| # is fine -- the project lives in the build dir and isn't shared. | |
| user = os.environ.get("USER", "default") | |
| mgmt_dir = proj / "xcuserdata" / f"{user}.xcuserdatad" / "xcschemes" | |
| mgmt_dir.mkdir(parents=True, exist_ok=True) | |
| preferred = next((n for n, _, b in schemes if b), schemes[0][0] if schemes else None) | |
| if preferred: | |
| ordered = [preferred] + sorted(n for n, _, _ in schemes if n != preferred) | |
| entries = "\n".join( | |
| f"\t\t<key>{n}.xcscheme_^#shared#^_</key>\n" | |
| f"\t\t<dict><key>orderHint</key><integer>{i}</integer></dict>" | |
| for i, n in enumerate(ordered) | |
| ) | |
| write_if_changed( | |
| mgmt_dir / "xcschememanagement.plist", | |
| '<?xml version="1.0" encoding="UTF-8"?>\n' | |
| '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' | |
| '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' | |
| '<plist version="1.0">\n<dict>\n' | |
| '\t<key>SchemeUserState</key>\n\t<dict>\n' | |
| f'{entries}\n' | |
| '\t</dict>\n</dict>\n</plist>\n' | |
| ) | |
| write_if_changed(build_dir / "lldbinit", | |
| generate_lldbinit(source_dir, build_dir)) | |
| if args.quiet: | |
| print(f"Generated {proj}: {len(schemes)} scheme(s).") | |
| return | |
| print(f"Generated {proj}") | |
| print(f" {len(schemes)} scheme(s): " | |
| f"{', '.join(n for n, _, _ in schemes) or '(none yet -- the next build adds one per product)'}") | |
| print(f" lldbinit: {build_dir}/lldbinit") | |
| print() | |
| print(f" open {proj}") | |
| print(f" Cmd-B -> {wrapper} -C {build_dir}") | |
| print(f" Cmd-R -> ninja, then launch + attach lldb") | |
| if __name__ == "__main__": | |
| main() |