shithub: candycrisis

ref: ceb09f7ffd02027f1f1be6242b9190f15398bcc2
dir: /build.py/

View raw version
#!/usr/bin/env python3

import argparse
import contextlib
import hashlib
import glob
import multiprocessing
import os
import os.path
import platform
import shutil
import stat
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from pathlib import Path

#----------------------------------------------------------------

libs_dir = os.path.abspath("extern")
cache_dir = os.path.abspath("cache")
dist_dir = os.path.abspath("dist")

game_name           = "CandyCrisis"  # no spaces
game_name_human     = "Candy Crisis"  # spaces and other special characters allowed
game_ver            = "3.0.0"

sdl_ver             = "2.0.20"
appimagetool_ver    = "13"

lib_hashes = {  # sha-256
    "SDL2-2.0.20.tar.gz":           "c56aba1d7b5b0e7e999e4a7698c70b63a3394ff9704b5f6e1c57e0c16f04dd06",
    "SDL2-2.0.20.dmg":              "e46a3694f5008c4c5ffd33e1dfdffbee64179ad15088781f2f70806dd0102d4d",
    "SDL2-devel-2.0.20-VC.zip":     "5b1512ca6c9d2427bd2147da01e5e954241f8231df12f54a7074dccde416df18",
    "appimagetool-x86_64.AppImage": "df3baf5ca5facbecfc2f3fa6713c29ab9cefa8fd8c1eac5d283b79cab33e4acb", # appimagetool v13
}

NPROC = multiprocessing.cpu_count()
SYSTEM = platform.system()

if SYSTEM == "Windows":
    os.system("")  # hack to get ANSI color escapes to work

#----------------------------------------------------------------

parser = argparse.ArgumentParser(description=F"Configure, build, and package {game_name_human}")

if SYSTEM == "Darwin":
    default_generator = "Xcode"
    default_architecture = None
    help_configure = "generate Xcode project"
    help_build = "build app from Xcode project"
    help_package = "package up the game into a DMG"
elif SYSTEM == "Windows":
    default_generator = "Visual Studio 17 2022"
    default_architecture = "x64"
    help_configure = F"generate {default_generator} solution"
    help_build = F"build exe from {default_generator} solution"
    help_package = "package up the game into a ZIP"
else:
    default_generator = None
    default_architecture = None
    help_configure = "generate project"
    help_build = "build binary"
    help_package = "package up the game into an AppImage"

parser.add_argument("--dependencies", default=False, action="store_true", help="fetch and set up dependencies (SDL)")
parser.add_argument("--configure", default=False, action="store_true", help=help_configure)
parser.add_argument("--build", default=False, action="store_true", help=help_build)
parser.add_argument("--package", default=False, action="store_true", help=help_package)

parser.add_argument("-G", metavar="<generator>", default=default_generator,
        help=F"custom project generator for the CMake configure step (default: {default_generator})")

parser.add_argument("-A", metavar="<arch>", default=default_architecture,
        help=F"custom platform name for the CMake configure step (default: {default_architecture})")

parser.add_argument("--print-artifact-name", default=False, action="store_true",
        help="print artifact name and exit")

if SYSTEM == "Linux":
    parser.add_argument("--system-sdl", default=False, action="store_true",
        help="use system SDL instead of building SDL from scratch")

args = parser.parse_args()

#----------------------------------------------------------------

class Project:
    def __init__(self, dir_name, gen_args=[], gen_env={}, build_configs=[], build_args=[]):
        self.dir_name = dir_name
        self.gen_args = gen_args
        self.gen_env = gen_env
        self.build_configs = build_configs
        self.build_args = build_args

#----------------------------------------------------------------

@contextlib.contextmanager
def chdir(path):
    origin = os.getcwd()
    try:
        os.chdir(path)
        yield
    finally:
        os.chdir(origin)

def die(message):
    print(F"\x1b[1;31m{message}\x1b[0m", file=sys.stderr)
    sys.exit(1)

def log(message):
    print(message, file=sys.stderr)

def fatlog(message):
    starbar = len(message) * '*'
    print(F"\n{starbar}\n{message}\n{starbar}", file=sys.stderr)

