From: tobtoht Date: Wed, 15 Mar 2023 13:31:45 +0000 (+0100) Subject: macOS: deterministic bundling X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=f2e284b2df8a09e6ea731a397b3f319fd2557634;p=gamesguru%2Ffeather.git macOS: deterministic bundling --- diff --git a/CMakeLists.txt b/CMakeLists.txt index 40a68f78..bb022f5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,18 +274,17 @@ add_subdirectory(src) configure_file("${CMAKE_SOURCE_DIR}/contrib/installers/windows/setup.nsi.in" "${CMAKE_SOURCE_DIR}/contrib/installers/windows/setup.nsi" @ONLY) -if(APPLE AND CMAKE_CROSSCOMPILING) - set(macos_app "Feather.app") - configure_file(contrib/macdeploy/Info.plist.in ${macos_app}/Contents/Info.plist @ONLY) - configure_file(src/assets/images/appicons/appicon.icns ${macos_app}/Contents/Resources/appicon.icns COPYONLY) - - add_custom_target(deploy - COMMAND "${CMAKE_COMMAND}" --install "${CMAKE_BINARY_DIR}" --config "$" --prefix "${CMAKE_BINARY_DIR}/release" --strip - COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_BINARY_DIR}/${macos_app}/Contents/MacOS" - COMMAND "${CMAKE_COMMAND}" -E rename "${CMAKE_BINARY_DIR}/bin/$" "${CMAKE_BINARY_DIR}/${macos_app}/Contents/MacOS/feather" - COMMAND PYTHONPATH=${PYTHONPATH} INSTALL_NAME_TOOL=${CMAKE_INSTALL_NAME_TOOL} OTOOL=${OTOOL} STRIP=${CMAKE_STRIP} ${CMAKE_SOURCE_DIR}/contrib/macdeploy/macdeployqtplus ${macos_app} ${PACKAGE_NAME} -zip - VERBATIM - ) +if(APPLE) + configure_file(${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist.in ${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist @ONLY) + + set_target_properties(feather PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/contrib/macdeploy/Info.plist" + LINK_FLAGS_RELEASE -s + ) + + file(COPY "${CMAKE_SOURCE_DIR}/src/assets/images/appicons/appicon.icns" DESTINATION "${CMAKE_SOURCE_DIR}/installed/feather.app/Contents/Resources/" ) endif() message("\n") diff --git a/contrib/guix/libexec/build.sh b/contrib/guix/libexec/build.sh index 0dade7f1..faa6d348 100755 --- a/contrib/guix/libexec/build.sh +++ b/contrib/guix/libexec/build.sh @@ -349,14 +349,6 @@ mkdir -p "$DISTSRC" ;; esac - # Make macOS DMG - case "$HOST" in - *darwin*) - make -C build deploy ${V:+V=1} - mv build/feather.zip "${OUTDIR}/${DISTNAME}.zip" - ;; - esac - ( cd installed @@ -364,6 +356,9 @@ mkdir -p "$DISTSRC" *linux*) mv feather "${DISTNAME}" ;; + *darwin*) + mv "feather.app" "Feather.app" + ;; esac # Finally, deterministically produce {non-,}debug binary tarballs ready @@ -408,6 +403,14 @@ mkdir -p "$DISTSRC" | zip -X@ "${OUTDIR}/${DISTNAME}-linux${LINUX_ARCH}-appimage${ANONDIST}.zip" \ || ( rm -f "${OUTDIR}/${DISTNAME}-linux${LINUX_ARCH}-appimage${ANONDIST}.zip" && exit 1 ) ;; + *darwin*) + find . -print0 \ + | xargs -0r touch --no-dereference --date="@${SOURCE_DATE_EPOCH}" + find . \ + | sort \ + | zip -X@ "${OUTDIR}/${DISTNAME}-mac.zip" \ + || ( rm -f "${OUTDIR}/${DISTNAME}-mac.zip" && exit 1 ) + ;; esac ) diff --git a/contrib/macdeploy/detached-sig-create.sh b/contrib/macdeploy/detached-sig-create.sh deleted file mode 100755 index f3933310..00000000 --- a/contrib/macdeploy/detached-sig-create.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Copyright (c) 2014-2021 The Bitcoin Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. - -export LC_ALL=C -set -e - -ROOTDIR=dist -BUNDLE="${ROOTDIR}/Bitcoin-Qt.app" -BINARY="${BUNDLE}/Contents/MacOS/Bitcoin-Qt" -SIGNAPPLE=signapple -TEMPDIR=sign.temp -ARCH=$(${SIGNAPPLE} info ${BINARY} | head -n 1 | cut -d " " -f 1) -OUT="signature-osx-${ARCH}.tar.gz" -OUTROOT=osx/dist - -if [ -z "$1" ]; then - echo "usage: $0 " - echo "example: $0 " - exit 1 -fi - -rm -rf ${TEMPDIR} -mkdir -p ${TEMPDIR} - -${SIGNAPPLE} sign -f --detach "${TEMPDIR}/${OUTROOT}" "$@" "${BUNDLE}" - -tar -C "${TEMPDIR}" -czf "${OUT}" . -rm -rf "${TEMPDIR}" -echo "Created ${OUT}" diff --git a/contrib/macdeploy/macdeployqtplus b/contrib/macdeploy/macdeployqtplus deleted file mode 100755 index 9988d471..00000000 --- a/contrib/macdeploy/macdeployqtplus +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2011 Patrick "p2k" Schneider -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -import sys, re, os, platform, shutil, stat, subprocess, os.path -from argparse import ArgumentParser -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 fromOtoolLibraryLine(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"otool line could not be parsed: {line}") - - path = 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("/") - i = 0 - # Search for the .framework directory - for part in parts: - if part.endswith(".framework"): - break - i += 1 - if i == len(parts): - raise RuntimeError(f"Could not find .framework or .dylib in otool 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 - self.binaryPath = os.path.join(path, "Contents", "MacOS", "feather") - 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: - pluginPath = os.path.join(self.qtPath, "plugins") - if os.path.exists(pluginPath): - self.pluginPath = pluginPath - - 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 getFrameworks(binaryPath: str, verbose: int) -> List[FrameworkInfo]: - if verbose: - print(f"Inspecting with otool: {binaryPath}") - otoolbin=os.getenv("OTOOL", "otool") - otool = run([otoolbin, "-L", binaryPath], stdout=PIPE, stderr=PIPE, universal_newlines=True) - if otool.returncode != 0: - sys.stderr.write(otool.stderr) - sys.stderr.flush() - raise RuntimeError(f"otool failed with return code {otool.returncode}") - - otoolLines = otool.stdout.split("\n") - otoolLines.pop(0) # First line is the inspected binary - if ".framework" in binaryPath or binaryPath.endswith(".dylib"): - otoolLines.pop(0) # Frameworks and dylibs list themselves as a dependency. - - libraries = [] - for line in otoolLines: - line = line.replace("@loader_path", os.path.dirname(binaryPath)) - info = FrameworkInfo.fromOtoolLibraryLine(line.strip()) - if info is not None: - if verbose: - print("Found framework:") - print(info) - libraries.append(info) - - return libraries - -def runInstallNameTool(action: str, *args): - installnametoolbin=os.getenv("INSTALL_NAME_TOOL", "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 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 - dependencies = getFrameworks(deployedBinaryPath, 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']: - continue - - for pluginName in filenames: - pluginPath = os.path.join(pluginDirectory, pluginName) - - if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle']: - 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 .zip 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("-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("-zip", nargs="?", const="", metavar="zip", help="create a .zip containing the app bundle") - -config = ap.parse_args() - -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) - -# ------------------------------------------------ - -if os.path.exists("dist"): - print("+ Removing existing dist folder +") - shutil.rmtree("dist") - -if os.path.exists(appname + ".zip"): - print("+ Removing existing .zip +") - os.unlink(appname + ".zip") - -# ------------------------------------------------ - -target = os.path.join("dist", "Feather.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) - -# ------------------------------------------------ - -if config.plugins: - print("+ Deploying plugins +") - - 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 platform.system() == "Darwin": - subprocess.check_call(f"codesign --deep --force --sign - {target}", shell=True) - -# ------------------------------------------------ - -print("+ Generating symlink for /Applications +") - -os.symlink("/Applications", os.path.join('dist', "Applications")) - -# ------------------------------------------------ - -if config.zip is not None: - shutil.make_archive('{}'.format(appname), format='zip', root_dir='dist', base_dir='Feather.app') - -# ------------------------------------------------ - -print("+ Done +") - -sys.exit(0)