#!/usr/bin/env python3
#
# Copyright (C) 2011  Patrick "p2k" Schneider <me@p2k-network.org>
#
# 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 <http://www.gnu.org/licenses/>.
#

import sys, re, os, platform, shutil, stat, subprocess, os.path
from argparse import ArgumentParser
try:
    from ds_store import DSStore
    from mac_alias import Alias
    HAS_DS_STORE = True
except ImportError:
    HAS_DS_STORE = False
from pathlib import Path
from subprocess import PIPE, run
from typing import List, Optional

# This is ported from the original macdeployqt with modifications

class FrameworkInfo(object):
    def __init__(self):
        self.frameworkDirectory = ""
        self.frameworkName = ""
        self.frameworkPath = ""
        self.binaryDirectory = ""
        self.binaryName = ""
        self.binaryPath = ""
        self.version = ""
        self.installName = ""
        self.deployedInstallName = ""
        self.sourceFilePath = ""
        self.destinationDirectory = ""
        self.sourceResourcesDirectory = ""
        self.sourceVersionContentsDirectory = ""
        self.sourceContentsDirectory = ""
        self.destinationResourcesDirectory = ""
        self.destinationVersionContentsDirectory = ""

    def __eq__(self, other):
        if self.__class__ == other.__class__:
            return self.__dict__ == other.__dict__
        else:
            return False

    def __str__(self):
        return f""" Framework name: {self.frameworkName}
 Framework directory: {self.frameworkDirectory}
 Framework path: {self.frameworkPath}
 Binary name: {self.binaryName}
 Binary directory: {self.binaryDirectory}
 Binary path: {self.binaryPath}
 Version: {self.version}
 Install name: {self.installName}
 Deployed install name: {self.deployedInstallName}
 Source file Path: {self.sourceFilePath}
 Deployed Directory (relative to bundle): {self.destinationDirectory}
"""

    def isDylib(self):
        return self.frameworkName.endswith(".dylib")

    def isQtFramework(self):
        if self.isDylib():
            return self.frameworkName.startswith("libQt")
        else:
            return self.frameworkName.startswith("Qt")

    reOLine = re.compile(r'^(.+) \(compatibility version [0-9.]+, current version [0-9.]+\)$')
    bundleFrameworkDirectory = "Contents/Frameworks"
    bundleBinaryDirectory = "Contents/MacOS"

    @classmethod
    def fromLibraryLine(cls, line: str) -> Optional['FrameworkInfo']:
        # Note: line must be trimmed
        if line == "":
            return None

        # Don't deploy system libraries
        if line.startswith("/System/Library/") or line.startswith("@executable_path") or line.startswith("/usr/lib/"):
            return None

        m = cls.reOLine.match(line)
        if m is None:
            raise RuntimeError(f"Line could not be parsed: {line}")

        # Normalize the path to collapse any '..' traversals that may arise
        # from @rpath or @loader_path expansion in different directory layouts.
        path = os.path.normpath(m.group(1))

        info = cls()
        info.sourceFilePath = path
        info.installName = path

        if path.endswith(".dylib"):
            dirname, filename = os.path.split(path)
            info.frameworkName = filename
            info.frameworkDirectory = dirname
            info.frameworkPath = path

            info.binaryDirectory = dirname
            info.binaryName = filename
            info.binaryPath = path
            info.version = "-"

            info.installName = path
            info.deployedInstallName = f"@executable_path/../Frameworks/{info.binaryName}"
            info.sourceFilePath = path
            info.destinationDirectory = cls.bundleFrameworkDirectory
        else:
            parts = path.split("/")
            # Search for the LAST .framework directory. When relative rpaths
            # are expanded, the path may traverse through a sibling framework
            # directory before reaching the actual dependency. Using the last
            # match ensures we identify the correct dependency framework.
            i = -1
            for j, part in enumerate(parts):
                if part.endswith(".framework"):
                    i = j
            if i == -1:
                raise RuntimeError(f"Could not find .framework or .dylib in line: {line}")

            info.frameworkName = parts[i]
            info.frameworkDirectory = "/".join(parts[:i])
            info.frameworkPath = os.path.join(info.frameworkDirectory, info.frameworkName)

            info.binaryName = parts[i+3]
            info.binaryDirectory = "/".join(parts[i+1:i+3])
            info.binaryPath = os.path.join(info.binaryDirectory, info.binaryName)
            info.version = parts[i+2]

            info.deployedInstallName = f"@executable_path/../Frameworks/{os.path.join(info.frameworkName, info.binaryPath)}"
            info.destinationDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, info.binaryDirectory)

            info.sourceResourcesDirectory = os.path.join(info.frameworkPath, "Resources")
            info.sourceContentsDirectory = os.path.join(info.frameworkPath, "Contents")
            info.sourceVersionContentsDirectory = os.path.join(info.frameworkPath, "Versions", info.version, "Contents")
            info.destinationResourcesDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Resources")
            info.destinationVersionContentsDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Versions", info.version, "Contents")

        return info