def hash_file(path):
    hasher = hashlib.sha256()
    with open(path, 'rb') as f:
        while True:
            chunk = f.read(64*1024)
            if not chunk:
                break
            hasher.update(chunk)
    return hasher.hexdigest()

def get_package(url):
    name = url[url.rfind('/')+1:]

    if name in lib_hashes:
        reference_hash = lib_hashes[name]
    else:
        die(F"Build script lacks reference checksum for {name}")

    path = os.path.normpath(F"{cache_dir}/{name}")
    if os.path.exists(path):
        log(F"Not redownloading: {path}")
    else:
        log(F"Downloading: {url}")
        os.makedirs(cache_dir, exist_ok=True)
        urllib.request.urlretrieve(url, path)

    actual_hash = hash_file(path)
    if reference_hash != actual_hash:
        die(F"Bad checksum for {name}: expected {reference_hash}, got {actual_hash}")

    return path

def call(cmd, **kwargs):
    cmdstr = ""
    for token in cmd:
        cmdstr += " "
        if " " in token:
            cmdstr += F"\"{token}\""
        else:
            cmdstr += token

    log(F">{cmdstr}")
    try:
        return subprocess.run(cmd, check=True, **kwargs)
    except subprocess.CalledProcessError as e:
        die(F"Aborting setup because: {e}")

def rmtree_if_exists(path):
    if os.path.exists(path):
        shutil.rmtree(path)

