macdeployqtplus raw

   1  #!/usr/bin/env python3
   2  #
   3  # Copyright (C) 2011  Patrick "p2k" Schneider <me@p2k-network.org>
   4  #
   5  # This program is free software: you can redistribute it and/or modify
   6  # it under the terms of the GNU General Public License as published by
   7  # the Free Software Foundation, either version 3 of the License, or
   8  # (at your option) any later version.
   9  #
  10  # This program is distributed in the hope that it will be useful,
  11  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  13  # GNU General Public License for more details.
  14  #
  15  # You should have received a copy of the GNU General Public License
  16  # along with this program.  If not, see <http://www.gnu.org/licenses/>.
  17  #
  18  
  19  import sys, re, os, platform, shutil, stat, subprocess, os.path
  20  from argparse import ArgumentParser
  21  from pathlib import Path
  22  from subprocess import PIPE, run
  23  from typing import Optional
  24  
  25  # This is ported from the original macdeployqt with modifications
  26  
  27  class FrameworkInfo(object):
  28      def __init__(self):
  29          self.frameworkDirectory = ""
  30          self.frameworkName = ""
  31          self.frameworkPath = ""
  32          self.binaryDirectory = ""
  33          self.binaryName = ""
  34          self.binaryPath = ""
  35          self.version = ""
  36          self.installName = ""
  37          self.deployedInstallName = ""
  38          self.sourceFilePath = ""
  39          self.destinationDirectory = ""
  40          self.sourceResourcesDirectory = ""
  41          self.sourceVersionContentsDirectory = ""
  42          self.sourceContentsDirectory = ""
  43          self.destinationResourcesDirectory = ""
  44          self.destinationVersionContentsDirectory = ""
  45  
  46      def __eq__(self, other):
  47          if self.__class__ == other.__class__:
  48              return self.__dict__ == other.__dict__
  49          else:
  50              return False
  51  
  52      def __str__(self):
  53          return f""" Framework name: {self.frameworkName}
  54   Framework directory: {self.frameworkDirectory}
  55   Framework path: {self.frameworkPath}
  56   Binary name: {self.binaryName}
  57   Binary directory: {self.binaryDirectory}
  58   Binary path: {self.binaryPath}
  59   Version: {self.version}
  60   Install name: {self.installName}
  61   Deployed install name: {self.deployedInstallName}
  62   Source file Path: {self.sourceFilePath}
  63   Deployed Directory (relative to bundle): {self.destinationDirectory}
  64  """
  65  
  66      def isDylib(self):
  67          return self.frameworkName.endswith(".dylib")
  68  
  69      def isQtFramework(self):
  70          if self.isDylib():
  71              return self.frameworkName.startswith("libQt")
  72          else:
  73              return self.frameworkName.startswith("Qt")
  74  
  75      reOLine = re.compile(r'^(.+) \(compatibility version [0-9.]+, current version [0-9.]+\)$')
  76      bundleFrameworkDirectory = "Contents/Frameworks"
  77      bundleBinaryDirectory = "Contents/MacOS"
  78  
  79      @classmethod
  80      def fromLibraryLine(cls, line: str) -> Optional['FrameworkInfo']:
  81          # Note: line must be trimmed
  82          if line == "":
  83              return None
  84  
  85          # Don't deploy system libraries
  86          if line.startswith("/System/Library/") or line.startswith("@executable_path") or line.startswith("/usr/lib/"):
  87              return None
  88  
  89          m = cls.reOLine.match(line)
  90          if m is None:
  91              raise RuntimeError(f"Line could not be parsed: {line}")
  92  
  93          path = m.group(1)
  94  
  95          info = cls()
  96          info.sourceFilePath = path
  97          info.installName = path
  98  
  99          if path.endswith(".dylib"):
 100              dirname, filename = os.path.split(path)
 101              info.frameworkName = filename
 102              info.frameworkDirectory = dirname
 103              info.frameworkPath = path
 104  
 105              info.binaryDirectory = dirname
 106              info.binaryName = filename
 107              info.binaryPath = path
 108              info.version = "-"
 109  
 110              info.installName = path
 111              info.deployedInstallName = f"@executable_path/../Frameworks/{info.binaryName}"
 112              info.sourceFilePath = path
 113              info.destinationDirectory = cls.bundleFrameworkDirectory
 114          else:
 115              parts = path.split("/")
 116              i = 0
 117              # Search for the .framework directory
 118              for part in parts:
 119                  if part.endswith(".framework"):
 120                      break
 121                  i += 1
 122              if i == len(parts):
 123                  raise RuntimeError(f"Could not find .framework or .dylib in line: {line}")
 124  
 125              info.frameworkName = parts[i]
 126              info.frameworkDirectory = "/".join(parts[:i])
 127              info.frameworkPath = os.path.join(info.frameworkDirectory, info.frameworkName)
 128  
 129              info.binaryName = parts[i+3]
 130              info.binaryDirectory = "/".join(parts[i+1:i+3])
 131              info.binaryPath = os.path.join(info.binaryDirectory, info.binaryName)
 132              info.version = parts[i+2]
 133  
 134              info.deployedInstallName = f"@executable_path/../Frameworks/{os.path.join(info.frameworkName, info.binaryPath)}"
 135              info.destinationDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, info.binaryDirectory)
 136  
 137              info.sourceResourcesDirectory = os.path.join(info.frameworkPath, "Resources")
 138              info.sourceContentsDirectory = os.path.join(info.frameworkPath, "Contents")
 139              info.sourceVersionContentsDirectory = os.path.join(info.frameworkPath, "Versions", info.version, "Contents")
 140              info.destinationResourcesDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Resources")
 141              info.destinationVersionContentsDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Versions", info.version, "Contents")
 142  
 143          return info
 144  
 145  class ApplicationBundleInfo(object):
 146      def __init__(self, path: str):
 147          self.path = path
 148          # for backwards compatibility reasons, this must remain as Bitcoin-Qt
 149          self.binaryPath = os.path.join(path, "Contents", "MacOS", "Bitcoin-Qt")
 150          if not os.path.exists(self.binaryPath):
 151              raise RuntimeError(f"Could not find bundle binary for {path}")
 152          self.resourcesPath = os.path.join(path, "Contents", "Resources")
 153          self.pluginPath = os.path.join(path, "Contents", "PlugIns")
 154  
 155  class DeploymentInfo(object):
 156      def __init__(self):
 157          self.qtPath = None
 158          self.pluginPath = None
 159          self.deployedFrameworks = []
 160  
 161      def detectQtPath(self, frameworkDirectory: str):
 162          parentDir = os.path.dirname(frameworkDirectory)
 163          if os.path.exists(os.path.join(parentDir, "share", "qt", "plugins")):
 164              self.qtPath = parentDir
 165          else:
 166              self.qtPath = os.getenv("QTDIR", None)
 167  
 168          if self.qtPath is not None:
 169              pluginPath = os.path.join(self.qtPath, "share", "qt", "plugins")
 170              if os.path.exists(pluginPath):
 171                  self.pluginPath = pluginPath
 172  
 173      def usesFramework(self, name: str) -> bool:
 174          for framework in self.deployedFrameworks:
 175              if framework.endswith(".framework"):
 176                  if framework.startswith(f"{name}."):
 177                      return True
 178              elif framework.endswith(".dylib"):
 179                  if framework.startswith(f"lib{name}."):
 180                      return True
 181          return False
 182  
 183  def getFrameworks(binaryPath: str, verbose: int, rpath: str = '') -> list[FrameworkInfo]:
 184      objdump = os.getenv("OBJDUMP", "objdump")
 185      if verbose:
 186          print(f"Inspecting with {objdump}: {binaryPath}")
 187      output = run([objdump, "--macho", "--dylibs-used", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
 188      if output.returncode != 0:
 189          sys.stderr.write(output.stderr)
 190          sys.stderr.flush()
 191          raise RuntimeError(f"{objdump} failed with return code {output.returncode}")
 192  
 193      lines = output.stdout.split("\n")
 194      lines.pop(0) # First line is the inspected binary
 195      if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
 196          lines.pop(0) # Frameworks and dylibs list themselves as a dependency.
 197  
 198      libraries = []
 199      for line in lines:
 200          line = line.replace("@loader_path", os.path.dirname(binaryPath))
 201          if rpath:
 202              line = line.replace("@rpath", rpath)
 203          info = FrameworkInfo.fromLibraryLine(line.strip())
 204          if info is not None:
 205              if verbose:
 206                  print("Found framework:")
 207                  print(info)
 208              libraries.append(info)
 209  
 210      return libraries
 211  
 212  def runInstallNameTool(action: str, *args):
 213      installnametoolbin=os.getenv("INSTALL_NAME_TOOL", "install_name_tool")
 214      run([installnametoolbin, "-"+action] + list(args), check=True)
 215  
 216  def changeInstallName(oldName: str, newName: str, binaryPath: str, verbose: int):
 217      if verbose:
 218          print("Using install_name_tool:")
 219          print(" in", binaryPath)
 220          print(" change reference", oldName)
 221          print(" to", newName)
 222      runInstallNameTool("change", oldName, newName, binaryPath)
 223  
 224  def changeIdentification(id: str, binaryPath: str, verbose: int):
 225      if verbose:
 226          print("Using install_name_tool:")
 227          print(" change identification in", binaryPath)
 228          print(" to", id)
 229      runInstallNameTool("id", id, binaryPath)
 230  
 231  def runStrip(binaryPath: str, verbose: int):
 232      stripbin=os.getenv("STRIP", "strip")
 233      if verbose:
 234          print("Using strip:")
 235          print(" stripped", binaryPath)
 236      run([stripbin, "-x", binaryPath], check=True)
 237  
 238  def copyFramework(framework: FrameworkInfo, path: str, verbose: int) -> Optional[str]:
 239      if framework.sourceFilePath.startswith("Qt"):
 240          #standard place for Nokia Qt installer's frameworks
 241          fromPath = f"/Library/Frameworks/{framework.sourceFilePath}"
 242      else:
 243          fromPath = framework.sourceFilePath
 244      toDir = os.path.join(path, framework.destinationDirectory)
 245      toPath = os.path.join(toDir, framework.binaryName)
 246  
 247      if framework.isDylib():
 248          if not os.path.exists(fromPath):
 249              raise RuntimeError(f"No file at {fromPath}")
 250  
 251          if os.path.exists(toPath):
 252              return None # Already there
 253  
 254          if not os.path.exists(toDir):
 255              os.makedirs(toDir)
 256  
 257          shutil.copy2(fromPath, toPath)
 258          if verbose:
 259              print("Copied:", fromPath)
 260              print(" to:", toPath)
 261      else:
 262          to_dir = os.path.join(path, "Contents", "Frameworks", framework.frameworkName)
 263          if os.path.exists(to_dir):
 264              return None # Already there
 265  
 266          from_dir = framework.frameworkPath
 267          if not os.path.exists(from_dir):
 268              raise RuntimeError(f"No directory at {from_dir}")
 269  
 270          shutil.copytree(from_dir, to_dir, symlinks=True)
 271          if verbose:
 272              print("Copied:", from_dir)
 273              print(" to:", to_dir)
 274  
 275          headers_link = os.path.join(to_dir, "Headers")
 276          if os.path.exists(headers_link):
 277              os.unlink(headers_link)
 278  
 279          headers_dir = os.path.join(to_dir, framework.binaryDirectory, "Headers")
 280          if os.path.exists(headers_dir):
 281              shutil.rmtree(headers_dir)
 282  
 283      permissions = os.stat(toPath)
 284      if not permissions.st_mode & stat.S_IWRITE:
 285        os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)
 286  
 287      return toPath
 288  
 289  def deployFrameworks(frameworks: list[FrameworkInfo], bundlePath: str, binaryPath: str, strip: bool, verbose: int, deploymentInfo: Optional[DeploymentInfo] = None) -> DeploymentInfo:
 290      if deploymentInfo is None:
 291          deploymentInfo = DeploymentInfo()
 292  
 293      while len(frameworks) > 0:
 294          framework = frameworks.pop(0)
 295          deploymentInfo.deployedFrameworks.append(framework.frameworkName)
 296  
 297          print("Processing", framework.frameworkName, "...")
 298  
 299          # Get the Qt path from one of the Qt frameworks
 300          if deploymentInfo.qtPath is None and framework.isQtFramework():
 301              deploymentInfo.detectQtPath(framework.frameworkDirectory)
 302  
 303          if framework.installName.startswith("@executable_path") or framework.installName.startswith(bundlePath):
 304              print(framework.frameworkName, "already deployed, skipping.")
 305              continue
 306  
 307          # install_name_tool the new id into the binary
 308          changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
 309  
 310          # Copy framework to app bundle.
 311          deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
 312          # Skip the rest if already was deployed.
 313          if deployedBinaryPath is None:
 314              continue
 315  
 316          if strip:
 317              runStrip(deployedBinaryPath, verbose)
 318  
 319          # install_name_tool it a new id.
 320          changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
 321          # Check for framework dependencies
 322          dependencies = getFrameworks(deployedBinaryPath, verbose, rpath=framework.frameworkDirectory)
 323  
 324          for dependency in dependencies:
 325              changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
 326  
 327              # Deploy framework if necessary.
 328              if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
 329                  frameworks.append(dependency)
 330  
 331      return deploymentInfo
 332  
 333  def deployFrameworksForAppBundle(applicationBundle: ApplicationBundleInfo, strip: bool, verbose: int) -> DeploymentInfo:
 334      frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
 335      if len(frameworks) == 0:
 336          print(f"Warning: Could not find any external frameworks to deploy in {applicationBundle.path}.")
 337          return DeploymentInfo()
 338      else:
 339          return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
 340  
 341  def deployPlugins(appBundleInfo: ApplicationBundleInfo, deploymentInfo: DeploymentInfo, strip: bool, verbose: int):
 342      plugins = []
 343      if deploymentInfo.pluginPath is None:
 344          return
 345      for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
 346          pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
 347  
 348          if pluginDirectory not in ['styles', 'platforms']:
 349              continue
 350  
 351          for pluginName in filenames:
 352              pluginPath = os.path.join(pluginDirectory, pluginName)
 353  
 354              if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle']:
 355                  continue
 356  
 357              plugins.append((pluginDirectory, pluginName))
 358  
 359      for pluginDirectory, pluginName in plugins:
 360          print("Processing plugin", os.path.join(pluginDirectory, pluginName), "...")
 361  
 362          sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
 363          destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
 364          if not os.path.exists(destinationDirectory):
 365              os.makedirs(destinationDirectory)
 366  
 367          destinationPath = os.path.join(destinationDirectory, pluginName)
 368          shutil.copy2(sourcePath, destinationPath)
 369          if verbose:
 370              print("Copied:", sourcePath)
 371              print(" to:", destinationPath)
 372  
 373          if strip:
 374              runStrip(destinationPath, verbose)
 375  
 376          dependencies = getFrameworks(destinationPath, verbose)
 377  
 378          for dependency in dependencies:
 379              changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
 380  
 381              # Deploy framework if necessary.
 382              if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
 383                  deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
 384  
 385  ap = ArgumentParser(description="""Improved version of macdeployqt.
 386  
 387  Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .zip file.
 388  Note, that the "dist" folder will be deleted before deploying on each run.
 389  
 390  Optionally, Qt translation files (.qm) can be added to the bundle.""")
 391  
 392  ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
 393  ap.add_argument("-verbose", nargs="?", const=True, help="Output additional debugging information")
 394  ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment")
 395  ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries")
 396  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.")
 397  ap.add_argument("-zip", nargs=1, metavar="zip", help="create a .zip containing the app bundle")
 398  
 399  config = ap.parse_args()
 400  
 401  verbose = config.verbose
 402  
 403  # ------------------------------------------------
 404  
 405  app_bundle = config.app_bundle[0]
 406  
 407  if not os.path.exists(app_bundle):
 408      sys.stderr.write(f"Error: Could not find app bundle \"{app_bundle}\"\n")
 409      sys.exit(1)
 410  
 411  # ------------------------------------------------
 412  
 413  if os.path.exists("dist"):
 414      print("+ Removing existing dist folder +")
 415      shutil.rmtree("dist")
 416  
 417  # ------------------------------------------------
 418  
 419  target = os.path.join("dist", "Bitcoin-Qt.app")
 420  
 421  print("+ Copying source bundle +")
 422  if verbose:
 423      print(app_bundle, "->", target)
 424  
 425  os.mkdir("dist")
 426  shutil.copytree(app_bundle, target, symlinks=True)
 427  
 428  applicationBundle = ApplicationBundleInfo(target)
 429  
 430  # ------------------------------------------------
 431  
 432  print("+ Deploying frameworks +")
 433  
 434  try:
 435      deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
 436      if deploymentInfo.qtPath is None:
 437          deploymentInfo.qtPath = os.getenv("QTDIR", None)
 438          if deploymentInfo.qtPath is None:
 439              sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
 440              config.plugins = False
 441  except RuntimeError as e:
 442      sys.stderr.write(f"Error: {str(e)}\n")
 443      sys.exit(1)
 444  
 445  # ------------------------------------------------
 446  
 447  if config.plugins:
 448      print("+ Deploying plugins +")
 449  
 450      try:
 451          deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
 452      except RuntimeError as e:
 453          sys.stderr.write(f"Error: {str(e)}\n")
 454          sys.exit(1)
 455  
 456  # ------------------------------------------------
 457  
 458  if config.translations_dir:
 459      if not Path(config.translations_dir[0]).exists():
 460          sys.stderr.write(f"Error: Could not find translation dir \"{config.translations_dir[0]}\"\n")
 461          sys.exit(1)
 462  
 463      print("+ Adding Qt translations +")
 464  
 465      translations = Path(config.translations_dir[0])
 466  
 467      regex = re.compile('qt_[a-z]*(.qm|_[A-Z]*.qm)')
 468  
 469      lang_files = [x for x in translations.iterdir() if regex.match(x.name)]
 470  
 471      for file in lang_files:
 472          if verbose:
 473              print(file.as_posix(), "->", os.path.join(applicationBundle.resourcesPath, file.name))
 474          shutil.copy2(file.as_posix(), os.path.join(applicationBundle.resourcesPath, file.name))
 475  
 476  # ------------------------------------------------
 477  
 478  print("+ Installing qt.conf +")
 479  
 480  qt_conf="""[Paths]
 481  Translations=Resources
 482  Plugins=PlugIns
 483  """
 484  
 485  with open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") as f:
 486      f.write(qt_conf.encode())
 487  
 488  # ------------------------------------------------
 489  
 490  if platform.system() == "Darwin":
 491      subprocess.check_call(f"codesign --deep --force --sign - {target}", shell=True)
 492  
 493  # ------------------------------------------------
 494  
 495  if config.zip is not None:
 496      name = config.zip[0]
 497  
 498      if os.path.exists(name + ".zip"):
 499          print("+ Removing existing .zip +")
 500          os.unlink(name + ".zip")
 501  
 502      subprocess.check_call(["zip", "-ry", os.path.abspath(name + ".zip"), 'Bitcoin-Qt.app'], cwd='dist')
 503  
 504  # ------------------------------------------------
 505  
 506  print("+ Done +")
 507  
 508  sys.exit(0)
 509