class ApplicationBundleInfo(object):
    def __init__(self, path: str):
        self.path = path
        # for backwards compatibility reasons, this must remain as gridcoinresearch
        self.binaryPath = os.path.join(path, "Contents", "MacOS", "gridcoinresearch")
        if not os.path.exists(self.binaryPath):
            raise RuntimeError(f"Could not find bundle binary for {path}")
        self.resourcesPath = os.path.join(path, "Contents", "Resources")
        self.pluginPath = os.path.join(path, "Contents", "PlugIns")

class DeploymentInfo(object):
    def __init__(self):
        self.qtPath = None
        self.pluginPath = None
        self.deployedFrameworks = []

    def detectQtPath(self, frameworkDirectory: str):
        parentDir = os.path.dirname(frameworkDirectory)
        if os.path.exists(os.path.join(parentDir, "translations")):
            # Classic layout, e.g. "/usr/local/Trolltech/Qt-4.x.x"
            self.qtPath = parentDir
        else:
            self.qtPath = os.getenv("QTDIR", None)

        if self.qtPath is not None:
            # Try common plugin directory layouts:
            #   Classic Qt:   ${prefix}/plugins
            #   Homebrew Qt6: ${prefix}/share/qt/plugins
            for candidate in [
                os.path.join(self.qtPath, "plugins"),
                os.path.join(self.qtPath, "share", "qt", "plugins"),
            ]:
                if os.path.exists(candidate):
                    self.pluginPath = candidate
                    break

    def usesFramework(self, name: str) -> bool:
        for framework in self.deployedFrameworks:
            if framework.endswith(".framework"):
                if framework.startswith(f"{name}."):
                    return True
            elif framework.endswith(".dylib"):
                if framework.startswith(f"lib{name}."):
                    return True
        return False