def zipdir(zipname, topleveldir, arc_topleveldir):
    with zipfile.ZipFile(zipname, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as zipf:
        for root, dirs, files in os.walk(topleveldir):
            for file in files:
                filepath = os.path.join(root, file)
                arcpath = os.path.join(arc_topleveldir, filepath[len(topleveldir)+1:])
                log(F"Zipping: {filepath} --> {arcpath}")
                zipf.write(filepath, arcpath)

#----------------------------------------------------------------

def prepare_dependencies_windows():
    rmtree_if_exists(F"{libs_dir}/SDL2-{sdl_ver}")

    sdl_zip_path = get_package(F"http://libsdl.org/release/SDL2-devel-{sdl_ver}-VC.zip")
    shutil.unpack_archive(sdl_zip_path, libs_dir)

def prepare_dependencies_macos():
    sdl2_framework = "SDL2.framework"
    sdl2_framework_target_path = F"{libs_dir}/{sdl2_framework}"

    rmtree_if_exists(sdl2_framework_target_path)

    sdl_dmg_path = get_package(F"http://libsdl.org/release/SDL2-{sdl_ver}.dmg")

    # Mount the DMG and copy SDL2.framework to extern/
    with tempfile.TemporaryDirectory() as mount_point:
        call(["hdiutil", "attach", sdl_dmg_path, "-mountpoint", mount_point, "-quiet"])
        shutil.copytree(F"{mount_point}/{sdl2_framework}", sdl2_framework_target_path, symlinks=True)
        call(["hdiutil", "detach", mount_point, "-quiet"])

    if "CODE_SIGN_IDENTITY" in os.environ:
        call(["codesign", "--force", "--timestamp", "--sign", os.environ["CODE_SIGN_IDENTITY"], sdl2_framework_target_path])
    else:
        print("SDL will not be codesigned. Set the CODE_SIGN_IDENTITY environment variable if you want to sign it.")

def prepare_dependencies_linux():
    if not args.system_sdl:
        sdl_source_dir = F"{libs_dir}/SDL2-{sdl_ver}"
        sdl_build_dir = F"{sdl_source_dir}/build"
        rmtree_if_exists(sdl_source_dir)

        sdl_zip_path = get_package(F"http://libsdl.org/release/SDL2-{sdl_ver}.tar.gz")
        shutil.unpack_archive(sdl_zip_path, libs_dir)

        with chdir(sdl_source_dir):
            call([F"{sdl_source_dir}/configure", F"--prefix={sdl_build_dir}", "--quiet"])
            call(["make", "-j", str(NPROC)], stdout=subprocess.DEVNULL)
            call(["make", "install", "--silent"])  # install to configured prefix (sdl_build_dir)

#----------------------------------------------------------------

def get_artifact_name():
    if SYSTEM == "Windows":
        return F"{game_name}-{game_ver}-windows-x64.zip"
    elif SYSTEM == "Darwin":
        return F"{game_name}-{game_ver}-mac.dmg"
    elif SYSTEM == "Linux":
        return F"{game_name}-{game_ver}-linux-x86_64.AppImage"
    else:
        die("Unknown system for print_artifact_name")

def copy_documentation(proj, appdir, full=True):
    #shutil.copy(F"{proj.dir_name}/ReadMe.txt", F"{appdir}")
    #shutil.copy(F"LICENSE.md", F"{appdir}/License.txt")

    if full:
        pass
        #shutil.copytree("docs", F"{appdir}/Documentation")
        # os.remove(F"{appdir}/Documentation/logo.png")
        #os.remove(F"{appdir}/Documentation/screenshot.png")
        #os.remove(F"{appdir}/Documentation/screenshot2.png")
        #for docfile in ["CHANGELOG.md"]:
        #    shutil.copy(docfile, F"{appdir}/Documentation")

def package_windows(proj):
    windows_dlls = ["SDL2.dll", "msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll"]  # C++

    # Prep DLLs with cmake (copied to {cache_dir}/install/bin)
    call(["cmake", "--install", proj.dir_name, "--prefix", F"{cache_dir}/install"])

    appdir = F"{cache_dir}/{game_name}-{game_ver}"
    rmtree_if_exists(appdir)
    os.makedirs(F"{appdir}", exist_ok=True)

    # Copy executable, libs and assets
    for dll in windows_dlls:
        shutil.copy(F"{cache_dir}/install/bin/{dll}", appdir)
    shutil.copy(F"{proj.dir_name}/Release/{game_name}.exe", appdir)
    shutil.copytree("CandyCrisisResources", F"{appdir}/CandyCrisisResources")

    copy_documentation(proj, appdir)

    zipdir(F"{dist_dir}/{get_artifact_name()}", appdir, F"{game_name}-{game_ver}")

def package_macos(proj):
    appdir = F"{proj.dir_name}/Release"

    # Human-friendly name for .app
    os.rename(F"{appdir}/{game_name}.app", F"{appdir}/{game_name_human}.app")

    copy_documentation(proj, appdir)

    #shutil.copy("packaging/dmg_DS_Store", F"{appdir}/.DS_Store")

    call(["hdiutil", "create",
        "-fs", "HFS+",
        "-srcfolder", appdir,
        "-volname", F"{game_name_human} {game_ver}",
        F"{dist_dir}/{get_artifact_name()}"])

def package_linux(proj):
    appimagetool_path = get_package(F"https://github.com/AppImage/AppImageKit/releases/download/{appimagetool_ver}/appimagetool-x86_64.AppImage")
    os.chmod(appimagetool_path, 0o755)

    appdir = F"{cache_dir}/{game_name}-{game_ver}.AppDir"
    rmtree_if_exists(appdir)

    os.makedirs(F"{appdir}", exist_ok=True)
    os.makedirs(F"{appdir}/usr/bin", exist_ok=True)
    os.makedirs(F"{appdir}/usr/lib", exist_ok=True)

    # Copy executable and assets
    shutil.copy(F"{proj.dir_name}/{game_name}", F"{appdir}/usr/bin")  # executable
    shutil.copytree("CandyCrisisResources", F"{appdir}/CandyCrisisResources")
    copy_documentation(proj, appdir, full=False)

    # Copy XDG stuff
    shutil.copy(F"packaging/{game_name.lower()}.desktop", appdir)
    shutil.copy(F"packaging/{game_name.lower()}-desktopicon.png", appdir)

    # Copy AppImage kicker script
    shutil.copy(F"packaging/AppRun", appdir)
    os.chmod(F"{appdir}/AppRun", 0o755)

    # Copy SDL (if not using system SDL)
    if not args.system_sdl:
        for file in glob.glob(F"{libs_dir}/SDL2-{sdl_ver}/build/lib/libSDL2*.so*"):
            shutil.copy(file, F"{appdir}/usr/lib", follow_symlinks=False)

    # Invoke appimagetool
    call([appimagetool_path, "--no-appstream", appdir, F"{dist_dir}/{get_artifact_name()}"])

#----------------------------------------------------------------

if args.print_artifact_name:
    print(get_artifact_name())
    sys.exit(0)

fatlog(F"{game_name} {game_ver} build script")

if not (args.dependencies or args.configure or args.build or args.package):
    log("No build steps specified, running all of them.")
    args.dependencies = True
    args.configure = True
    args.build = True
    args.package = True

# Make sure we're running from the correct directory...
if not os.path.exists("src/graymonitor.cpp"):  # some file that's likely to be from the game's source tree
    die(F"STOP - Please run this script from the root of the {game_name} source repo")

#----------------------------------------------------------------
# Set up project metadata

projects = []

common_gen_args = []
if args.G:
    common_gen_args += ["-G", args.G]
if args.A:
    common_gen_args += ["-A", args.A]

if SYSTEM == "Windows":

    projects = [Project(
        dir_name="build-msvc",
        gen_args=common_gen_args,
        build_configs=["Release", "Debug"],
        build_args=["-m"]  # multiprocessor compilation
    )]

elif SYSTEM == "Darwin":
    projects = [Project(
        dir_name="build-xcode",
        gen_args=common_gen_args,
        build_configs=["Release"],
        build_args=["-j", str(NPROC)]
    )]

elif SYSTEM == "Linux":
    gen_env = {}
    if not args.system_sdl:
        gen_env["SDL2DIR"] = F"{libs_dir}/SDL2-{sdl_ver}/build"

    projects.append(Project(
        dir_name="build-relwithdebinfo",
        gen_args=common_gen_args + ["-DCMAKE_BUILD_TYPE=RelWithDebInfo"],
        gen_env=gen_env,
        build_args=["-j", str(NPROC)]
    ))

    projects.append(Project(
        dir_name="build-debug",
        gen_args=common_gen_args + ["-DCMAKE_BUILD_TYPE=Debug"],
        gen_env=gen_env,
        build_args=["-j", str(NPROC)]
    ))
else:
    die(F"Unsupported system for configure step: {SYSTEM}")


#----------------------------------------------------------------
# Prepare dependencies

if args.dependencies:
    fatlog("Setting up dependencies")

    # Check that our submodules are here
    #if not os.path.exists("extern/Pomme/CMakeLists.txt"):
    #    die("Submodules appear to be missing.\n"
    #        + "Did you clone the submodules recursively? Try this:    git submodule update --init --recursive")

    if SYSTEM == "Windows":
        prepare_dependencies_windows()
    elif SYSTEM == "Darwin":
        prepare_dependencies_macos()
    elif SYSTEM == "Linux":
        prepare_dependencies_linux()
    else:
        die(F"Unsupported system for dependencies step: {SYSTEM}")

#----------------------------------------------------------------
# Configure projects

if args.configure:
    for proj in projects:
        fatlog(F"Configuring {proj.dir_name}")

        rmtree_if_exists(proj.dir_name)

        env = None
        if proj.gen_env:
            env = os.environ.copy()
            env.update(proj.gen_env)

        call(["cmake", "-S", ".", "-B", proj.dir_name] + proj.gen_args, env=env)

#----------------------------------------------------------------
# Build the game

proj = projects[0]

if args.build:
    fatlog(F"Building the game: {proj.dir_name}")

    build_command = ["cmake", "--build", proj.dir_name]

    if proj.build_configs:
        build_command += ["--config", proj.build_configs[0]]

    if proj.build_args:
        build_command += ["--"] + proj.build_args

    call(build_command)

#----------------------------------------------------------------
# Package the game

if args.package:
    fatlog(F"Packaging the game")

    rmtree_if_exists(dist_dir)
    os.makedirs(dist_dir, exist_ok=True)

    if SYSTEM == "Darwin":
        package_macos(proj)
    elif SYSTEM == "Windows":
        package_windows(proj)
    elif SYSTEM == "Linux":
        package_linux(proj)
    else:
        die(F"Unsupported system for package step: {SYSTEM}")