#!/usr/bin/env python3 # # Helper module to build macOS version of various source ports # Copyright (C) 2020 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 sys if sys.hexversion < 0x3070000: print('Build module requires Python 3.7 or newer') exit(1) import argparse import collections import hashlib import os import re import shutil import subprocess import typing import urllib.request import zipapp class CommandLineOptions(dict): # Rules to combine argument's name and value MAKE_RULES = 0 CMAKE_RULES = 1 def to_list(self, rules=MAKE_RULES) -> list: result = [] for arg_name, arg_value in self.items(): if rules == CommandLineOptions.MAKE_RULES: option = arg_name + ('=' + arg_value if arg_value else '') elif rules == CommandLineOptions.CMAKE_RULES: arg_value = arg_value if arg_value else '' option = f'-D{arg_name}={arg_value}' else: assert False, 'Unknown argument rules' result.append(option) return result class BaseTarget: def __init__(self, name=None): self.name = name def prepare_source(self, builder: 'Builder'): pass def initialize(self, builder: 'Builder'): pass def detect(self, builder: 'Builder') -> bool: return False def configure(self, builder: 'Builder'): pass def build(self, builder: 'Builder'): pass def post_build(self, builder: 'Builder'): pass class Target(BaseTarget): def __init__(self, name=None): super().__init__(name) self.src_root = '' self.prefix = None self.environment = os.environ self.options = CommandLineOptions() def initialize(self, builder: 'Builder'): self.prefix = builder.deps_path + self.name def configure(self, builder: 'Builder'): os.makedirs(builder.build_path, exist_ok=True) self.environment['PATH'] = self.environment['PATH'] \ + os.pathsep + '/Applications/CMake.app/Contents/bin' \ + os.pathsep + builder.bin_path for prefix in ('CPP', 'C', 'CXX', 'OBJC', 'OBJCXX'): varname = f'{prefix}FLAGS' self._update_env(varname, f'-I{builder.include_path}') self._set_sdk(builder, varname) self._set_os_version(builder, varname) ldflags = 'LDFLAGS' self._update_env(ldflags, f'-L{builder.lib_path}') self._set_sdk(builder, ldflags) self._set_os_version(builder, ldflags) def _update_env(self, name: str, value: str): env = self.environment env[name] = env[name] + ' ' + value if name in env else value def _set_sdk(self, builder: 'Builder', varname: str): if builder.sdk_path: self._update_env(varname, f'-isysroot {builder.sdk_path}') def _set_os_version(self, builder: 'Builder', varname: str): if builder.os_version: self._update_env(varname, f'-mmacosx-version-min={builder.os_version}') def install(self, builder: 'Builder', options: 'CommandLineOptions' = None, tool: str = 'make'): if builder.xcode: return if os.path.exists(self.prefix): shutil.rmtree(self.prefix) args = [tool, 'install'] args += options and options.to_list() or [] work_path = builder.build_path + self.src_root subprocess.check_call(args, cwd=work_path) self.update_pc_files(builder) @staticmethod def update_pc_file(path: str, processor: typing.Callable = None): with open(path, 'r') as f: content = f.readlines() patched_content = [] prefix = 'prefix=' for line in content: patched_line = line if line.startswith(prefix): # Clear prefix variable patched_line = prefix + os.linesep if processor: patched_line = processor(path, patched_line) if not patched_line: continue patched_content.append(patched_line) with open(path, 'w') as f: f.writelines(patched_content) def update_pc_files(self, builder: 'Builder'): for root, _, files in os.walk(builder.deps_path + self.name, followlinks=True): for filename in files: if filename.endswith('.pc'): file_path = root + os.sep + filename Target.update_pc_file(file_path, self._process_pkg_config) @staticmethod def _process_pkg_config(pcfile: str, line: str) -> str: assert pcfile return line class MakeTarget(Target): def __init__(self, name=None): super().__init__(name) self.makefile = 'Makefile' def configure(self, builder: 'Builder'): super().configure(builder) Builder.symlink_directory(builder.source_path, builder.build_path) def build(self, builder: 'Builder'): assert not builder.xcode args = [ 'make', '-f', self.makefile, '-j', builder.jobs, ] args += self.options.to_list() work_path = builder.build_path + self.src_root subprocess.check_call(args, cwd=work_path, env=self.environment) class ConfigureMakeTarget(Target): def __init__(self, name=None): super().__init__(name) self.make = MakeTarget(name) def initialize(self, builder: 'Builder'): super().initialize(builder) self.options['--prefix'] = self.prefix self.make.initialize(builder) def configure(self, builder: 'Builder'): super().configure(builder) self.make.configure(builder) work_path = builder.build_path + self.src_root args = [work_path + os.sep + 'configure'] args += self.options.to_list() subprocess.check_call(args, cwd=work_path, env=self.environment) def build(self, builder: 'Builder'): assert not builder.xcode self.make.build(builder) class ConfigureMakeDependencyTarget(ConfigureMakeTarget): def __init__(self, name=None): super().__init__(name) def post_build(self, builder: 'Builder'): self.install(builder) class ConfigureMakeStaticDependencyTarget(ConfigureMakeDependencyTarget): def __init__(self, name=None): super().__init__(name) self.options['--enable-shared'] = 'no' class CMakeTarget(Target): def __init__(self, name=None): super().__init__(name) def detect(self, builder: 'Builder') -> bool: src_root = self.src_root and os.sep + self.src_root or '' cmakelists_path = builder.source_path + src_root + os.sep + 'CMakeLists.txt' if not os.path.exists(cmakelists_path): return False for line in open(cmakelists_path).readlines(): project_name = CMakeTarget._extract_project_name(line) if project_name: project_name = project_name.lower() project_name = project_name.replace(' ', '-') break else: return False return project_name == self.name @staticmethod def _extract_project_name(line: str): project_name = None # Try to get project name without whitespaces in it match = re.search(r'project\s*\(\s*(\w[\w-]+)', line, re.IGNORECASE) if not match: # Try to get project name that contains whitespaces match = re.search(r'project\s*\(\s*"?(\w[\s\w-]+)"?', line, re.IGNORECASE) if match: project_name = match.group(1) return project_name def configure(self, builder: 'Builder'): super().configure(builder) args = [ 'cmake', builder.xcode and '-GXcode' or '-GUnix Makefiles', '-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_INSTALL_PREFIX=' + self.prefix, '-DCMAKE_PREFIX_PATH=' + builder.prefix_path, '-DCMAKE_OSX_DEPLOYMENT_TARGET=' + builder.os_version, ] if builder.sdk_path: args.append('-DCMAKE_OSX_SYSROOT=' + builder.sdk_path) args += self.options.to_list(CommandLineOptions.CMAKE_RULES) args.append(builder.source_path + self.src_root) subprocess.check_call(args, cwd=builder.build_path, env=self.environment) def _link_with_sound_libraries(self, builder: 'Builder'): extra_libs = ( 'mpg123', # FluidSynth with dependencies 'fluidsynth', 'instpatch-1.0', 'glib-2.0', 'gobject-2.0', 'intl', 'iconv', 'ffi', 'pcre', # Sndfile with dependencies 'sndfile', 'ogg', 'vorbis', 'vorbisenc', 'FLAC', 'opus', ) linker_args = '-framework AudioUnit -framework AudioToolbox -framework Carbon ' \ '-framework CoreAudio -framework CoreMIDI -framework CoreVideo' for lib in extra_libs: linker_args += f' {builder.lib_path}lib{lib}.a' self.options['CMAKE_EXE_LINKER_FLAGS'] = linker_args def build(self, builder: 'Builder'): if builder.xcode: # TODO: support case-sensitive file system args = ('open', self.name + '.xcodeproj') else: args = ['make', '-j', builder.jobs] if builder.verbose: args.append('VERBOSE=1') subprocess.check_call(args, cwd=builder.build_path) class CMakeStaticDependencyTarget(CMakeTarget): def __init__(self, name=None): super().__init__(name) # Set commonly used variables for static libraries opts = self.options opts['BUILD_SHARED_LIBS'] = 'NO' opts['ENABLE_SHARED'] = 'NO' opts['LIBTYPE'] = 'STATIC' def post_build(self, builder: 'Builder'): self.install(builder) class ZDoomBaseTarget(CMakeTarget): def __init__(self, name=None): super().__init__(name) def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) opts = self.options opts['PK3_QUIET_ZIPDIR'] = 'YES' opts['DYN_OPENAL'] = 'NO' # Explicit OpenAL configuration to avoid selection of Apple's framework opts['OPENAL_INCLUDE_DIR'] = builder.include_path + 'AL' opts['OPENAL_LIBRARY'] = builder.lib_path + 'libopenal.a' class GZDoomTarget(ZDoomBaseTarget): def __init__(self, name='gzdoom'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/coelckers/gzdoom.git') def post_build(self, builder: 'Builder'): # Put MoltenVK library into application bundle molten_lib = 'libMoltenVK.dylib' src_path = builder.lib_path + molten_lib dst_path = builder.build_path if builder.xcode: # TODO: Support other configurations dst_path += 'Debug' + os.sep dst_path += self.name + '.app/Contents/MacOS' + os.sep os.makedirs(dst_path, exist_ok=True) dst_path += molten_lib if not os.path.exists(dst_path): copy_func = builder.xcode and os.symlink or shutil.copy copy_func(src_path, dst_path) class QZDoomTarget(GZDoomTarget): def __init__(self, name='qzdoom'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/madame-rachelle/qzdoom.git') class LZDoomTarget(ZDoomBaseTarget): def __init__(self, name='lzdoom'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/drfrag666/gzdoom.git') def initialize(self, builder: 'Builder'): super().initialize(builder) opts = self.options opts['DYN_FLUIDSYNTH'] = 'NO' opts['DYN_MPG123'] = 'NO' opts['DYN_SNDFILE'] = 'NO' class RazeTarget(ZDoomBaseTarget): def __init__(self, name='raze'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/coelckers/Raze.git') class AccTarget(CMakeTarget): def __init__(self, name='acc'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/rheit/acc.git') class PrBoomPlusTarget(CMakeTarget): def __init__(self, name='prboom-plus'): super().__init__(name) self.src_root = 'prboom2' def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/coelckers/prboom-plus.git') def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) extra_linker_args = ' -framework ForceFeedback -framework IOKit' extra_libs = ( 'mikmod', 'modplug', 'opusfile', 'webp', ) for lib in extra_libs: extra_linker_args += f' {builder.lib_path}lib{lib}.a' opts = self.options opts['CMAKE_C_FLAGS'] = '-D_FILE_OFFSET_BITS=64' opts['CMAKE_EXE_LINKER_FLAGS'] += extra_linker_args opts['CMAKE_POLICY_DEFAULT_CMP0056'] = 'NEW' class ChocolateDoomTarget(CMakeTarget): def __init__(self, name='chocolate-doom'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/chocolate-doom/chocolate-doom.git') def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) extra_linker_args = ' -lc++ -framework Cocoa -framework ForceFeedback -framework IOKit' extra_libs = ( 'mikmod', 'modplug', 'opusfile', 'vorbisfile', ) for lib in extra_libs: extra_linker_args += f' {builder.lib_path}lib{lib}.a' sdl2_include_dir = builder.include_path + 'SDL2' opts = self.options opts['SDL2_INCLUDE_DIR'] = sdl2_include_dir opts['SDL2_LIBRARY'] = builder.lib_path + 'libSDL2.a' opts['SDL2_MIXER_INCLUDE_DIR'] = sdl2_include_dir opts['SDL2_MIXER_LIBRARY'] = builder.lib_path + 'libSDL2_mixer.a' opts['SDL2_NET_INCLUDE_DIR'] = sdl2_include_dir opts['SDL2_NET_LIBRARY'] = builder.lib_path + 'libSDL2_net.a' opts['CMAKE_EXE_LINKER_FLAGS'] += extra_linker_args class CrispyDoomTarget(ChocolateDoomTarget): def __init__(self, name='crispy-doom'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/fabiangreffrath/crispy-doom.git') class DoomRetroTarget(CMakeTarget): def __init__(self, name='doomretro'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/bradharding/doomretro.git') def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) extra_linker_args = ' -lc++ -framework Cocoa -framework ForceFeedback -framework IOKit' extra_libs = ( 'mikmod', 'modplug', 'opusfile', 'vorbisfile', 'webp' ) for lib in extra_libs: extra_linker_args += f' {builder.lib_path}lib{lib}.a' sdl2_include_dir = builder.include_path + 'SDL2' opts = self.options opts['SDL2_INCLUDE_DIRS'] = sdl2_include_dir opts['SDL2_LIBRARIES'] = builder.lib_path + 'libSDL2.a' opts['SDL2_FOUND'] = 'YES' opts['SDL2_IMAGE_INCLUDE_DIRS'] = sdl2_include_dir opts['SDL2_IMAGE_LIBRARIES'] = builder.lib_path + 'libSDL2_image.a' opts['SDL2_IMAGE_FOUND'] = 'YES' opts['SDL2_MIXER_INCLUDE_DIRS'] = sdl2_include_dir opts['SDL2_MIXER_LIBRARIES'] = builder.lib_path + 'libSDL2_mixer.a' opts['SDL2_MIXER_FOUND'] = 'YES' opts['CMAKE_EXE_LINKER_FLAGS'] += extra_linker_args class Doom64EXTarget(CMakeTarget): def __init__(self, name='doom64ex'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/svkaiser/Doom64EX.git') def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) opts = self.options opts['ENABLE_SYSTEM_FLUIDSYNTH'] = 'YES' opts['CMAKE_EXE_LINKER_FLAGS'] += ' -framework Cocoa -framework ForceFeedback -framework IOKit' class DevilutionXTarget(CMakeTarget): def __init__(self, name='devilutionx'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://github.com/diasurgical/devilutionX.git') def initialize(self, builder: 'Builder'): super().initialize(builder) self._link_with_sound_libraries(builder) extra_linker_args = ' -framework Cocoa -framework ForceFeedback -framework IOKit' extra_libs = ( 'bz2', 'freetype', 'mikmod', 'modplug', 'opusfile', 'png', 'vorbisfile', 'z', ) for lib in extra_libs: extra_linker_args += f' {builder.lib_path}lib{lib}.a' opts = self.options opts['CMAKE_EXE_LINKER_FLAGS'] += extra_linker_args class QuakespasmTarget(MakeTarget): def __init__(self, name='quakespasm'): super().__init__(name) self.src_root = 'Quake' def prepare_source(self, builder: 'Builder'): builder.checkout_git('https://git.code.sf.net/p/quakespasm/quakespasm') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + os.sep + 'Quakespasm.txt') def initialize(self, builder: 'Builder'): super().initialize(builder) # TODO: Use macOS specific Makefile which requires manual application bundle creation opts = self.options opts['USE_SDL2'] = '1' opts['USE_CODEC_FLAC'] = '1' opts['USE_CODEC_OPUS'] = '1' opts['USE_CODEC_MIKMOD'] = '1' opts['USE_CODEC_UMX'] = '1' # TODO: Setup sdl2-config opts['SDL_CFLAGS'] = f'-I{builder.include_path}SDL2' opts['SDL_LIBS'] = f'{builder.lib_path}libSDL2.a' opts['COMMON_LIBS'] = '-framework AudioToolbox -framework Carbon -framework Cocoa -framework CoreAudio' \ ' -framework CoreVideo -framework ForceFeedback -framework IOKit -framework OpenGL' self._update_env('CFLAGS', f'-I{builder.include_path}opus') # Use main() alias to workaround executable linking without macOS launcher self._update_env('LDFLAGS', f'-Wl,-alias -Wl,_SDL_main -Wl,_main') for name in ('opus', 'opusfile'): self._update_env('LDFLAGS', f'{builder.lib_path}lib{name}.a') # TODO: Specify full paths for remaining libraries def configure(self, builder: 'Builder'): super().configure(builder) # Copy linker flags from environment to command line argument, they would be overridden by Makefile otherwise ldflags = 'LDFLAGS' self.options[ldflags] = self.environment[ldflags] class Bzip2Target(MakeTarget): def __init__(self, name='bzip2'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://sourceware.org/pub/bzip2/bzip2-1.0.8.tar.gz', 'ab5a03176ee106d3f0fa90e381da478ddae405918153cca248e682cd0c4a2269') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'bzlib.h') def configure(self, builder: 'Builder'): super().configure(builder) # Copy compiler flags from environment to command line argument, they would be overridden by Makefile otherwise cflags = 'CFLAGS' self.options[cflags] = self.environment[cflags] + ' -D_FILE_OFFSET_BITS=64 -O2' def post_build(self, builder: 'Builder'): self.options['PREFIX'] = self.prefix self.install(builder, self.options) class FfiTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='ffi'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/libffi/libffi/releases/download/v3.3/libffi-3.3.tar.gz', '72fba7922703ddfa7a028d513ac15a85c8d54c8d67f55fa5a4802885dc652056') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'libffi.pc.in') class FlacTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='flac'): super().__init__(name) self.options['--enable-cpplibs'] = 'no' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://downloads.xiph.org/releases/flac/flac-1.3.3.tar.xz', '213e82bd716c9de6db2f98bcadbc4c24c7e2efe8c75939a1a84e28539c4e1748') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'FLAC/flac.pc.in') class FluidSynthTarget(CMakeStaticDependencyTarget): def __init__(self, name='fluidsynth'): super().__init__(name) opts = self.options opts['LIB_SUFFIX'] = None opts['enable-framework'] = 'NO' opts['enable-readline'] = 'NO' opts['enable-sdl2'] = 'NO' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/FluidSynth/fluidsynth/archive/v2.1.5.tar.gz', 'b539b7c65a650b56f01cd60a4e83c6125c217c5a63c0c214ef6274894a677d00') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'fluidsynth.pc.in') @staticmethod def _process_pkg_config(pcfile: str, line: str) -> str: if line.startswith('Version:'): # Add instpatch as private dependency which pulls all necessary libraries return line + 'Requires.private: libinstpatch-1.0' + os.linesep return line class GettextTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='gettext'): super().__init__(name) opts = self.options opts['--enable-csharp'] = 'no' opts['--enable-java'] = 'no' opts['--enable-libasprintf'] = 'no' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://ftp.gnu.org/gnu/gettext/gettext-0.21.tar.xz', 'd20fcbb537e02dcf1383197ba05bd0734ef7bf5db06bdb241eb69b7d16b73192') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'gettext-runtime') class GlibTarget(Target): def __init__(self, name='glib'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://download.gnome.org/sources/glib/2.66/glib-2.66.3.tar.xz', '79f31365a99cb1cc9db028625635d1438890702acde9e2802eae0acebcf7b5b1') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'glib.doap') def configure(self, builder: 'Builder'): super().configure(builder) environment = self.environment environment['LDFLAGS'] += ' -framework CoreFoundation' args = ( builder.bin_path + 'meson', '--prefix=' + builder.deps_path + self.name, '--buildtype=release', '--default-library=static', builder.source_path ) subprocess.check_call(args, cwd=builder.build_path, env=environment) def build(self, builder: 'Builder'): args = ('ninja',) subprocess.check_call(args, cwd=builder.build_path, env=self.environment) def post_build(self, builder: 'Builder'): self.install(builder, tool='ninja') class IconvTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='iconv'): super().__init__(name) self.options['--enable-extra-encodings'] = 'yes' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://ftp.gnu.org/gnu/libiconv/libiconv-1.16.tar.gz', 'e6a1b1b589654277ee790cce3734f07876ac4ccfaecbee8afa0b649cf529cc04') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'include/iconv.h.in') class InstPatchTarget(CMakeStaticDependencyTarget): def __init__(self, name='instpatch'): super().__init__(name) self.options['LIB_SUFFIX'] = None # Workaround for missing frameworks in dependencies, no clue what's wrong at the moment self.environment['LDFLAGS'] = '-framework CoreFoundation -framework Foundation' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/swami/libinstpatch/archive/v1.1.5.tar.gz', '5fd01cd2ba7377e7a72caaf3b565d8fe088b5c8a14e0ea91516f0c87524bcf8a') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'libinstpatch-1.0.pc.in') class IntlTarget(GettextTarget): def __init__(self, name='intl'): super().__init__(name) self.src_root = 'gettext-runtime' self.make.src_root += self.src_root + os.sep + 'intl' def post_build(self, builder: 'Builder'): # Do install of intl only, avoid complete gettext runtime self.src_root = self.make.src_root self.install(builder) class JpegTurboTarget(CMakeStaticDependencyTarget): def __init__(self, name='jpeg-turbo'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://downloads.sourceforge.net/project/libjpeg-turbo/2.0.6/libjpeg-turbo-2.0.6.tar.gz', 'd74b92ac33b0e3657123ddcf6728788c90dc84dcb6a52013d758af3c4af481bb') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'turbojpeg.h') class MesonTarget(Target): def __init__(self, name='meson'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/mesonbuild/meson/releases/download/0.56.0/meson-0.56.0.tar.gz', '291dd38ff1cd55fcfca8fc985181dd39be0d3e5826e5f0013bf867be40117213') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'meson.py') def post_build(self, builder: 'Builder'): script = '__main__.py' shutil.copy(builder.source_path + script, builder.build_path) module = 'mesonbuild' module_path = builder.build_path + module if os.path.exists(module_path): shutil.rmtree(module_path) shutil.copytree(builder.source_path + module, module_path) dest_path = builder.deps_path + self.name + os.sep + 'bin' + os.sep os.makedirs(dest_path, exist_ok=True) zipapp.create_archive(builder.build_path, dest_path + self.name, '/usr/bin/env python3', compressed=True) class MoltenVKTarget(MakeTarget): def __init__(self, name='moltenvk'): super().__init__(name) self.options['macos'] = None def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/KhronosGroup/MoltenVK/archive/v1.1.1.tar.gz', 'cd1712c571d4155f4143c435c8551a5cb8cbb311ad7fff03595322ab971682c0') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'MoltenVKPackaging.xcodeproj') def configure(self, builder: 'Builder'): # Do not use specified macOS deployment target and SDK # MoltenVK defines minimal OS version itself, and usually, it requires the very recent SDK builder.os_version = None builder.sdk_path = None super().configure(builder) def build(self, builder: 'Builder'): args = ('./fetchDependencies', '--macos', '-v') subprocess.check_call(args, cwd=builder.build_path) super().build(builder) def post_build(self, builder: 'Builder'): if builder.xcode: return if os.path.exists(self.prefix): shutil.rmtree(self.prefix) lib_path = self.prefix + os.sep + 'lib' + os.sep os.makedirs(lib_path) src_path = builder.build_path + 'Package/Latest/MoltenVK/' shutil.copytree(src_path + 'include', self.prefix + os.sep + 'include') shutil.copy(builder.build_path + 'LICENSE', self.prefix + os.sep + 'apache2.txt') # TODO: Replace lipo with the following line when ARM64 support will be ready # shutil.copy(src_path + 'dylib/macOS/libMoltenVK.dylib', lib_path) dylib_name = 'libMoltenVK.dylib' args = ( 'lipo', f'{src_path}dylib/macOS/{dylib_name}', '-thin', 'x86_64', '-output', lib_path + dylib_name ) subprocess.check_call(args) class Mpg123Target(ConfigureMakeStaticDependencyTarget): def __init__(self, name='mpg123'): super().__init__(name) self.options['--enable-modules'] = 'no' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://www.mpg123.de/download/mpg123-1.26.3.tar.bz2', '30c998785a898f2846deefc4d17d6e4683a5a550b7eacf6ea506e30a7a736c6e') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'libmpg123.pc.in') class NasmTarget(ConfigureMakeDependencyTarget): def __init__(self, name='nasm'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/nasm-2.15.05.tar.xz', '3caf6729c1073bf96629b57cee31eeb54f4f8129b01902c73428836550b30a3f') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'nasm.txt') class NinjaTarget(MakeTarget): def __init__(self, name='ninja'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/ninja-build/ninja/archive/v1.10.2.tar.gz', 'ce35865411f0490368a8fc383f29071de6690cbadc27704734978221f25e2bed') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'src/ninja.cc') def build(self, builder: 'Builder'): args = ('python3', './configure.py', '--bootstrap', '--verbose') subprocess.check_call(args, cwd=builder.build_path) def post_build(self, builder: 'Builder'): dest_path = builder.deps_path + self.name + os.sep + 'bin' os.makedirs(dest_path, exist_ok=True) shutil.copy(builder.build_path + self.name, dest_path) class OggTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='ogg'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://downloads.xiph.org/releases/ogg/libogg-1.3.4.tar.gz', 'fe5670640bd49e828d64d2879c31cb4dde9758681bb664f9bdbf159a01b0c76e') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'ogg.pc.in') class OpenALTarget(CMakeStaticDependencyTarget): def __init__(self, name='openal'): super().__init__(name) opts = self.options opts['ALSOFT_EXAMPLES'] = 'NO' opts['ALSOFT_UTILS'] = 'NO' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://openal-soft.org/openal-releases/openal-soft-1.21.0.tar.bz2', '2916b4fc24e23b0271ce0b3468832ad8b6d8441b1830215b28cc4fee6cc89297') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'openal.pc.in') @staticmethod def _process_pkg_config(pcfile: str, line: str) -> str: libs_private = 'Libs.private:' if line.startswith(libs_private): # Fix full paths to OS frameworks return libs_private + ' -framework ApplicationServices -framework AudioToolbox'\ ' -framework AudioUnit -framework CoreAudio' + os.linesep else: return line class OpusTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='opus'): super().__init__(name) self.options['--disable-extra-programs'] = None def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://archive.mozilla.org/pub/opus/opus-1.3.1.tar.gz', '65b58e1e25b2a114157014736a3d9dfeaad8d41be1c8179866f144a2fb44ff9d') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'opus.pc.in') class OpusFileTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='opusfile'): super().__init__(name) self.options['--enable-http'] = 'no' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://downloads.xiph.org/releases/opus/opusfile-0.12.tar.gz', '118d8601c12dd6a44f52423e68ca9083cc9f2bfe72da7a8c1acb22a80ae3550b') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'opusfile.pc.in') class PcreTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='pcre'): super().__init__(name) opts = self.options opts['--enable-unicode-properties'] = 'yes' opts['--enable-cpp'] = 'no' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://ftp.pcre.org/pub/pcre/pcre-8.44.tar.bz2', '19108658b23b3ec5058edc9f66ac545ea19f9537234be1ec62b714c84399366d') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'pcre.h.in') class PkgConfigTarget(ConfigureMakeDependencyTarget): def __init__(self, name='pkg-config'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://pkg-config.freedesktop.org/releases/pkg-config-0.29.2.tar.gz', '6fc69c01688c9458a57eb9a1664c9aba372ccda420a02bf4429fe610e7e7d591') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'pkg-config.1') def post_build(self, builder: 'Builder'): src_path = builder.build_path + 'pkg-config' dst_path = builder.deps_path + self.name + os.sep + 'bin' + os.sep + 'pkg-config.exe' shutil.copy(src_path, dst_path) class SndFileTarget(CMakeStaticDependencyTarget): def __init__(self, name='sndfile'): super().__init__(name) opts = self.options opts['BUILD_REGTEST'] = 'NO' opts['BUILD_TESTING'] = 'NO' def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/libsndfile/libsndfile/releases/download/v1.0.30/libsndfile-1.0.30.tar.bz2', '9df273302c4fa160567f412e10cc4f76666b66281e7ba48370fb544e87e4611a') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'sndfile.pc.in') class VorbisTarget(ConfigureMakeStaticDependencyTarget): def __init__(self, name='vorbis'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://downloads.xiph.org/releases/vorbis/libvorbis-1.3.7.tar.xz', 'b33cc4934322bcbf6efcbacf49e3ca01aadbea4114ec9589d1b1e9d20f72954b') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'vorbis.pc.in') class VpxTarget(ConfigureMakeDependencyTarget): def __init__(self, name='vpx'): super().__init__(name) opts = self.options opts['--disable-examples'] = None opts['--disable-unit-tests'] = None def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://github.com/webmproject/libvpx/archive/v1.9.0.tar.gz', 'd279c10e4b9316bf11a570ba16c3d55791e1ad6faa4404c67422eb631782c80a') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'vpxstats.h') class YasmTarget(ConfigureMakeDependencyTarget): def __init__(self, name='yasm'): super().__init__(name) def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://www.tortall.net/projects/yasm/releases/yasm-1.3.0.tar.gz', '3dce6601b495f5b3d45b59f7d2492a340ee7e84b5beca17e48f862502bd5603f') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'libyasm.h') class ZlibTarget(ConfigureMakeDependencyTarget): def __init__(self, name='zlib'): super().__init__(name) self.options['--static'] = None def prepare_source(self, builder: 'Builder'): builder.download_source( 'https://zlib.net/zlib-1.2.11.tar.gz', 'c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1') def detect(self, builder: 'Builder') -> bool: return os.path.exists(builder.source_path + 'zlib.pc.in') class CleanTarget(BaseTarget): def __init__(self, name=None): super().__init__(name) self.args = () def build(self, builder: 'Builder'): args = ('git', 'clean') + self.args subprocess.check_call(args, cwd=builder.root_path) class CleanAllTarget(CleanTarget): def __init__(self, name='clean-all'): super().__init__(name) self.args = ('-dX', '--force') class CleanDepsTarget(CleanAllTarget): def __init__(self, name='clean-deps'): super().__init__(name) def configure(self, builder: 'Builder'): self.args += (builder.deps_path,) # Case insensitive dictionary class from # https://github.com/psf/requests/blob/v2.25.0/requests/structures.py class CaseInsensitiveDict(collections.abc.MutableMapping): """A case-insensitive ``dict``-like object. Implements all methods and operations of ``MutableMapping`` as well as dict's ``copy``. Also provides ``lower_items``. All keys are expected to be strings. The structure remembers the case of the last key to be set, and ``iter(instance)``, ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` will contain case-sensitive keys. However, querying and contains testing is case insensitive:: cid = CaseInsensitiveDict() cid['Accept'] = 'application/json' cid['aCCEPT'] == 'application/json' # True list(cid) == ['Accept'] # True For example, ``headers['content-encoding']`` will return the value of a ``'Content-Encoding'`` response header, regardless of how the header name was originally stored. If the constructor, ``.update``, or equality comparison operations are given keys that have equal ``.lower()``s, the behavior is undefined. """ def __init__(self, data=None, **kwargs): self._store = collections.OrderedDict() if data is None: data = {} self.update(data, **kwargs) def __setitem__(self, key, value): # Use the lowercased key for lookups, but store the actual # key alongside the value. self._store[key.lower()] = (key, value) def __getitem__(self, key): return self._store[key.lower()][1] def __delitem__(self, key): del self._store[key.lower()] def __iter__(self): return (casedkey for casedkey, mappedvalue in self._store.values()) def __len__(self): return len(self._store) def lower_items(self): """Like iteritems(), but with all lowercase keys.""" return ( (lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items() ) def __eq__(self, other): if isinstance(other, collections.abc.Mapping): other = CaseInsensitiveDict(other) else: return NotImplemented # Compare insensitively return dict(self.lower_items()) == dict(other.lower_items()) # Copy is required def copy(self): return CaseInsensitiveDict(self._store.values()) def __repr__(self): return str(dict(self.items())) class Builder(object): def __init__(self, args: list): self._create_targets() self.root_path = os.path.dirname(os.path.abspath(__file__)) + os.sep self.deps_path = self.root_path + 'deps' + os.sep self.prefix_path = self.root_path + 'prefix' + os.sep self.bin_path = self.prefix_path + 'bin' + os.sep self.include_path = self.prefix_path + 'include' + os.sep self.lib_path = self.prefix_path + 'lib' + os.sep self.root_source_path = self.root_path + 'source' + os.sep self.patch_path = self.root_path + 'patch' + os.sep arguments = self._parse_arguments(args) self.xcode = arguments.xcode self.checkout_commit = arguments.checkout_commit self.build_path = arguments.build_path self.sdk_path = arguments.sdk_path self.os_version = arguments.os_version self.verbose = arguments.verbose if arguments.target: self.target = self.targets[arguments.target] self.source_path = self.root_source_path + self.target.name else: assert arguments.source_path self.source_path = arguments.source_path self._detect_target() if not self.build_path: self.build_path = self.root_path + 'build' + os.sep + self.target.name + \ os.sep + (self.xcode and 'xcode' or 'make') self.source_path += os.sep self.build_path += os.sep self.jobs = arguments.jobs and arguments.jobs or \ subprocess.check_output(['sysctl', '-n', 'hw.ncpu']).decode('ascii').strip() if not self.sdk_path: sdk_probe_path = f'{self.root_path}sdk{os.sep}MacOSX{self.os_version}.sdk' if os.path.exists(sdk_probe_path): self.sdk_path = sdk_probe_path self.target.initialize(self) def run(self): self._create_prefix_directory() target = self.target target.prepare_source(self) target.configure(self) target.build(self) target.post_build(self) def _create_prefix_directory(self): os.makedirs(self.prefix_path, exist_ok=True) cleanup = True for dep in os.scandir(self.deps_path): Builder.symlink_directory(dep.path, self.prefix_path, cleanup) # Do symlink cleanup only once cleanup = False @staticmethod def symlink_directory(src_path: str, dst_path: str, cleanup=True): src_abspath = os.path.abspath(src_path) dst_abspath = os.path.abspath(dst_path) if cleanup: # Delete obsolete symbolic links for root, _, files in os.walk(dst_abspath, followlinks=True): for filename in files: file_path = root + os.sep + filename if os.path.islink(file_path) and not os.path.exists(file_path): os.remove(file_path) # Create symbolic links if needed for entry in os.scandir(src_abspath): dst_subpath = entry.path.replace(src_abspath, dst_abspath) if entry.is_dir(): os.makedirs(dst_subpath, exist_ok=True) Builder.symlink_directory(entry.path, dst_subpath, cleanup=False) elif not os.path.exists(dst_subpath): if os.path.islink(entry.path): shutil.copy(entry.path, dst_subpath, follow_symlinks=False) else: os.symlink(entry.path, dst_subpath) def _detect_target(self): for name, target in self.targets.items(): if target.detect(self): self.target = self.targets[name] break assert self.target def _create_targets(self): targets = ( GZDoomTarget(), QZDoomTarget(), LZDoomTarget(), RazeTarget(), AccTarget(), PrBoomPlusTarget(), ChocolateDoomTarget(), CrispyDoomTarget(), DoomRetroTarget(), Doom64EXTarget(), DevilutionXTarget(), QuakespasmTarget(), # Dependencies Bzip2Target(), FfiTarget(), FlacTarget(), FluidSynthTarget(), GlibTarget(), IconvTarget(), InstPatchTarget(), IntlTarget(), JpegTurboTarget(), MesonTarget(), MoltenVKTarget(), Mpg123Target(), NasmTarget(), NinjaTarget(), OggTarget(), OpenALTarget(), OpusTarget(), OpusFileTarget(), PcreTarget(), PkgConfigTarget(), SndFileTarget(), VorbisTarget(), VpxTarget(), YasmTarget(), ZlibTarget(), # Special CleanAllTarget(), CleanDepsTarget() ) self.targets = CaseInsensitiveDict({target.name: target for target in targets}) def _parse_arguments(self, args: list): assert self.targets parser = argparse.ArgumentParser(description='*ZDoom binary dependencies for macOS') group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--target', choices=self.targets.keys(), help='target to build') group.add_argument('--source-path', metavar='path', help='path to target\'s source code') group = parser.add_argument_group() group.add_argument('--xcode', action='store_true', help='generate Xcode project instead of build') group.add_argument('--checkout-commit', metavar='commit', help='target\'s source code commit or tag to checkout') group.add_argument('--build-path', metavar='path', help='target build path') group.add_argument('--sdk-path', metavar='path', help='path to macOS SDK') group.add_argument('--os-version', metavar='version', default='10.9', help='macOS deployment version') group.add_argument('--verbose', action='store_true', help='enable verbose build output') group.add_argument('--jobs', help='number of parallel compilation jobs') return parser.parse_args(args) def checkout_git(self, url: str): if not os.path.exists(self.source_path): args = ('git', 'clone', '--recurse-submodules', url, self.source_path) subprocess.check_call(args, cwd=self.root_path) if self.checkout_commit: args = ['git', 'checkout', self.checkout_commit] subprocess.check_call(args, cwd=self.source_path) def _read_source_package(self, url: str) -> (bytes, str): filename = url.rsplit(os.sep, 1)[1] filepath = self.source_path + filename if os.path.exists(filepath): # 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: str) -> None: file_hasher = hashlib.sha256() file_hasher.update(data) file_checksum = file_hasher.hexdigest() if file_checksum != checksum: os.unlink(filepath) raise Exception(f'Checksum of {filepath} does not match, expected: {checksum}, actual: {file_checksum}') def _unpack_source_package(self, filepath: str) -> (str, str): filepaths = subprocess.check_output(['tar', '-tf', filepath]).decode("utf-8") filepaths = filepaths.split('\n') first_path_component = None for path in filepaths: if os.sep in path: first_path_component = path[:path.find(os.sep)] break if not first_path_component: raise Exception("Failed to figure out source code path for " + filepath) extract_path = self.source_path + first_path_component + os.sep if not os.path.exists(extract_path): # Extract source code package try: subprocess.check_call(['tar', '-xf', filepath], cwd=self.source_path) 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: str): patch_path = self.patch_path + self.target.name + '.patch' if not os.path.exists(patch_path): return # Check if patch is already applied test_arg = '--dry-run' args = ['patch', test_arg, '--strip=1', '--input=' + patch_path] if subprocess.call(args, cwd=extract_path) == 0: # Patch wasn't applied yet, do it now args.remove(test_arg) subprocess.check_call(args, cwd=extract_path) def download_source(self, url: str, checksum: str): os.makedirs(self.source_path, exist_ok=True) data, filepath = self._read_source_package(url) Builder._verify_checksum(checksum, data, filepath) first_path_component, extract_path = self._unpack_source_package(filepath) self._apply_source_patch(extract_path) # Adjust source and build paths according to extracted source code self.source_path = extract_path self.build_path = self.build_path + first_path_component + os.sep if __name__ == '__main__': Builder(sys.argv[1:]).run()