def getRpaths(binaryPath: str, verbose: int) -> List[str]:
    """Extract LC_RPATH search paths from a Mach-O binary using otool."""
    output = run(["otool", "-l", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
    if output.returncode != 0:
        return []
    rpaths = []
    lines = output.stdout.split("\n")
    for i, line in enumerate(lines):
        if "cmd LC_RPATH" in line:
            for j in range(i + 1, min(i + 4, len(lines))):
                if "path " in lines[j]:
                    rp = lines[j].strip().split("path ")[1].split(" (offset")[0].strip()
                    rpaths.append(rp)
                    break
    if verbose and rpaths:
        print(f"  rpaths for {binaryPath}: {rpaths}")
    return rpaths

def expandRpathTokens(rpath: str, binaryPath: str) -> str:
    """Expand @loader_path and @executable_path tokens in an rpath entry."""
    rpath = rpath.replace("@loader_path", os.path.dirname(binaryPath))
    rpath = rpath.replace("@executable_path", os.path.dirname(binaryPath))
    return rpath

def resolveRpath(line: str, rpaths: List[str], binaryPath: str) -> Optional[str]:
    """Try to resolve an @rpath reference using extracted rpaths.

    Returns the line with @rpath replaced by the matching rpath,
    or None if no rpath resolves to an existing path.
    Expands @loader_path/@executable_path tokens within rpath entries.
    """
    for rp in rpaths:
        rp = expandRpathTokens(rp, binaryPath)
        candidate = line.replace("@rpath", rp)
        stripped = candidate.strip()
        m = FrameworkInfo.reOLine.match(stripped)
        if m:
            # Normalize the path to collapse '..' traversals. Without this,
            # relative rpaths expanded in a bundle context can produce paths
            # that pass through a sibling .framework directory (e.g.
            # .../QtGui.framework/Versions/A/../../QtCore.framework/...).
            # The old first-match .framework check would then find the wrong
            # framework and accept a bogus candidate.
            path = os.path.normpath(m.group(1))
            if os.path.exists(path):
                # Reconstruct the line with the normalized path so downstream
                # parsing sees a clean path without '..' components.
                version_info = stripped[len(m.group(1)):]
                return f"  {path}{version_info}"
    return None

def getFrameworks(binaryPath: str, verbose: int) -> List[FrameworkInfo]:
    objdump = os.getenv("OBJDUMP", "objdump")
    if verbose:
        print(f"Inspecting with {objdump}: {binaryPath}")
    output = run([objdump, "--macho", "--dylibs-used", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
    if output.returncode != 0:
        sys.stderr.write(output.stderr)
        sys.stderr.flush()
        raise RuntimeError(f"{objdump} failed with return code {output.returncode}")

    lines = output.stdout.split("\n")
    lines.pop(0) # First line is the inspected binary
    if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
        lines.pop(0) # Frameworks and dylibs list themselves as a dependency.

    # Extract rpaths for resolving @rpath references
    rpaths = getRpaths(binaryPath, verbose)

    libraries = []
    for line in lines:
        # Extract the raw install name before any token resolution.
        # install_name_tool -change must match the exact string in the binary.
        raw_match = FrameworkInfo.reOLine.match(line.strip())
        raw_installname = raw_match.group(1) if raw_match else None

        line = line.replace("@loader_path", os.path.dirname(binaryPath))
        if "@rpath/" in line:
            resolved = resolveRpath(line, rpaths, binaryPath)
            if resolved is not None:
                line = resolved
            else:
                if verbose:
                    print(f"  Skipping unresolvable @rpath reference: {line.strip()}")
                continue
        info = FrameworkInfo.fromLibraryLine(line.strip())
        if info is not None:
            # Restore the original install name so that install_name_tool -change
            # can match the actual reference in the binary (e.g. @rpath/... or
            # @loader_path/...) rather than the resolved absolute path.
            if raw_installname and raw_installname != info.installName:
                info.installName = raw_installname
            if verbose:
                print("Found framework:")
                print(info)
            libraries.append(info)

    return libraries

def runInstallNameTool(action: str, *args):
    installnametoolbin=os.getenv("INSTALLNAMETOOL", "install_name_tool")
    run([installnametoolbin, "-"+action] + list(args), check=True)

def changeInstallName(oldName: str, newName: str, binaryPath: str, verbose: int):
    if verbose:
        print("Using install_name_tool:")
        print(" in", binaryPath)
        print(" change reference", oldName)
        print(" to", newName)
    runInstallNameTool("change", oldName, newName, binaryPath)

def changeIdentification(id: str, binaryPath: str, verbose: int):
    if verbose:
        print("Using install_name_tool:")
        print(" change identification in", binaryPath)
        print(" to", id)
    runInstallNameTool("id", id, binaryPath)

def runStrip(binaryPath: str, verbose: int):
    stripbin=os.getenv("STRIP", "strip")
    if verbose:
        print("Using strip:")
        print(" stripped", binaryPath)
    run([stripbin, "-x", binaryPath], check=True)

def codesignItem(path: str, identity: str, entitlements: Optional[str],
                  hardened: bool, verbose: int):
    """Sign a single binary, dylib, framework, or bundle."""
    if platform.system() != "Darwin":
        return
    cmd = ["codesign", "--force", "--sign", identity, "--timestamp"]
    if hardened:
        cmd.append("--options=runtime")
    if entitlements:
        cmd.extend(["--entitlements", entitlements])
    cmd.append(path)
    if verbose:
        print("  codesign:", " ".join(cmd))
    subprocess.check_call(cmd)

def copyFramework(framework: FrameworkInfo, path: str, verbose: int) -> Optional[str]:
    if framework.sourceFilePath.startswith("Qt"):
        #standard place for Nokia Qt installer's frameworks
        fromPath = f"/Library/Frameworks/{framework.sourceFilePath}"
    else:
        fromPath = framework.sourceFilePath
    toDir = os.path.join(path, framework.destinationDirectory)
    toPath = os.path.join(toDir, framework.binaryName)

    if framework.isDylib():
        if not os.path.exists(fromPath):
            raise RuntimeError(f"No file at {fromPath}")

        if os.path.exists(toPath):
            return None # Already there

        if not os.path.exists(toDir):
            os.makedirs(toDir)

        shutil.copy2(fromPath, toPath)
        if verbose:
            print("Copied:", fromPath)
            print(" to:", toPath)
    else:
        to_dir = os.path.join(path, "Contents", "Frameworks", framework.frameworkName)
        if os.path.exists(to_dir):
            return None # Already there

        from_dir = framework.frameworkPath
        if not os.path.exists(from_dir):
            raise RuntimeError(f"No directory at {from_dir}")

        shutil.copytree(from_dir, to_dir, symlinks=True)
        if verbose:
            print("Copied:", from_dir)
            print(" to:", to_dir)

        headers_link = os.path.join(to_dir, "Headers")
        if os.path.exists(headers_link):
            os.unlink(headers_link)

        headers_dir = os.path.join(to_dir, framework.binaryDirectory, "Headers")
        if os.path.exists(headers_dir):
            shutil.rmtree(headers_dir)

    permissions = os.stat(toPath)
    if not permissions.st_mode & stat.S_IWRITE:
      os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)

    return toPath

def deployFrameworks(frameworks: List[FrameworkInfo], bundlePath: str, binaryPath: str, strip: bool, verbose: int, deploymentInfo: Optional[DeploymentInfo] = None) -> DeploymentInfo:
    if deploymentInfo is None:
        deploymentInfo = DeploymentInfo()

    while len(frameworks) > 0:
        framework = frameworks.pop(0)
        deploymentInfo.deployedFrameworks.append(framework.frameworkName)

        print("Processing", framework.frameworkName, "...")

        # Get the Qt path from one of the Qt frameworks
        if deploymentInfo.qtPath is None and framework.isQtFramework():
            deploymentInfo.detectQtPath(framework.frameworkDirectory)

        if framework.installName.startswith("@executable_path") or framework.installName.startswith(bundlePath):
            print(framework.frameworkName, "already deployed, skipping.")
            continue

        # install_name_tool the new id into the binary
        changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)

        # Copy framework to app bundle.
        deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
        # Skip the rest if already was deployed.
        if deployedBinaryPath is None:
            continue

        if strip:
            runStrip(deployedBinaryPath, verbose)

        # install_name_tool it a new id.
        changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
        # Check for framework dependencies.
        # Scan the ORIGINAL binary so that @loader_path / @rpath tokens
        # resolve relative to the Qt prefix (where sibling frameworks
        # exist) rather than the bundle (where they may not yet be copied).
        originalPath = framework.sourceFilePath
        if originalPath.startswith("Qt"):
            originalPath = f"/Library/Frameworks/{originalPath}"
        dependencies = getFrameworks(originalPath, verbose)

        for dependency in dependencies:
            changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)

            # Deploy framework if necessary.
            if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
                frameworks.append(dependency)

    return deploymentInfo

def deployFrameworksForAppBundle(applicationBundle: ApplicationBundleInfo, strip: bool, verbose: int) -> DeploymentInfo:
    frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
    if len(frameworks) == 0:
        print(f"Warning: Could not find any external frameworks to deploy in {applicationBundle.path}.")
        return DeploymentInfo()
    else:
        return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)

