# # Helper module to build macOS version of various source ports # Copyright (C) 2020-2023 Alexey Lysiuk # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import argparse import os import shutil import subprocess import typing from pathlib import Path from platform import machine from .state import BuildState from .target import Target, targets from .utility import ( OS_VERSION_ARM64, OS_VERSION_X86_64, CaseInsensitiveDict, CommandLineOptions, TargetPlatform, symlink_directory, ) class Builder(object): def __init__(self, args: list): self._targets = CaseInsensitiveDict({target.name: target for target in targets()}) arguments = self._parse_arguments(args) state = self._state = BuildState() state.xcode = arguments.xcode state.verbose = arguments.verbose self._platforms: typing.List[TargetPlatform] = [] self._populate_platforms(arguments) state.platform = self._platforms[0] if arguments.temp_path: state.temp_path = Path(arguments.temp_path).absolute() state.environment['TMPDIR'] = str(state.temp_path) + os.sep os.makedirs(state.temp_path, exist_ok=True) if arguments.source_path: state.source_path = Path(arguments.source_path).absolute() if arguments.target: self._target = self._targets[arguments.target] state.source = state.source_path / self._target.name state.external_source = False os.makedirs(state.source_path, exist_ok=True) else: assert arguments.source state.source = Path(arguments.source).absolute() state.external_source = True self._detect_target() for target in self._targets.values(): if target != self._target: target.initialize(state) del self._targets if arguments.build_path: state.build_path = Path(arguments.build_path).absolute() else: state.build_path = state.root_path / 'build' / self._target.name / ('xcode' if state.xcode else 'make') if arguments.output_path: state.output_path = Path(arguments.output_path).absolute() else: state.output_path = state.root_path / 'output' self._environment = state.environment state.static_moltenvk = arguments.static_moltenvk state.quasi_glib = arguments.quasi_glib state.jobs = arguments.jobs and arguments.jobs or self._get_default_job_count() def _get_default_job_count(self): args = ('sysctl', '-n', 'hw.ncpu') result = subprocess.run(args, check=True, env=self._environment, stdout=subprocess.PIPE) return result.stdout.decode('ascii').strip() def _populate_platforms(self, arguments): state = self._state def adjust_sdk_path(path: str) -> Path: if path: return Path(path).absolute() sdk_probe_path = state.root_path / 'sdk' / f'MacOSX{os_version}.sdk' return sdk_probe_path if sdk_probe_path.exists() else None if not arguments.disable_x64: os_version = arguments.os_version_x64 if arguments.os_version_x64 else OS_VERSION_X86_64 assert os_version >= OS_VERSION_X86_64, f'macOS {os_version} is not supported' sdk_path = adjust_sdk_path(arguments.sdk_path_x64) platform = TargetPlatform('x86_64', 'x86_64-apple-darwin', os_version, sdk_path, state.prefix_path) self._platforms.append(platform) if not arguments.disable_arm: os_version = arguments.os_version_arm if arguments.os_version_arm else OS_VERSION_ARM64 assert os_version >= OS_VERSION_ARM64, f'macOS {os_version} is not supported' sdk_path = adjust_sdk_path(arguments.sdk_path_arm) platform = TargetPlatform('arm64', 'aarch64-apple-darwin', os_version, sdk_path, state.prefix_path) self._platforms.append(platform) assert len(self._platforms) > 0 # Put native platform first in the list of platforms if self._platforms[0].architecture == machine(): return for platform in self._platforms: if platform.architecture == machine(): native_platform = platform self._platforms.remove(platform) self._platforms.insert(0, native_platform) break def run(self): state = self._state target = self._target target.prepare_source(state) if target.destination == Target.DESTINATION_DEPS: state.install_path = state.deps_path / target.name elif target.destination == Target.DESTINATION_OUTPUT: state.install_path = state.output_path / target.name assert state.install_path if not state.xcode and state.install_path.exists(): shutil.rmtree(state.install_path) self._create_prefix_directory() if version := state.source_version(): print(f'Building {version}') if target.multi_platform and not state.xcode: self._build_multiple_platforms(target) else: self._build(target) def _build(self, target: Target): state = self._state state.environment = self._environment.copy() state.options = CommandLineOptions() target.configure(state) target.build(state) target.post_build(state) def _build_multiple_platforms(self, target: Target): assert target.multi_platform state = self._state base_build_path = state.build_path base_install_path = state.install_path install_paths = [] for platform in self._platforms: if platform.architecture in target.unsupported_architectures: continue state.platform = platform state.build_path = base_build_path / ('build_' + platform.architecture) if platform.architecture == machine(): state.native_build_path = state.build_path state.install_path = base_build_path / ('install_' + platform.architecture) self._build(target) install_paths.append(state.install_path) self._merge_install_paths(install_paths, base_install_path) @staticmethod def _compare_files(paths: typing.Sequence[Path]) -> bool: content = None for path in paths: if not path.exists(): return False with path.open('rb') as f: if content: if content != f.read(): return False else: content = f.read() return True def _merge_file(self, src: Path, src_sub_paths: typing.Sequence[Path], dst_path: Path): with open(src, 'rb') as f: header = f.read(8) is_executable = header[:4] == b'\xcf\xfa\xed\xfe' is_library = header == b'!\n' if is_executable or is_library: # Merge executable and library files dst_file = dst_path / src.name args: typing.List[typing.Union[str, Path]] = ['lipo'] args += src_sub_paths args += ['-create', '-output', dst_file] subprocess.run(args, check=True, env=self._environment) # Apply ad-hoc code signing on executable files outside of application bundles if is_executable and '.app/Contents/' not in str(src): args = ['codesign', '--sign', '-', dst_file] subprocess.run(args, check=True, env=self._environment) else: if not Builder._compare_files(src_sub_paths): print(f'WARNING: Source files for {dst_path / src.name} don\'t match') shutil.copy(src_sub_paths[0], dst_path) def _merge_missing_files(self, src_paths: typing.Sequence[Path], dst_path: Path): shifted_src_paths = [path for path in src_paths] last_path_index = len(src_paths) - 1 for _ in range(last_path_index): shifted_src_paths.append(shifted_src_paths[0]) del shifted_src_paths[0] if not shifted_src_paths[0].exists(): continue self._merge_install_paths(shifted_src_paths, dst_path, missing_files_only=True) def _merge_install_paths(self, src_paths: typing.Sequence[Path], dst_path: Path, missing_files_only=False): if len(src_paths) == 0: return if not missing_files_only: if dst_path.exists(): shutil.rmtree(dst_path) os.makedirs(dst_path, exist_ok=True) for src in src_paths[0].iterdir(): src_sub_paths = [path / src.name for path in src_paths] if src.is_dir(): self._merge_install_paths(src_sub_paths, dst_path / src.name, missing_files_only) elif src.name.endswith('.la'): # Skip libtool files continue elif missing_files_only: for src_sub_path in src_sub_paths[1:]: if not src_sub_path.exists(): shutil.copy(src_sub_paths[0], dst_path) else: self._merge_file(src, src_sub_paths, dst_path) if not missing_files_only: self._merge_missing_files(src_paths, dst_path) def _create_prefix_directory(self): state = self._state os.makedirs(state.prefix_path, exist_ok=True) cleanup = True for dep in state.deps_path.iterdir(): if dep.is_dir(): symlink_directory(dep, state.prefix_path, cleanup) # Do symlink cleanup only once cleanup = False Builder._remove_empty_directories(state.prefix_path) @staticmethod def _remove_empty_directories(path: Path) -> int: content: typing.List[str] = os.listdir(path) count = len(content) removed = 0 for entry in content: abspath = path / entry if os.path.isdir(abspath): removed += Builder._remove_empty_directories(abspath) if count == removed: os.rmdir(path) removed = 1 return removed def _detect_target(self): for name, target in self._targets.items(): if target.detect(self._state): self._target = self._targets[name] break assert self._target def _parse_arguments(self, args: list): assert self._targets parser = argparse.ArgumentParser(description='*ZDoom binary dependencies for macOS') excl_group = parser.add_mutually_exclusive_group(required=True) excl_group.add_argument('--target', choices=self._targets.keys(), help='target to build') excl_group.add_argument('--source', metavar='path', help='path to target\'s source code') group = parser.add_argument_group('Configuration options') group.add_argument('--xcode', action='store_true', help='generate Xcode project instead of build') group.add_argument('--os-version-x64', metavar='version', help='macOS deployment version for x86_64') group.add_argument('--os-version-arm', metavar='version', help='macOS deployment version for ARM64') group.add_argument('--verbose', action='store_true', help='enable verbose build output') group.add_argument('--jobs', help='number of parallel compilation jobs') excl_group = parser.add_mutually_exclusive_group() excl_group.add_argument('--disable-x64', action='store_true', help='disable x86_64 support') excl_group.add_argument('--disable-arm', action='store_true', help='disable ARM64 support') group = parser.add_argument_group('Paths') group.add_argument('--source-path', metavar='path', help='path to store downloaded and checked out source code') group.add_argument('--build-path', metavar='path', help='target build path') group.add_argument('--output-path', metavar='path', help='output path for main targets') group.add_argument('--temp-path', metavar='path', help='path to temporary files directory') group.add_argument('--sdk-path-x64', metavar='path', help='path to macOS SDK for x86_64') group.add_argument('--sdk-path-arm', metavar='path', help='path to macOS SDK for ARM64') group = parser.add_argument_group('Hacks') group.add_argument('--static-moltenvk', action='store_true', help='link with static MoltenVK library') group.add_argument('--quasi-glib', action='store_true', help='link with QuasiGlib library') return parser.parse_args(args)