# # Helper module to build macOS version of various source ports # Copyright (C) 2020-2024 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 hashlib import os import re import shutil import subprocess import typing import urllib.request from pathlib import Path from .packaging.version import Version as StrictVersion from .utility import CommandLineOptions class BuildState: def __init__(self): self_path = Path(__file__) self.root_path = self_path.parent.parent self.deps_path = self.root_path / 'deps' self.prefix_path = self.root_path / 'prefix' self.bin_path = self.prefix_path / 'bin' self.include_path = self.prefix_path / 'include' self.lib_path = self.prefix_path / 'lib' self.patch_path = self.root_path / 'patch' self.source_path = self.root_path / 'source' self.temp_path = self.root_path / 'temp' self.source = Path() self.external_source = True self.build_path = None self.native_build_path = None self.output_path = None self.install_path = None self._compiler_flags = None self._linker_flags = None self.platform = None self.xcode = False self.verbose = False self.jobs = 1 self.static_moltenvk = False self.quasi_glib = False self.environment = os.environ.copy() self.options = CommandLineOptions() def architecture(self) -> str: return self.platform.architecture if self.platform else '' def host(self) -> str: return self.platform.host if self.platform else '' def os_version(self) -> StrictVersion: return self.platform.os_version if self.platform else None def sdk_path(self) -> Path: return self.platform.sdk_path if self.platform else None def sdk_version(self) -> typing.Union[StrictVersion, None]: if sdk_path := self.sdk_path(): if match := re.search(r'/MacOSX(\d+.\d+).sdk', str(sdk_path), re.IGNORECASE): return StrictVersion(match[1]) return None def c_compiler(self) -> Path: return self.platform.c_compiler if self.platform else None def cxx_compiler(self) -> Path: return self.platform.cxx_compiler if self.platform else None def compiler_flags(self) -> str: if not self._compiler_flags: self._compiler_flags = f'-I{self.include_path} -ffile-prefix-map={self.source}/=' return self._compiler_flags def linker_flags(self) -> str: if not self._linker_flags: self._linker_flags = f'-L{self.lib_path}' # Fix for Xcode 15.0 known issue with the new linker # https://developer.apple.com/documentation/xcode-release-notes/xcode-15-release-notes#Known-Issues # Binaries using symbols with a weak definition crash at runtime on iOS 14/macOS 12 or older. # This impacts primarily C++ projects due to their extensive use of weak symbols. (114813650) (FB13097713) # Workaround: Bump the minimum deployment target to iOS 15, macOS 12, watchOS 8 or tvOS 15, # or add -Wl,-ld_classic to the OTHER_LDFLAGS build setting. version_output = subprocess.run(('clang', '--version'), check=True, capture_output=True) version_match = re.search(r'\(clang-([\d.]+)\)', version_output.stdout.decode('ascii')) if version_match: version = StrictVersion(version_match.group(1)) if version.major == 1500 and version.minor == 0: self._linker_flags += ' -Wl,-ld_classic' return self._linker_flags def checkout_git(self, url: str, branch: typing.Optional[str] = None): if self.source.exists(): return args = ('git', 'clone', '--recurse-submodules', url, self.source) subprocess.run(args, check=True, cwd=self.root_path, env=self.environment) if branch: args = ('git', 'checkout', '-b', branch, 'origin/' + branch) subprocess.run(args, check=True, cwd=self.source, env=self.environment) def download_source(self, url: str, checksum: str, patches: typing.Union[tuple, list, str, None] = None): if self.external_source: return os.makedirs(self.source, exist_ok=True) data, filepath = self._read_source_package(url) self._verify_checksum(checksum, data, filepath) first_path_component, extract_path = self._unpack_source_package(filepath) if not patches: pass elif isinstance(patches, str): self._apply_source_patch(extract_path, patches) elif isinstance(patches, (tuple, list)): for patch in patches: self._apply_source_patch(extract_path, patch) else: assert False # Adjust source and build paths according to extracted source code self.source = extract_path self.build_path = self.build_path / first_path_component def _read_source_package(self, url: str) -> typing.Tuple[bytes, Path]: filename = url.rsplit(os.sep, 1)[1] filepath = self.source / filename if filepath.exists(): # Read existing source package with open(filepath, 'rb') as f: data = f.read() else: # Download package with source code print(f'Downloading {filename}') response = urllib.request.urlopen(url) try: with open(filepath, 'wb') as f: data = response.read() f.write(data) except IOError: os.unlink(filepath) raise return data, filepath @staticmethod def _verify_checksum(checksum: str, data: bytes, filepath: Path) -> None: file_hasher = hashlib.sha256() file_hasher.update(data) file_checksum = file_hasher.hexdigest() if file_checksum != checksum: filepath.unlink() raise Exception(f'Checksum of {filepath} does not match, expected: {checksum}, actual: {file_checksum}') def _unpack_source_package(self, filepath: Path) -> typing.Tuple[str, Path]: args = ('tar', '-tf', filepath) result = subprocess.run(args, check=True, env=self.environment, stdout=subprocess.PIPE) file_paths_str = result.stdout.decode("utf-8") file_paths = file_paths_str.split('\n') file_paths.remove('') assert len(file_paths) > 0 # Determine root path of source code to be extracted # If all files and directories are stored in one top level directory, this directory is used as a root # If there is no single top level directory, new root directory will be created need_new_directory = False first_path_component = '' for path in file_paths: if os.sep not in path: need_new_directory = True break else: current_first_path_component = path[:path.find(os.sep)] if first_path_component: if first_path_component != current_first_path_component: need_new_directory = True break else: first_path_component = current_first_path_component work_path = self.source if need_new_directory: first_path_component = Path(filepath.name).stem work_path /= first_path_component extract_path = self.source / first_path_component if not extract_path.exists(): os.makedirs(work_path, exist_ok=True) # Extract source code package try: args = ('tar', '-xf', filepath) subprocess.run(args, check=True, cwd=work_path, env=self.environment) except (IOError, subprocess.CalledProcessError): shutil.rmtree(extract_path, ignore_errors=True) raise return first_path_component, extract_path def _apply_source_patch(self, extract_path: Path, patch: str): patch_path = self.patch_path / (patch + '.diff') assert patch_path.exists() args = ['patch', '--strip=1', '--input=' + str(patch_path)] # Check if patch is already applied dry_run_args = args + ['--dry-run', '--force'] dry_run = subprocess.run(dry_run_args, cwd=extract_path, env=self.environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if dry_run.returncode == 0: # Patch wasn't applied yet, do it now subprocess.run(args, check=True, cwd=extract_path, env=self.environment) def run_pkg_config(self, *args) -> str: os.makedirs(self.build_path, exist_ok=True) args = (self.bin_path / 'pkg-config',) + args result = subprocess.run(args, check=True, cwd=self.build_path, env=self.environment, stdout=subprocess.PIPE) return result.stdout.decode('utf-8').rstrip('\n') def has_source_file(self, path: typing.Union[str, Path]): return (self.source / path).exists() def update_flags_environment_variable(self, name: str, value: str): sdk_path = self.sdk_path() if sdk_path: value += f' -isysroot {sdk_path}' os_version = self.os_version() if os_version: value += f' -mmacosx-version-min={os_version}' env = self.environment env[name] = env[name] + ' ' + value if name in env else value def validate_minimum_version(self, version: str): minimum_version = StrictVersion(version) if os_version := self.os_version(): if os_version < minimum_version: raise RuntimeError('Minimum OS version requirement is not met') if sdk_version := self.sdk_version(): if sdk_version < minimum_version: raise RuntimeError('Minimum SDK version requirement is not met') def source_version(self): version = '' args = ('git', f'--git-dir={self.source}/.git', 'describe', '--tags') git_describe = subprocess.run(args, env=self.environment, capture_output=True) if git_describe.returncode == 0: version = git_describe.stdout.decode('ascii') return version