def deployPlugins(appBundleInfo: ApplicationBundleInfo, deploymentInfo: DeploymentInfo, strip: bool, verbose: int):
    plugins = []
    if deploymentInfo.pluginPath is None:
        return
    for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
        pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)

        if pluginDirectory not in ['styles', 'platforms', 'imageformats']:
            continue

        for pluginName in filenames:
            pluginPath = os.path.join(pluginDirectory, pluginName)

            if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle', 'libqsvg']:
                continue

            plugins.append((pluginDirectory, pluginName))

    for pluginDirectory, pluginName in plugins:
        print("Processing plugin", os.path.join(pluginDirectory, pluginName), "...")

        sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
        destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
        if not os.path.exists(destinationDirectory):
            os.makedirs(destinationDirectory)

        destinationPath = os.path.join(destinationDirectory, pluginName)
        shutil.copy2(sourcePath, destinationPath)
        if verbose:
            print("Copied:", sourcePath)
            print(" to:", destinationPath)

        if strip:
            runStrip(destinationPath, verbose)

        dependencies = getFrameworks(destinationPath, verbose)

        for dependency in dependencies:
            changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)

            # Deploy framework if necessary.
            if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
                deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)

ap = ArgumentParser(description="""Improved version of macdeployqt.

Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .dmg file.
Note, that the "dist" folder will be deleted before deploying on each run.

Optionally, Qt translation files (.qm) can be added to the bundle.""")

ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
ap.add_argument("appname", nargs=1, metavar="appname", help="name of the app being deployed")
ap.add_argument("-verbose", nargs="?", const=True, help="Output additional debugging information")
ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment")
ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries")
ap.add_argument("-dmg", nargs="?", const="", metavar="basename", help="create a .dmg disk image")
ap.add_argument("-plugin-dir", nargs=1, metavar="path", default=None, help="Path to Qt's plugins directory (e.g. from qtpaths --query QT_INSTALL_PLUGINS)")
ap.add_argument("-translations-dir", nargs=1, metavar="path", default=None, help="Path to Qt's translations. Base translations will automatically be added to the bundle's resources.")
ap.add_argument("-sign", nargs="?", const="-", default=None, metavar="identity",
    help="Code signing identity. Omit flag to skip signing. "
         "Use without value for ad-hoc ('-').")
