# Copyright 2004-2025 Tom Rothamel <pytom@bishoujo.us>
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

# This file contains code that manages the distribution of Ren'Py games
# and Ren'Py proper.
#
# In this module, all files and paths are stored in unicode. Full paths
# might include windows path separators (\), but archive paths and names we
# deal with/match against use the unix separator (/).

init python in distribute:

    from store import config, persistent
    import store.project as project
    import store.interface as interface
    import store.archiver as archiver
    import store.updater as updater
    import store as store

    from change_icon import change_icons

    import sys
    import os
    import json
    import subprocess
    import hashlib
    import struct
    import collections
    import os
    import io
    import re
    import plistlib
    import time
    import shutil

    def py(s):
        """
        Formats a string with information about the python version.
        """

        return s.format(
            major=sys.version_info.major,
            minor=sys.version_info.minor,
        )

    # * Going from 7.4 to 7.5 or 8.0, the library directory changed.
    # * 7.7 called os.makedirs with exist_ok=True, even on Python 2.
    # * 8.2 wouldn't save the DLC state.
    RENPY_PATCH = py("""\
def change_renpy_executable():
    import sys, os, renpy, site

    if hasattr(site, "RENPY_PLATFORM") and hasattr(sys, "renpy_executable") and (renpy.linux or renpy.windows):
        sys.renpy_executable = os.path.join(renpy.config.renpy_base, "lib", "py{major}-" + site.RENPY_PLATFORM, os.path.basename(sys.renpy_executable))

change_renpy_executable()

if sys.version_info.major == 2:
    os.old_makedirs = getattr(os, "old_makedirs", os.makedirs)

    def makedirs(name, mode=0o777, exist_ok=False):
        if exist_ok and os.path.exists(name):
            return

        os.old_makedirs(name, mode)

    os.makedirs = makedirs

def fix_dlc(name, fn):
    import sys, os

    if not os.path.exists(os.path.join(config.renpy_base, fn)):
        return

    u = sys._getframe(2).f_locals["self"]
    if name in u.current_state:
        return

    u.add_dlc_state(name)

fix_dlc("steam", "lib/py3-linux-x86_64/libsteam_api.so")
fix_dlc("steam", "lib/py2-linux-x86_64/libsteam_api.so")
fix_dlc("web", "web")
fix_dlc("rapt", "rapt")
fix_dlc("renios", "renios")
""")

    match_cache = { }

    def compile_match(pattern):
        """
        Compiles a pattern for use with match.
        """

        regexp = ""

        while pattern:
            if pattern.startswith("**"):
                regexp += r'.*'
                pattern = pattern[2:]
            elif pattern[0] == "*":
                regexp += r'[^/]*/?'
                pattern = pattern[1:]
            elif pattern[0] == '[':
                regexp += r'['
                pattern = pattern[1:]

                while pattern and pattern[0] != ']':
                    regexp += pattern[0]
                    pattern = pattern[1:]

                pattern = pattern[1:]
                regexp += ']'

            else:
                regexp += re.escape(pattern[0])
                pattern = pattern[1:]

        regexp += "$"

        return re.compile(regexp, re.I)

    def match(s, pattern):
        """
        Matches a glob-style pattern against s. Returns True if it matches,
        and False otherwise.

        ** matches every character.
        * matches every character but /.
        [abc] matches a, b, or c.

        Things are matched case-insensitively.
        """

        regexp = match_cache.get(pattern, None)
        if regexp is None:
            regexp = compile_match(pattern)
            match_cache[pattern] = regexp

        if regexp.match(s):
            return True

        if regexp.match("/" + s):
            return True

        return False

    def hash_file(fn):
        """
        Returns the hash of `fn`.
        """

        sha = hashlib.sha256()

        with open(renpy.fsencode(fn), "rb") as f:
            while True:

                data = f.read(8 * 1024 * 1024)

                if not data:
                    break

                sha.update(data)

        return sha.hexdigest()

    class File(object):
        """
        Represents a file that we can distribute.

        self.name
            The name of the file as it will be stored in the archives.

        self.path
            The path to the file on disk. None if it won't be stored
            on disk.

        self.directory
            True if this is a directory.

        self.executable
            True if this is an executable that should be distributed
            with the xbit set.
        """

        def __init__(self, name, path, directory, executable):
            self.name = name
            self.path = path
            self.directory = directory
            self.executable = executable

        def __repr__(self):
            if self.directory:
                extra = "dir"
            elif self.executable:
                extra = "x-bit"
            else:
                extra = ""

            return "<File {!r} {!r} {}>".format(self.name, self.path, extra)

        def copy(self):
            return File(self.name, self.path, self.directory, self.executable)

        def hash(self, hash, distributor):
            """
            Update hash with information about this entry.
            """

            key = (self.name, self.directory, self.executable)

            hash.update(repr(key).encode("utf-8"))

            if self.path is None:
                return

            if self.directory:
                return

            if self.name == "update/current.json":
                return

            if self.path in distributor.hash_cache:
                digest = distributor.hash_cache[self.path]
            else:
                digest = hash_file(self.path)

            hash.update(digest.encode("utf-8"))

        def reprefix(self, old, new):
            rv = self.copy()

            if self.name.startswith(old):
                rv.name = new + self.name[len(old):]

            return rv

    class FileList(list):
        """
        This represents a list of files that we know about.
        """

        def sort(self):
            list.sort(self, key=lambda a : a.name)

        def copy(self):
            """
            Makes a deep copy of this file list.
            """

            rv = FileList()

            for i in self:
                rv.append(i.copy())

            return rv

        def filter_empty(self):
            """
            Makes a deep copy of this file list with empty directories
            omitted.
            """

            rv = FileList()

            needed_dirs = set()

            for i in reversed(self):

                if (not i.directory) or (i.name in needed_dirs):
                    rv.insert(0, i.copy())

                    directory, _sep, _filename = i.name.rpartition("/")
                    needed_dirs.add(directory)

            return rv

        def add_missing_directories(self):
            """
            Adds to this file list all directories that are needed by other
            entries in this file list.
            """

            rv = self.copy()

            seen = set()
            required = set()

            for i in self:
                seen.add(i.name)

                name = i.name

                while "/" in name:
                    name = name.rpartition("/")[0]
                    required.add(name)

            for name in required - seen:
                rv.append(File(name, None, True, False))

            rv.sort()

            return rv


        @staticmethod
        def merge(l):
            """
            Merges a list of file lists into a single file list with no
            duplicate entries.
            """

            rv = FileList()

            seen = set()

            for fl in l:
                for f in fl:
                    if f.name in seen:
                        continue

                    rv.append(f)
                    seen.add(f.name)

            return rv

        def prepend_directory(self, directory):
            """
            Modifies this file list such that every file in it has `directory`
            prepended.
            """

            for i in self:
                i.name = directory + "/" + i.name

            self.insert(0, File(directory, None, True, False))


        def mac_transform(self, app, documentation):
            """
            Creates a new file list that has the mac transform applied to it.

            The mac transform places all files that aren't already in <app> in
            <app>/Contents/Resources/autorun. If it matches one of the documentation
            patterns, then it appears both inside and outside of the app.
            """

            rv = FileList()

            for f in self:

                # Already in the app.
                if f.name == app or f.name.startswith(app + "/"):
                    rv.append(f)
                    continue

                # If it's documentation, keep the file. (But also make
                # a copy.)
                for pattern in documentation:
                    if match(f.name, pattern):
                        rv.append(f)

                    if match("/" + f.name, pattern):
                        rv.append(f)

                # Make a copy.
                f = f.copy()

                f.name = app + "/Contents/Resources/autorun/" + f.name
                rv.append(f)

            rv.append(File(app + "/Contents/Resources/autorun", None, True, False))
            rv.sort()

            return rv

        def mac_lib_transform(self, app, duplicate):
            """
            Creates a new file list that has lib/darwin-x86_64 and lib/pythonlib2.7
            copied into the mac app, the latter iff it's not duplicated elsewhere or
            duplicate is set.
            """

            prefix = py("lib/py{major}-mac-universal")

            for f in list(self):

                if f.name.startswith("lib/python") and (not duplicate):
                    name = app + "/Contents/Resources/" + f.name

                elif f.name.startswith(prefix):
                    name = app + "/Contents/MacOS/" + f.name[len(prefix)+1:]

                else:
                    continue

                new = f.copy()
                new.name = name
                self.append(new)

                if not duplicate:
                    self.remove(f)

            self.sort()


        def hash(self, distributor):
            """
            Returns a hex digest representing this file list.
            """

            sha = hashlib.sha256()

            for f in sorted(self, key=lambda a : a.name):
                f.hash(sha, distributor)

            return sha.hexdigest()

        def split_by_prefix(self, prefix):
            """
            Returns two filelists, one that contains all the files starting with prefix,
            and one tht contains all other files.
            """

            yes = FileList()
            no = FileList()

            for f in self:
                if f.name.startswith(prefix):
                    yes.append(f)
                else:
                    no.append(f)

            return yes, no

        def reprefix(self, old, new):
            """
            Returns a new file list with all the paths reprefixed.
            """

            rv = FileList()

            for f in self:
                rv.append(f.reprefix(old, new))

            return rv


    class Distributor(object):
        """
        This manages the process of building distributions.
        """

        def __init__(self, project, destination=None, reporter=None, packages=None, build_update=True, open_directory=False, noarchive=False, packagedest=None, report_success=True, scan=True, macapp=None, force_format=None, files_filter=None):
            """
            Distributes `project`.

            `destination`
                The destination in which the distribution will be placed. If None,
                uses a default location.

            `reporter`
                An object that's used to report status and progress to the user.

            `packages`
                If not None, a list of packages to distributed. If None, all
                packages are distributed.

            `build_update`
                Will updates be built?

            `open_directory`
                If true, the directory containing the built files will be opened
                if the build succeeds.

            `noarchive`
                If true, files will not be placed into archives.

            `packagedest`
                If given, gives the full path to the single package (without any
                extensions).

            `report_success`
                If true, we report that the build succeeded.

            `macapp`
                If given, the path to a macapp that's used instead of
                the macapp that's included with Ren'Py.

            `force_format`
                If given, forces the format of the distribution to be this.

            `files_filter`
                If given, use this object to decide which files must be included.
                The object must contains the `filter(file, variant, format)`
                method which must return True is the file must be included.
            """

            # A map from a package to a unique update version hash.
            self.update_versions = { }

            # A map from file to its hash.
            self.hash_cache = { }

            # A map from a list of file lists and formats to a single integrated
            # file list with transforms applied.
            self.file_list_cache = { }

            # Status reporter.
            self.reporter = reporter

            if packagedest is not None:
                if packages is None or len(packages) != 1:
                    raise Exception("Packagedest requires a single package be given.")

            # Safety - prevents us from releasing a launcher that won't update.
            if store.UPDATE_SIMULATE:
                raise Exception("Cannot build distributions when UPDATE_SIMULATE is True.")

            # The project we want to distribute.
            self.project = project

            # Logfile.
            self.log = open(self.temp_filename("distribute.txt"), "w")

            # The path to the mac app.
            self.macapp = macapp

            # Start by scanning the project, to get the data and build
            # dictionaries.
            data = project.data

            # Reset the RPU update builder.
            RPUPackage.reset()

            if scan:
                self.reporter.info(_("Scanning project files..."))
                project.update_dump(force=True, gui=False, compile=project.data['force_recompile'])

            if project.data['tutorial']:
                self.reporter.info(_("Building distributions failed:\n\nThe project is the Ren'Py Tutorial, which can't be distributed outside of Ren'Py. Consider using The Question as a test project."), pause=True)
                self.log.close()
                return

            if project.data['force_recompile']:
                import compileall

                compileall.compile_dir(
                    os.path.join(config.renpy_base, "renpy"),
                    ddir="renpy/",
                    force=True,
                    quiet=True,
                )

            if project.dump.get("error", False):
                raise Exception("Could not get build data from the project. Please ensure the project runs.")

            self.build = build = project.dump['build']

            # Map from file list name to file list.
            self.file_lists = collections.defaultdict(FileList)

            self.base_name = build['directory_name']
            self.executable_name = build['executable_name']
            self.pretty_version = build['version']

            if (" " in self.base_name) or (":" in self.base_name) or (";" in self.base_name):
                reporter.info(
                    _("Building distributions failed:\n\nThe build.directory_name variable may not include the space, colon, or semicolon characters."),
                    submessage=_("This may be derived from build.name and config.version or build.version."),
                    pause=True)
                self.log.close()
                return

            # The destination directory.
            if destination is None:
                destination = build["destination"]
                parent = os.path.dirname(project.path)
                self.destination = os.path.join(parent, destination)
            else:
                self.destination = destination

            if not packagedest:
                try:
                    os.makedirs(self.destination)
                except Exception:
                    pass

            self.packagedest = packagedest

            self.include_update = build['include_update']
            self.build_update = self.include_update and build_update

            if self.include_update:
                self.make_key_pem()

            # The various executables, which change names based on self.executable_name.
            self.app = self.executable_name + ".app"
            self.exe = self.executable_name + ".exe"
            self.exe32 = self.executable_name + "-32.exe"
            self.sh = self.executable_name + ".sh"
            self.py = self.executable_name + ".py"

            self.documentation_patterns = build['documentation_patterns']

            build_packages = [ ]

            for i in build['packages']:
                name = i['name']

                if packages is None:
                    if not i['hidden']:
                        build_packages.append(i)
                elif name in packages:
                    build_packages.append(i)

            if not build_packages:
                self.reporter.info(_("No packages are selected, so there's nothing to do."), pause=True)
                self.log.close()
                return

            self.scan_and_classify(project.path, build["base_patterns"])

            if noarchive:
                self.ignore_archives(build['archives'])
            else:
                self.archive_files(build["archives"])

            # Add Ren'Py.
            self.reporter.info(_("Scanning Ren'Py files..."))
            self.scan_and_classify(config.renpy_base, build["renpy_patterns"])

            if build["_sdk_fonts"]:
                for k in list(self.file_lists.keys()):
                    self.file_lists[k] = self.file_lists[k].reprefix("sdk-fonts", "game")

            # Add Python (with the same name as our executables)
            self.add_python()

            # Build the mac app and windows exes.
            self.add_mac_files()
            self.add_windows_files()

            # Add the main.py.
            self.add_main_py()

            # Add generated/special files.
            if build['renpy']:
                self.add_renpy_distro_files()
            else:
                self.add_renpy_game_files()

            # Assign the x-bit as necessary.
            self.mark_executable()

            # Merge file lists, as needed.
            self.merge_file_lists()

            # Rename the executable-like files.
            self.rename()

            # Sign the mac app once on Ren'Py.
            if self.build["renpy"]:
                fl = self.file_lists['binary']
                app, rest = fl.split_by_prefix(self.app)
                if app:
                    app = self.sign_app(app, macapp)
                    fl = FileList.merge([ app, rest ])
                    self.file_lists['binary'] = fl
                else:
                    raise Exception("No mac app found.")

            # The time of the update version.
            self.update_version = int(time.time())

            self.files_filter = files_filter

            for p in build_packages:

                formats = p["formats"]
                if force_format is not None:
                    formats = [ force_format ]

                for f in formats:

                    self.make_package(
                        p["name"],
                        f,
                        p["file_lists"],
                        dlc=p["dlc"])

                if self.build_update and p["update"]:
                    for update_format in self.list_update_formats():
                        self.make_package(
                            p["name"],
                            update_format,
                            p["file_lists"],
                            dlc=p["dlc"])

            wait_parallel_threads()

            if self.build_update:
                self.finish_updates(build_packages)

            # Finish up.
            self.log.close()

            if report_success:
                self.reporter.info(_("All packages have been built.\n\nDue to the presence of permission information, unpacking and repacking the Linux and Macintosh distributions on Windows is not supported."))

            if open_directory:
                renpy.run(store.OpenDirectory(self.destination, absolute=True))

        def list_update_formats(self):
            """
            Returns a list of update formats to build.
            """

            rv = [ ]

            for update_format in self.build["update_formats"]:
                if update_format == "rpu":
                    rv.append("rpu")
                elif update_format == "zsync":
                    rv.append("update")
                else:
                    raise Exception("Unknown update format: " + update_format)

            return rv

        def scan_and_classify(self, directory, patterns):
            """
            Walks through the `directory`, finds files and directories that
            match the pattern, and assigns them to the appropriate file list.

            `patterns`
                A list of pattern, file_list tuples. The pattern is a string
                that is matched using match. File_list is either
                a space-separated list of file lists to add the file to,
                or None to ignore it.

                Directories are matched with a trailing /, but added to the
                file list with the trailing / removed.
            """

            def walk(name, path):

                # Ignore ASCII control characters, like (Icon\r on the mac).
                if re.search('[\x00-\x19]', name):
                    return

                is_dir = os.path.isdir(path)

                if is_dir:
                    match_names = [ name + "/", name ]
                else:
                    match_names = [ name ]

                for pattern, file_list in patterns:

                    matched = False

                    for match_name in match_names:

                        if match(match_name, pattern):

                            # When we have ('test/**', None), avoid excluding test.
                            if (not file_list) and is_dir:
                                new_pattern = pattern.rstrip("*")
                                if (pattern != new_pattern) and match(match_name, new_pattern):
                                    continue

                            matched = True
                            break

                    if matched:
                        break

                else:
                    print(str(match_names[0]), "doesn't match anything.", file=self.log)

                    pattern = None
                    file_list = None

                print(str(match_names[0]), "matches", str(pattern), "(" + str(file_list) + ").", file=self.log)

                if file_list is None:
                    return

                for fl in file_list:
                    f = File(name, path, is_dir, False)
                    self.file_lists[fl].append(f)

                if is_dir:

                    for fn in os.listdir(path):
                        walk(
                            name + "/" + fn,
                            os.path.join(path, fn),
                            )

            for fn in os.listdir(directory):
                walk(fn, os.path.join(directory, fn))

        def merge_file_lists(self):
            """
            For each (old, new) in self.build['merge'], merge the old list
            into the new list.
            """

            for old, new in self.build['merge']:
                self.file_lists[new] = FileList.merge([
                    self.file_lists[old],
                    self.file_lists[new]])

        def rescan(self, oldlist, directory):
            """
            Scans `directory`, and produces a filelist from it. Returns the
            produced filelist.

            `oldlist`
                Is a filelist. If a file has the xbit set in the oldlist, it
                has the xbit set in the new list.
            """

            executable = set()

            for f in oldlist:
                if f.executable:
                    executable.add(f.name)

            rv = FileList()

            def walk(name, path):

                # Ignore ASCII control characters, like (Icon\r on the mac).
                if re.search('[\x00-\x19]', name):
                    return

                is_dir = os.path.isdir(path)

                f = File(name, path, is_dir, name in executable)
                rv.append(f)

                if is_dir:

                    for fn in os.listdir(path):
                        walk(
                            name + "/" + fn,
                            os.path.join(path, fn),
                            )

            for fn in os.listdir(directory):
                walk(fn, os.path.join(directory, fn))

            return rv

        def temp_filename(self, name):
            self.project.make_tmp()
            return os.path.join(self.project.tmp, name)

        def add_file(self, file_list, name, path, executable=False):
            """
            Adds a file to the file lists.

            `file_list`
                A space-separated list of file list names.

            `name`
                The name of the file to be added.

            `path`
                The path to that file on disk.
            """

            if not os.path.exists(path):
                raise Exception("{} does not exist.".format(path))

            if isinstance(file_list, str):
                file_list = file_list.split()

            f = File(name, path, False, executable)

            for fl in file_list:
                self.file_lists[fl].append(f)

        def add_directory(self, file_list, name):
            """
            Adds an empty directory to the file lists.
            """

            if isinstance(file_list, str):
                file_list = file_list.split()

            f = File(name, None, True, False)

            for fl in file_list:
                self.file_lists[fl].append(f)


        def ignore_archives(self, archives):
            """
            Ignore archiving commands by adding the files that would be in
            archives into packages instead.
            """

            for arcname, file_lists in archives:
                if not self.file_lists[arcname]:
                    continue

                for f in self.file_lists[arcname]:
                    for fl in file_lists:
                        self.file_lists[fl].append(f)

        def archive_files(self, archives):
            """
            Add files to archives.
            """

            for arcname, file_list in archives:

                if not self.file_lists[arcname]:
                    continue

                arcfn = arcname + ".rpa"
                arcpath = self.temp_filename(arcfn)

                # Create new directories leading to the new archive file relative to the tmp root
                # if the archive's name indicates it should be in a subdirectory
                arc_relpath = os.path.relpath(arcpath, self.project.tmp)
                arc_subdir = os.path.dirname(arc_relpath)

                if arc_subdir:
                    abs_subdir = os.path.join(self.project.tmp, arc_subdir)
                    os.makedirs(abs_subdir, exist_ok=True)

                af = archiver.Archive(arcpath)

                fll = len(self.file_lists[arcname])

                for i, entry in enumerate(self.file_lists[arcname]):

                    if entry.directory:
                        continue

                    self.reporter.progress(_("Archiving files..."), i, fll)

                    name = "/".join(entry.name.split("/")[1:])
                    af.add(name, entry.path)

                self.reporter.progress_done()

                af.close()

                self.add_file(file_list, "game/" + arcfn, arcpath)

        def add_renpy_game_files(self):
            """
            Add Ren'Py file to the game.
            """

            LICENSE_TXT = os.path.join(config.renpy_base, "LICENSE.txt")

            if os.path.exists(LICENSE_TXT):
                self.add_file("renpy", "renpy/LICENSE.txt", LICENSE_TXT)

            if self.build["script_version"]:

                if (not os.path.exists(os.path.join(self.project.path, "game", "script_version.rpy"))) and \
                    (not os.path.exists(os.path.join(self.project.path, "game", "script_version.rpyc"))):

                    script_version_txt = self.temp_filename("script_version.txt")

                    with open(script_version_txt, "w") as f:
                        f.write(repr(renpy.renpy.version_tuple[:-1]))

                    self.add_file("all", "game/script_version.txt", script_version_txt)

            if self.build["info"]:

                build_info_json = self.temp_filename("build_info.json")

                with open(build_info_json, "w") as f:
                    json.dump(self.build["info"], f)

                self.add_file("all", "game/cache/build_info.json", build_info_json)


        def add_file_list_hash(self, list_name):
            """
            Hashes a file list, then adds that file to the Ren'Py distribution.
            """

            tfn = self.temp_filename(list_name + "_hash.txt")

            with open(tfn, "w") as tf:
                tf.write(self.file_lists[list_name].hash(self))

            self.add_file("binary", "launcher/game/" + list_name + "_hash.txt", tfn)
            self.add_file(list_name, list_name + "/hash.txt", tfn)

        def add_renpy_distro_files(self):
            """
            Add additional files to Ren'Py.
            """

            self.add_file_list_hash("rapt")
            self.add_file_list_hash("renios")
            self.add_file_list_hash("web")

            tmp_fn = self.temp_filename("renpy.py")

            with open(os.path.join(config.renpy_base, "renpy.py"), "rb") as f:
                data = f.read()

            with open(tmp_fn, "wb") as f:
                f.write(b"#!/usr/bin/env python3\n")
                f.write(data)

            self.add_file("source_only", "renpy.py", tmp_fn, True)


        def write_plist(self):

            display_name = self.build['display_name']
            executable_name = self.executable_name
            version = self.build['version']

            plist = dict(
                CFBundleDevelopmentRegion="English",
                CFBundleDisplayName=display_name,
                CFBundleExecutable=executable_name,
                CFBundleIconFile="icon",
                CFBundleIdentifier="com.domain.game",
                CFBundleInfoDictionaryVersion="6.0",
                CFBundleName=display_name,
                CFBundlePackageType="APPL",
                CFBundleShortVersionString=version,
                CFBundleVersion=time.strftime("%Y.%m%d.%H%M%S"),
                LSApplicationCategoryType="public.app-category.simulation-games",
                CFBundleDocumentTypes = [
                    {
                        "CFBundleTypeOSTypes" : [ "****", "fold", "disk" ],
                        "CFBundleTypeRole" : "Viewer",
                    },
                    ],
                UTExportedTypeDeclarations = [
                    {
                        "UTTypeConformsTo" : [ "public.python-script" ],
                        "UTTypeDescription" : "Ren'Py Script",
                        "UTTypeIdentifier" : "org.renpy.rpy",
                        "UTTypeTagSpecification" : { "public.filename-extension" : [ "rpy" ] }
                    },
                    ],
                NSHighResolutionCapable=True,
                )

            if self.build.get('allow_integrated_gpu', False):
                plist["NSSupportsAutomaticGraphicsSwitching"] = True

            plist.update(self.build.get("mac_info_plist", { }))

            rv = self.temp_filename("Info.plist")

            with open(rv, "wb") as f:
                plistlib.dump(plist, f)

            return rv

        def add_python(self):

            if self.build['renpy']:
                windows = 'binary'
                linux = 'binary'
                linux_i686 = 'binary'
                mac = 'binary'
                raspi = 'linux_arm'
            else:
                windows = 'windows'
                linux = 'linux'
                linux_i686 = 'linux_i686'
                mac = 'mac'
                raspi = 'linux_arm'

            prefix = py("lib/py{major}-")

            i686fn = os.path.join(config.renpy_base, prefix + "linux-i686/renpy")

            if os.path.exists(i686fn):

                self.add_file(
                    linux_i686,
                    prefix + "linux-i686/" + self.executable_name,
                    i686fn,
                    True)

            self.add_file(
                linux,
                prefix + "linux-x86_64/" + self.executable_name,
                os.path.join(config.renpy_base, prefix + "linux-x86_64/renpy"),
                True)

            aarch64fn = os.path.join(config.renpy_base, prefix + "linux-aarch64/renpy")

            if os.path.exists(aarch64fn):

                self.add_file(
                    raspi,
                    prefix + "linux-aarch64/" + self.executable_name,
                    aarch64fn,
                    True)

            self.add_file(
                mac,
                prefix + "mac-universal/" + self.executable_name,
                os.path.join(config.renpy_base, prefix + "mac-universal/renpy"),
                True)

        def add_mac_files(self):
            """
            Add mac-specific files to the distro.
            """

            if self.build['renpy']:
                filelist = "binary"
            else:
                filelist = "mac"

            contents = self.app + "/Contents"

            self.add_directory(filelist, self.app)
            self.add_directory(filelist, contents)
            self.add_directory(filelist, contents + "/MacOS")

            plist_fn = self.write_plist()
            self.add_file(filelist, contents + "/Info.plist", plist_fn)

            self.add_file(filelist,
                contents + "/MacOS/" + self.executable_name,
                os.path.join(config.renpy_base, py("lib/py{major}-mac-universal/renpy")))


            custom_fn = os.path.join(self.project.path, "icon.icns")
            default_fn = os.path.join(config.renpy_base, "launcher/icon.icns")

            if os.path.exists(custom_fn):
                icon_fn = custom_fn
            else:
                icon_fn = default_fn

            resources = contents + "/Resources"

            self.add_directory(filelist, resources)
            self.add_file(filelist, resources + "/icon.icns", icon_fn)

            if not self.build['renpy']:
                self.add_directory(filelist, contents + "/MacOS/lib")
                self.add_directory(filelist, contents + py("/MacOS/lib/py{major}-mac-universal"))
                self.add_directory(filelist, contents + py("/Resources/lib/python{major}.{minor}"))

            self.file_lists[filelist].mac_lib_transform(self.app, self.build['renpy'])

        def add_windows_files(self):
            """
            Adds windows-specific files.
            """

            if self.build['renpy']:
                windows = 'binary'
                windows_i686 = 'binary'
            else:
                windows = 'windows'
                windows_i686 = 'windows_i686'


            icon_fn = os.path.join(self.project.path, "icon.ico")


            def write_exe(src, dst, tmp, fl):
                """
                Write the exe found at `src` (taken as relative to renpy-base)
                as `dst` (in the distribution). `tmp` is the name of a tempfile
                that is written if one is needed.
                """

                if fl == "windows_i686":
                    should_change_icon = self.build["change_icon_i686"]
                else:
                    should_change_icon = True

                src = os.path.join(config.renpy_base, src)
                tmp = self.temp_filename(tmp)

                if should_change_icon and os.path.exists(icon_fn) and os.path.exists(src):

                    with open(tmp, "wb") as f:
                        f.write(change_icons(src, icon_fn))

                else:
                    tmp = src

                if os.path.exists(tmp):
                    self.add_file(fl, dst, tmp)

            write_exe("lib/py3-windows-x86_64/renpy.exe", self.exe, self.exe, windows)
            write_exe("lib/py3-windows-x86_64/pythonw.exe", "lib/py3-windows-x86_64/pythonw.exe", "pythonw-64.exe", windows)


        def add_main_py(self):
            if self.build['renpy']:
                return

            self.add_file("web", "main.py", os.path.join(config.renpy_base, "renpy.py"))

        def mark_executable(self):
            """
            Marks files as executable.
            """

            for l in self.file_lists.values():
                for f in l:
                    for pat in self.build['xbit_patterns']:
                        if match(f.name, pat):
                            f.executable = True

                        if match("/" + f.name, pat):
                            f.executable = True

        def rename(self):
            """
            Rename files in all lists to match the executable names.
            """

            major_sh = py("renpy{major}.sh")

            def rename_one(fn):
                parts = fn.split('/')
                p = parts[0]

                if p == major_sh:
                    p = self.sh
                elif p == "renpy.sh":
                    p = self.sh
                elif p == "renpy.py":
                    p = self.py

                parts[0] = p
                return "/".join(parts)

            for l in self.file_lists.values():
                for f in l:
                    f.name = rename_one(f.name)

        def run(self, message, command, **kwargs):
            """
            Runs a command.
            """

            self.reporter.info(message)

            cmd = [ renpy.fsencode(i.format(**kwargs)) for i in command ]

            # print("\"" + "\" \"".join(cmd) + "\"")

            try:
                import sys, os
                isatty = os.isatty(sys.stdin.fileno())
            except Exception:
                isatty = False

            if isatty:
                subprocess.check_call(cmd)
            else:
                subprocess.check_call(cmd, stdout=self.log, stderr=subprocess.STDOUT)


        def sign_app(self, fl, appzip):
            """
            Signs the mac app contained in appzip.
            """

            if self.macapp:
                return self.rescan(fl, self.macapp)

            identity = self.build.get('mac_identity', None)

            if identity is None:
                return fl

            # Figure out where it goes.
            if appzip:
                dn = "sign.app-standalone"
            else:
                dn = "sign.app-crossplatform"

            dn = self.temp_filename(dn)

            if os.path.exists(dn):
                shutil.rmtree(dn)

            # Unpack the app.
            pkg = DirectoryPackage(dn)

            for i, f in enumerate(fl):
                self.reporter.progress(_("Unpacking the Macintosh application for signing..."), i, len(fl))

                if f.directory:
                    pkg.add_directory(f.name, f.path)
                else:
                    pkg.add_file(f.name, f.path, f.executable)

            pkg.close()

            # Sign the mac app.
            self.run(
                _("Signing the Macintosh application...\n(This may take a long time.)"),
                self.build["mac_codesign_command"],
                identity=identity,
                app=os.path.join(dn, self.app),
                entitlements=os.path.join(config.gamedir, "entitlements.plist"),
                )

            # Rescan the signed app.
            fl = self.rescan(fl, dn)

            return fl

        def make_dmg(self, volname, sourcedir, dmg):
            """
            Packages `sourcedir` as a dmg.
            """

            identity = self.build.get('mac_identity', None)

            if identity is None:
                identity = ''

            self.run(
                _("Creating the Macintosh DMG..."),
                self.build["mac_create_dmg_command"],
                identity=identity,
                volname=volname,
                sourcedir=sourcedir,
                dmg=dmg,
            )

            if self.build.get("mac_codesign_dmg_command", None):

                self.run(
                    _("Signing the Macintosh DMG..."),
                    self.build["mac_codesign_dmg_command"],
                    identity=identity,
                    volname=volname,
                    sourcedir=sourcedir,
                    dmg=dmg,
                )

        def workaround_mac_notarization(self, fl):
            """
            This works around mac notarization by compressing the unsigned,
            un-notarized, binaries in lib/py3-mac-universal.
            """

            fl = fl.copy()

            for f in fl:
                if py("/lib/py{major}-mac-universal/") in f.name:
                    with open(f.path, "rb") as inf:
                        data = inf.read()

                    tempfile = self.temp_filename(os.path.basename(f.name) + ".macho")

                    with open(tempfile, "wb") as outf:
                        outf.write(b"RENPY" + data)

                    f.name += ".macho"
                    f.path = tempfile

            return fl

        def prepare_file_list(self, format, file_lists):
            """
            Prepares a master list of files, given the format and file lists.
            This also takes care of the mac transforms, and signing the app
            if necessary.
            """

            macapp = (format in { "app-zip", "app-directory", "app-dmg" })
            key = (macapp, tuple(file_lists))

            if key in self.file_list_cache:
                return self.file_list_cache[key].copy()

            fl = FileList.merge([ self.file_lists[i] for i in file_lists ])
            fl = fl.copy()
            fl.sort()

            if self.build.get("exclude_empty_directories", True):
                fl = fl.filter_empty()

            fl = fl.add_missing_directories()

            if macapp:
                fl = fl.mac_transform(self.app, self.documentation_patterns)

            if not self.build["renpy"]:

                app, rest = fl.split_by_prefix(self.app)

                if app:
                    app = self.sign_app(app, macapp)

                    fl = FileList.merge([ app, rest ])

            self.file_list_cache[key] = fl
            return fl.copy()

        def make_package(self, variant, format, file_lists, dlc=False):
            """
            Creates a package file in the projects directory.

            `variant`
                The name of the variant to package. This is appended to the base name to become
                part of the file and directory names.

            `format`
                The format things will be packaged in. See the table of formats below.
            `file_lists`
                A string containing a space-separated list of file_lists to include in this
                package.

            `dlc`
                True if we want to build a non-update file in DLC mode.
            """
            filename = self.base_name + "-" + variant
            path = os.path.join(self.destination, filename)


            # A map from the name of the format, to the options that will be
            # used with it. The fields are:
            #
            # - The extension used.
            # - Is this a directory based format?
            # - Should the directory be turned into a dmg?
            # - Should a directory name be prepended?

            FORMATS = {
                "update" : (".update", False, False, False),
                "rpu" : ("", False, False, False),

                "tar.bz2" : (".tar.bz2", False, False, True),
                "zip" : (".zip", False, False, True),
                "directory" : ("", True, False, False),
                "dmg" : ("-dmg", True, True, True),

                "app-zip" : (".zip", False, False, False),
                "app-directory" : ("-app", True, False, False),
                "app-dmg" : ("-app-dmg", True, True, False),

                "bare-tar.bz2" : (".tar.bz2", False, False, False),
                "bare-zip" : (".zip", False, False, False),

                "null" : ( "", True, False, False),
            }

            if format not in FORMATS:
                raise Exception("Format %r is unknown." % format)

            ext, directory, dmg, prepend = FORMATS[format]

            mac_identity = self.build.get('mac_identity', None)

            if dmg and (mac_identity is None):
                return

            if self.packagedest:
                path = self.packagedest

            fl = self.prepare_file_list(format, file_lists)

            # Write the update information.
            update_files = [ ]
            update_xbit = [ ]
            update_directories = [ ]

            for i in fl:
                if not i.directory:
                    update_files.append(i.name)
                else:
                    update_directories.append(i.name)

                if i.executable:
                    update_xbit.append(i.name)

            self.update_versions[variant] = fl.hash(self)

            update = { variant : { "version" : self.update_versions[variant], "base_name" : self.base_name, "files" : update_files, "directories" : update_directories, "xbit" : update_xbit } }

            update_fn = self.temp_filename(filename + ".update.json")


            if self.include_update and not format.startswith("app-"):

                with open(update_fn, "w") as f:
                    json.dump(update, f, indent=2)

                if (not dlc) or (format == "update"):
                    fl.append(File("update", None, True, False))
                    fl.append(File("update/current.json", update_fn, False, False))

                    if not dlc:
                        fl.append(File("update/key.pem", self.temp_filename("key.pem"), False, False))

            # If we're not an update file, prepend the directory.
            if (not dlc) and prepend:
                fl.prepend_directory(filename)

            # The path to the DMG, if we're going to make one.
            dmg_path = path + ".dmg"

            full_filename = filename + ext
            path += ext

            if format == "rpu":
                full_filename = "rpu/" + variant + ".files.rpu"
                path = self.destination + "/" + full_filename

            def done():
                """
                This is called when the build of the package is done, either
                in this thread or a background thread.
                """

                final_update_fn = os.path.join(self.destination, filename + ".update.json")

                if self.build_update or dlc:
                    if os.path.exists(update_fn):
                        shutil.copy(update_fn, final_update_fn)

            if format == "tar.bz2" or format == "bare-tar.bz2":
                pkg = TarPackage(path, "w:bz2")
            elif format == "update":
                pkg = UpdatePackage(path, filename, self.destination)
            elif format == "rpu":
                pkg = RPUPackage(self.destination, variant)
            elif format == "null":
                pkg = NullPackage()
            elif format == "zip" or format == "app-zip" or format == "bare-zip":
                if self.build['renpy']:
                    pkg = ExternalZipPackage(path)
                else:
                    pkg = ZipPackage(path)
            elif dmg:

                def make_dmg():
                    self.make_dmg(filename, path, dmg_path)
                    shutil.rmtree(path)

                pkg = DMGPackage(path, make_dmg)

                fl = self.workaround_mac_notarization(fl)

            elif directory:
                pkg = DirectoryPackage(path)

            # If we want to build in parallel.
            if self.build['renpy']:
                pkg = ParallelPackage(pkg, done, variant + "." + format)
                done = None

            for i, f in enumerate(fl):
                self.reporter.progress(_("Writing the [variant] [format] package."), i, len(fl), variant=variant, format=format)

                if f.directory:
                    pkg.add_directory(f.name, f.path)
                else:
                    if self.files_filter is not None and not self.files_filter.filter(f, variant, format):
                        # Ignore file
                        continue
                    pkg.add_file(f.name, f.path, f.executable)

            self.reporter.progress_done()

            if format == "update":
                self.reporter.info(_("Making the [variant] update zsync file."), variant=variant)

            def close_progress(done, total):
                self.reporter.progress(_("Finishing the [variant] [format] package."), done, total, variant=variant, format=format)

            pkg.close(close_progress)

            if done is not None:
                done()


        def finish_updates(self, packages):
            """
            Indexes the updates, then removes the .update files.
            """

            if not self.build_update:
                return

            index = { }

            if self.build['renpy']:
                index["monkeypatch"] = RENPY_PATCH

            def add_variant(variant):

                index[variant] = {
                    "version" : self.update_versions[variant],
                    "pretty_version" : self.pretty_version,
                    "renpy_version" : renpy.version_only,
                }

                if "zsync" in self.build["update_formats"]:

                    digest = hash_file(self.destination + "/" + self.base_name + "-" + variant + ".update")
                    sums_size = os.path.getsize(self.destination + "/" + self.base_name + "-" + variant + ".sums")

                    index[variant].update({
                        "digest" : digest,
                        "zsync_url" : self.base_name + "-" + variant + ".zsync",
                        "sums_url" : self.base_name + "-" + variant + ".sums",
                        "sums_size" : sums_size,
                        "json_url" : self.base_name + "-" + variant + ".update.json",
                        })

                    fn = renpy.fsencode(os.path.join(self.destination, self.base_name + "-" + variant + ".update"))

                    if os.path.exists(fn):
                        os.unlink(fn)

                if "rpu" in self.build["update_formats"]:
                    index[variant]["rpu_url"] = "rpu/" + variant + ".files.rpu"
                    index[variant]["rpu_digest"] = hash_file(self.destination + "/rpu/" + variant + ".files.rpu")

            for p in packages:
                if p["update"]:
                    add_variant(p["name"])

            update_data = json.dumps(index, indent=2)

            if not isinstance(update_data, bytes):
                update_data = update_data.encode("utf-8")

            fn = renpy.fsencode(os.path.join(self.destination, "updates.json"))
            with open(fn, "wb") as f:
                f.write(update_data)

            # Write the signed file.
            import ecdsa

            with open(self.find_update_pem(), "rb") as f:
                signing_key = ecdsa.SigningKey.from_pem(f.read())

            fn = renpy.fsencode(os.path.join(self.destination, "updates.ecdsa"))
            with open(fn, "wb") as f:
                f.write(signing_key.sign(update_data))

        def find_update_pem(self):
            if self.build['renpy']:
                return os.path.join(config.renpy_base, "update.pem")
            else:
                return os.path.join(self.project.path, "update.pem")

        def make_key_pem(self):
            import ecdsa

            with open(self.find_update_pem(), "rb") as f:
                signing_key = ecdsa.SigningKey.from_pem(f.read())

            key_pem = self.temp_filename("key.pem")
            with open(key_pem, "wb") as f:
                f.write(signing_key.verifying_key.to_pem())

        def dump(self):
            for k, v in sorted(self.file_lists.items()):
                print()
                print(k + ":")

                v.sort()

                for i in v:
                    print("   ", i.name, "xbit" if i.executable else "")

    class GuiReporter(object):
        """
        Displays progress using the gui.
        """

        def __init__(self):
            # The time at which we should next report progress.
            self.next_progress = 0

        def info(self, what, pause=False, **kwargs):
            if pause:
                interface.info(what, **kwargs)
            else:
                interface.processing(what, **kwargs)

        def progress(self, what, complete, total, **kwargs):

            if (complete > 0) and (time.time() < self.next_progress):
                return

            interface.processing(what, _("Processed {b}[complete]{/b} of {b}[total]{/b} files."), complete=complete, total=total, **kwargs)

            self.next_progress = time.time() + .05

        def progress_done(self):
            return


    class TextReporter(object):
        """
        Displays progress on the command line.
        """

        def info(self, what, pause=False, **kwargs):
            what = what.replace("[", "{")
            what = what.replace("]", "}")
            what = what.format(**kwargs)
            print(what)

        def progress(self, what, done, total, **kwargs):
            what = what.replace("[", "{")
            what = what.replace("]", "}")
            what = what.format(**kwargs)

            sys.stdout.write("\r{} - {} of {}".format(what, done + 1, total))
            sys.stdout.flush()

        def progress_done(self):
            sys.stdout.write("\n")


    def distribute_command():
        ap = renpy.arguments.ArgumentParser()
        ap.add_argument("--destination", "--dest", default=None, action="store", help="The directory where the packaged files should be placed.")
        ap.add_argument("--packagedest", default=None, action="store", help="If given, gives the full path to the package file, without extensions." )
        ap.add_argument("--no-update", default=True, action="store_false", dest="build_update", help="Prevents updates from being built.")
        ap.add_argument("--package", action="append", help="If given, a package to build. Defaults to building all packages.")
        ap.add_argument("--no-archive", action="store_true", help="If given, files will not be added to archives.")
        ap.add_argument("--macapp", default=None, action="store", help="If given, the path to a signed and notarized mac app.")
        ap.add_argument("--format", default=None, action="store", help="The format of package to build.")

        ap.add_argument("project", help="The path to the project directory.")

        args = ap.parse_args()

        p = project.Project(args.project)

        if args.package:
            packages = args.package
        else:
            packages = None

        Distributor(p, destination=args.destination, reporter=TextReporter(), packages=packages, build_update=args.build_update, noarchive=args.no_archive, packagedest=args.packagedest, macapp=args.macapp, force_format=args.format)

        return False

    renpy.arguments.register_command("distribute", distribute_command)


    def update_old_game(project, reporter, compile):
        if compile:
            reporter.info(_("Recompiling all rpy files into rpyc files..."))
            project.launch([ "compile", "--keep-orphan-rpyc" ], wait=True)

        files = [
            fn + "c" for fn in project.script_files()
            if fn.startswith("game/") and project.exists(fn + "c")]
        len_files = len(files)

        if not files:
            return

        TEMP_OLD_GAME_DIR = project.temp_filename("old-game")
        if os.path.isdir(TEMP_OLD_GAME_DIR):
            shutil.rmtree(TEMP_OLD_GAME_DIR)

        for i, src in enumerate(files):
            reporter.progress(_("Copying files..."), i, len_files)
            dst = project.temp_filename("old-" + src)
            try:
                os.makedirs(os.path.dirname(dst))
            except Exception:
                pass
            shutil.copyfile(os.path.join(project.path, src), dst)

        reporter.progress_done()

        OLD_GAME_DIR = os.path.join(project.path, "old-game")
        if os.path.isdir(OLD_GAME_DIR):
            shutil.rmtree(OLD_GAME_DIR)

        shutil.copytree(TEMP_OLD_GAME_DIR, OLD_GAME_DIR)

    def update_old_game_command():
        ap = renpy.arguments.ArgumentParser("Back-ups all rpyc files into old-game directory.")
        ap.add_argument("project", help="The path to the project directory.")

        args = ap.parse_args()

        update_old_game(project.Project(args.project), TextReporter(), True)

        return False

    renpy.arguments.register_command("update_old_game", update_old_game_command)

label distribute:

    python hide:

        data = project.current.data
        d = distribute.Distributor(project.current,
            reporter=distribute.GuiReporter(),
            packages=data['packages'],
            build_update=data['build_update'],
            open_directory=True,
            )


    jump post_build

label update_old_game:
    python hide:
        distribute.update_old_game(project.current, distribute.GuiReporter(), False)
    return