ap.add_argument("-entitlements", nargs=1, default=None, metavar="path",
    help="Path to entitlements plist for hardened runtime signing")

config = ap.parse_args()

# The signing identity may come from the APPLE_SIGNING_IDENTITY environment
# variable instead of the -sign flag, because the identity string often
# contains parentheses that break shell command-line expansion in CMake.
# Priority: -sign <value> > APPLE_SIGNING_IDENTITY env var > ad-hoc on macOS.
env_identity = os.getenv("APPLE_SIGNING_IDENTITY")
if config.sign is None and env_identity:
    config.sign = env_identity
elif config.sign is None and platform.system() == "Darwin":
    config.sign = "-"

verbose = config.verbose

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

app_bundle = config.app_bundle[0]
appname = config.appname[0]

if not os.path.exists(app_bundle):
    sys.stderr.write(f"Error: Could not find app bundle \"{app_bundle}\"\n")
    sys.exit(1)

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

# When -dmg is given with an explicit basename, use it for the output file;
# otherwise fall back to appname.  The volume name always uses appname so that
# the mounted disk appears as e.g. "Gridcoin" regardless of the .dmg filename.
dmg_basename = config.dmg if (config.dmg is not None and config.dmg != "") else appname

if os.path.exists("dist"):
    print("+ Removing existing dist folder +")
    shutil.rmtree("dist")

if os.path.exists(dmg_basename + ".dmg"):
    print("+ Removing existing DMG +")
    os.unlink(dmg_basename + ".dmg")

if os.path.exists(dmg_basename + ".temp.dmg"):
    os.unlink(dmg_basename + ".temp.dmg")

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

target = os.path.join("dist", "Gridcoin.app")

print("+ Copying source bundle +")
if verbose:
    print(app_bundle, "->", target)

os.mkdir("dist")
shutil.copytree(app_bundle, target, symlinks=True)

applicationBundle = ApplicationBundleInfo(target)

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

print("+ Deploying frameworks +")

try:
    deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
    if deploymentInfo.qtPath is None:
        deploymentInfo.qtPath = os.getenv("QTDIR", None)
        if deploymentInfo.qtPath is None:
            sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
            config.plugins = False
except RuntimeError as e:
    sys.stderr.write(f"Error: {str(e)}\n")
    sys.exit(1)

# Override plugin path if explicitly provided via -plugin-dir
if config.plugin_dir is not None:
    explicit_plugin_dir = config.plugin_dir[0]
    if os.path.isdir(explicit_plugin_dir):
        deploymentInfo.pluginPath = explicit_plugin_dir
    else:
        sys.stderr.write(f"Warning: -plugin-dir path does not exist: {explicit_plugin_dir}\n")

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

if config.plugins:
    print("+ Deploying plugins +")

    if deploymentInfo.pluginPath is None:
        sys.stderr.write("Warning: Qt plugin path not found, skipping plugin deployment!\n")
    else:
        try:
            deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
        except RuntimeError as e:
            sys.stderr.write(f"Error: {str(e)}\n")
            sys.exit(1)

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

if config.translations_dir:
    if not Path(config.translations_dir[0]).exists():
        sys.stderr.write(f"Error: Could not find translation dir \"{config.translations_dir[0]}\"\n")
        sys.exit(1)

    print("+ Adding Qt translations +")

    translations = Path(config.translations_dir[0])

    regex = re.compile('qt_[a-z]*(.qm|_[A-Z]*.qm)')

    lang_files = [x for x in translations.iterdir() if regex.match(x.name)]

    for file in lang_files:
        if verbose:
            print(file.as_posix(), "->", os.path.join(applicationBundle.resourcesPath, file.name))
        shutil.copy2(file.as_posix(), os.path.join(applicationBundle.resourcesPath, file.name))

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

print("+ Installing qt.conf +")

qt_conf="""[Paths]
Translations=Resources
Plugins=PlugIns
"""

with open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") as f:
    f.write(qt_conf.encode())

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

if HAS_DS_STORE:
    print("+ Generating .DS_Store +")

    output_file = os.path.join("dist", ".DS_Store")

    ds = DSStore.open(output_file, 'w+')

    ds['.']['bwsp'] = {
        'WindowBounds': '{{300, 280}, {500, 343}}',
        'PreviewPaneVisibility': False,
    }

    icvp = {
        'gridOffsetX': 0.0,
        'textSize': 12.0,
        'viewOptionsVersion': 1,
        'backgroundImageAlias': b'\x00\x00\x00\x00\x02\x1e\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xd1\x94\\\xb0H+\x00\x05\x00\x00\x00\x98\x0fbackground.tiff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x99\xd19\xb0\xf8\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\r\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b.background\x00\x00\x10\x00\x08\x00\x00\xd1\x94\\\xb0\x00\x00\x00\x11\x00\x08\x00\x00\xd19\xb0\xf8\x00\x00\x00\x01\x00\x04\x00\x00\x00\x98\x00\x0e\x00 \x00\x0f\x00b\x00a\x00c\x00k\x00g\x00r\x00o\x00u\x00n\x00d\x00.\x00t\x00i\x00f\x00f\x00\x0f\x00\x02\x00\x00\x00\x12\x00\x1c/.background/background.tiff\x00\x14\x01\x06\x00\x00\x00\x00\x01\x06\x00\x02\x00\x00\x0cMacintosh HD\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xce\x97\xab\xc3H+\x00\x00\x01\x88[\x88\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02u\xab\x8d\xd1\x94\\\xb0devrddsk\xff\xff\xff\xff\x00\x00\t \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07bitcoin\x00\x00\x10\x00\x08\x00\x00\xce\x97\xab\xc3\x00\x00\x00\x11\x00\x08\x00\x00\xd1\x94\\\xb0\x00\x00\x00\x01\x00\x14\x01\x88[\x88\x00\x16\xa9\t\x00\x08\xfaR\x00\x08\xfaQ\x00\x02d\x8e\x00\x0e\x00\x02\x00\x00\x00\x0f\x00\x1a\x00\x0c\x00M\x00a\x00c\x00i\x00n\x00t\x00o\x00s\x00h\x00 \x00H\x00D\x00\x13\x00\x01/\x00\x00\x15\x00\x02\x00\x14\xff\xff\x00\x00\xff\xff\x00\x00',
        'backgroundColorBlue': 1.0,
        'iconSize': 96.0,
        'backgroundColorGreen': 1.0,
        'arrangeBy': 'none',
        'showIconPreview': True,
        'gridSpacing': 100.0,
        'gridOffsetY': 0.0,
        'showItemInfo': False,
        'labelOnBottom': True,
        'backgroundType': 2,
        'backgroundColorRed': 1.0
    }
    alias = Alias().from_bytes(icvp['backgroundImageAlias'])
    alias.volume.name = appname
    alias.volume.posix_path = '/Volumes/' + appname
    icvp['backgroundImageAlias'] = alias.to_bytes()
    ds['.']['icvp'] = icvp

    ds['.']['vSrn'] = ('long', 1)

    ds['Applications']['Iloc'] = (370, 156)
    ds['Gridcoin.app']['Iloc'] = (128, 156)

    ds.flush()
    ds.close()
else:
    print("+ Skipping .DS_Store generation (ds_store/mac_alias packages not installed) +")

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

if config.sign is not None and platform.system() == "Darwin":
    identity = config.sign
    entitlements_path = config.entitlements[0] if config.entitlements else None
    # Use hardened runtime only for real identities (not ad-hoc).
    hardened = (identity != "-") and (entitlements_path is not None)

    print("+ Code signing (inside-out) +")
    print(f"  Identity: {identity}")
    print(f"  Hardened runtime: {hardened}")
    if entitlements_path:
        print(f"  Entitlements: {entitlements_path}")

    # 1. Sign all dylibs and frameworks in Contents/Frameworks/
    frameworks_dir = os.path.join(target, "Contents", "Frameworks")
    if os.path.exists(frameworks_dir):
        for item in sorted(os.listdir(frameworks_dir)):
            item_path = os.path.join(frameworks_dir, item)
            if item.endswith(".dylib"):
                codesignItem(item_path, identity, None, hardened, verbose)
            elif item.endswith(".framework"):
                codesignItem(item_path, identity, None, hardened, verbose)

    # 2. Sign all plugins in Contents/PlugIns/
    plugins_dir = os.path.join(target, "Contents", "PlugIns")
    if os.path.exists(plugins_dir):
        for dirpath, dirnames, filenames in os.walk(plugins_dir):
            for filename in sorted(filenames):
                if filename.endswith(".dylib"):
                    codesignItem(os.path.join(dirpath, filename),
                                 identity, None, hardened, verbose)

    # 3. Sign the main executable (with entitlements)
    main_binary = os.path.join(target, "Contents", "MacOS", "gridcoinresearch")
    codesignItem(main_binary, identity, entitlements_path, hardened, verbose)

    # 4. Sign the top-level app bundle (with entitlements)
    codesignItem(target, identity, entitlements_path, hardened, verbose)

    # 5. Verify
    print("+ Verifying code signature +")
    subprocess.check_call(["codesign", "--verify", "--strict", "--verbose=2", target])

if config.dmg is not None:

    print("+ Preparing .dmg disk image +")

    if verbose:
        print("Determining size of \"dist\"...")
    size = 0
    for path, dirs, files in os.walk("dist"):
        for file in files:
            size += os.path.getsize(os.path.join(path, file))
    size += int(size * 0.15)

    if verbose:
        print("Creating temp image for modification...")

    tempname: str = dmg_basename + ".temp.dmg"

    run(["hdiutil", "create", tempname, "-srcfolder", "dist", "-format", "UDRW", "-size", str(size), "-volname", appname], check=True, universal_newlines=True)

    if verbose:
        print("Attaching temp image...")
    output = run(["hdiutil", "attach", tempname, "-readwrite"], check=True, universal_newlines=True, stdout=PIPE).stdout

    m = re.search(r"/Volumes/(.+$)", output)
    disk_root = m.group(0)

    print("+ Applying fancy settings +")

    bg_file = 'background.tiff'
    if os.path.exists(bg_file):
        bg_path = os.path.join(disk_root, ".background", os.path.basename(bg_file))
        os.mkdir(os.path.dirname(bg_path))
        if verbose:
            print(bg_file, "->", bg_path)
        shutil.copy2(bg_file, bg_path)
    else:
        print("  (Skipping background image: background.tiff not found)")

    os.symlink("/Applications", os.path.join(disk_root, "Applications"))

    print("+ Finalizing .dmg disk image +")

    run(["hdiutil", "detach", f"/Volumes/{appname}"], universal_newlines=True)

    run(["hdiutil", "convert", tempname, "-format", "UDZO", "-o", dmg_basename, "-imagekey", "zlib-level=9"], check=True, universal_newlines=True)

    os.unlink(tempname)

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

print("+ Done +")

sys.exit(0)
