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 Limenka-Qt
 149          self.binaryPath = os.path.join(path, "Contents", "MacOS", "Limenka-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, "translations")):
 164              # Classic layout, e.g. "/usr/local/Trolltech/Qt-4.x.x"
 165              self.qtPath = parentDir
 166          else:
 167              self.qtPath = os.getenv("QTDIR", None)
 168  
 169          if self.qtPath is not None:
 170              pluginPath = os.path.join(self.qtPath, "plugins")
 171              if os.path.exists(pluginPath):
 172                  self.pluginPath = pluginPath
 173      
 174      def usesFramework(self, name: str) -> bool:
 175          for framework in self.deployedFrameworks:
 176              if framework.endswith(".framework"):
 177                  if framework.startswith(f"{name}."):
 178                      return True
 179              elif framework.endswith(".dylib"):
 180                  if framework.startswith(f"lib{name}."):
 181                      return True
 182          return False
 183  
 184  def getFrameworks(binaryPath: str, verbose: int) -> list[FrameworkInfo]:
 185      objdump = os.getenv("OBJDUMP", "objdump")
 186      if verbose:
 187          print(f"Inspecting with {objdump}: {binaryPath}")
 188      output = run([objdump, "--macho", "--dylibs-used", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
 189      if output.returncode != 0:
 190          sys.stderr.write(output.stderr)
 191          sys.stderr.flush()
 192          raise RuntimeError(f"{objdump} failed with return code {output.returncode}")
 193  
 194      lines = output.stdout.split("\n")
 195      lines.pop(0) # First line is the inspected binary
 196      if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
 197          lines.pop(0) # Frameworks and dylibs list themselves as a dependency.
 198      
 199      libraries = []
 200      for line in lines:
 201          line = line.replace("@loader_path", os.path.dirname(binaryPath))
 202          info = FrameworkInfo.fromLibraryLine(line.strip())
 203          if info is not None:
 204              if verbose:
 205                  print("Found framework:")
 206                  print(info)
 207              libraries.append(info)
 208      
 209      return libraries
 210  
 211  def runInstallNameTool(action: str, *args):
 212      installnametoolbin=os.getenv("INSTALL_NAME_TOOL", "install_name_tool")
 213      run([installnametoolbin, "-"+action] + list(args), check=True)
 214  
 215  def changeInstallName(oldName: str, newName: str, binaryPath: str, verbose: int):
 216      if verbose:
 217          print("Using install_name_tool:")
 218          print(" in", binaryPath)
 219          print(" change reference", oldName)
 220          print(" to", newName)
 221      runInstallNameTool("change", oldName, newName, binaryPath)
 222  
 223  def changeIdentification(id: str, binaryPath: str, verbose: int):
 224      if verbose:
 225          print("Using install_name_tool:")
 226          print(" change identification in", binaryPath)
 227          print(" to", id)
 228      runInstallNameTool("id", id, binaryPath)
 229  
 230  def runStrip(binaryPath: str, verbose: int):
 231      stripbin=os.getenv("STRIP", "strip")
 232      if verbose:
 233          print("Using strip:")
 234          print(" stripped", binaryPath)
 235      run([stripbin, "-x", binaryPath], check=True)
 236  
 237  def copyFramework(framework: FrameworkInfo, path: str, verbose: int) -> Optional[str]:
 238      if framework.sourceFilePath.startswith("Qt"):
 239          #standard place for Nokia Qt installer's frameworks
 240          fromPath = f"/Library/Frameworks/{framework.sourceFilePath}"
 241      else:
 242          fromPath = framework.sourceFilePath
 243      toDir = os.path.join(path, framework.destinationDirectory)
 244      toPath = os.path.join(toDir, framework.binaryName)
 245  
 246      if framework.isDylib():
 247          if not os.path.exists(fromPath):
 248              raise RuntimeError(f"No file at {fromPath}")
 249  
 250          if os.path.exists(toPath):
 251              return None # Already there
 252  
 253          if not os.path.exists(toDir):
 254              os.makedirs(toDir)
 255  
 256          shutil.copy2(fromPath, toPath)
 257          if verbose:
 258              print("Copied:", fromPath)
 259              print(" to:", toPath)
 260      else:
 261          to_dir = os.path.join(path, "Contents", "Frameworks", framework.frameworkName)
 262          if os.path.exists(to_dir):
 263              return None # Already there
 264  
 265          from_dir = framework.frameworkPath
 266          if not os.path.exists(from_dir):
 267              raise RuntimeError(f"No directory at {from_dir}")
 268  
 269          shutil.copytree(from_dir, to_dir, symlinks=True)
 270          if verbose:
 271              print("Copied:", from_dir)
 272              print(" to:", to_dir)
 273  
 274          headers_link = os.path.join(to_dir, "Headers")
 275          if os.path.exists(headers_link):
 276              os.unlink(headers_link)
 277  
 278          headers_dir = os.path.join(to_dir, framework.binaryDirectory, "Headers")
 279          if os.path.exists(headers_dir):
 280              shutil.rmtree(headers_dir)
 281  
 282      permissions = os.stat(toPath)
 283      if not permissions.st_mode & stat.S_IWRITE:
 284        os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)
 285  
 286      return toPath
 287  
 288  def deployFrameworks(frameworks: list[FrameworkInfo], bundlePath: str, binaryPath: str, strip: bool, verbose: int, deploymentInfo: Optional[DeploymentInfo] = None) -> DeploymentInfo:
 289      if deploymentInfo is None:
 290          deploymentInfo = DeploymentInfo()
 291      
 292      while len(frameworks) > 0:
 293          framework = frameworks.pop(0)
 294          deploymentInfo.deployedFrameworks.append(framework.frameworkName)
 295          
 296          print("Processing", framework.frameworkName, "...")
 297          
 298          # Get the Qt path from one of the Qt frameworks
 299          if deploymentInfo.qtPath is None and framework.isQtFramework():
 300              deploymentInfo.detectQtPath(framework.frameworkDirectory)
 301          
 302          if framework.installName.startswith("@executable_path") or framework.installName.startswith(bundlePath):
 303              print(framework.frameworkName, "already deployed, skipping.")
 304              continue
 305          
 306          # install_name_tool the new id into the binary
 307          changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
 308          
 309          # Copy framework to app bundle.
 310          deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
 311          # Skip the rest if already was deployed.
 312          if deployedBinaryPath is None:
 313              continue
 314          
 315          if strip:
 316              runStrip(deployedBinaryPath, verbose)
 317          
 318          # install_name_tool it a new id.
 319          changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
 320          # Check for framework dependencies
 321          dependencies = getFrameworks(deployedBinaryPath, verbose)
 322          
 323          for dependency in dependencies:
 324              changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
 325              
 326              # Deploy framework if necessary.
 327              if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
 328                  frameworks.append(dependency)
 329      
 330      return deploymentInfo
 331  
 332  def deployFrameworksForAppBundle(applicationBundle: ApplicationBundleInfo, strip: bool, verbose: int) -> DeploymentInfo:
 333      frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
 334      if len(frameworks) == 0:
 335          print(f"Warning: Could not find any external frameworks to deploy in {applicationBundle.path}.")
 336          return DeploymentInfo()
 337      else:
 338          return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
 339  
 340  def deployPlugins(appBundleInfo: ApplicationBundleInfo, deploymentInfo: DeploymentInfo, strip: bool, verbose: int):
 341      plugins = []
 342      if deploymentInfo.pluginPath is None:
 343          return
 344      for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
 345          pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
 346  
 347          if pluginDirectory not in ['styles', 'platforms']:
 348              continue
 349  
 350          for pluginName in filenames:
 351              pluginPath = os.path.join(pluginDirectory, pluginName)
 352  
 353              if pluginName.split('.')[0] not in ['libqminimal', 'libqcocoa', 'libqmacstyle']:
 354                  continue
 355  
 356              plugins.append((pluginDirectory, pluginName))
 357      
 358      for pluginDirectory, pluginName in plugins:
 359          print("Processing plugin", os.path.join(pluginDirectory, pluginName), "...")
 360          
 361          sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
 362          destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
 363          if not os.path.exists(destinationDirectory):
 364              os.makedirs(destinationDirectory)
 365          
 366          destinationPath = os.path.join(destinationDirectory, pluginName)
 367          shutil.copy2(sourcePath, destinationPath)
 368          if verbose:
 369              print("Copied:", sourcePath)
 370              print(" to:", destinationPath)
 371          
 372          if strip:
 373              runStrip(destinationPath, verbose)
 374          
 375          dependencies = getFrameworks(destinationPath, verbose)
 376          
 377          for dependency in dependencies:
 378              changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
 379              
 380              # Deploy framework if necessary.
 381              if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
 382                  deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
 383  
 384  ap = ArgumentParser(description="""Improved version of macdeployqt.
 385  
 386  Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .zip file.
 387  Note, that the "dist" folder will be deleted before deploying on each run.
 388  
 389  Optionally, Qt translation files (.qm) can be added to the bundle.""")
 390  
 391  ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
 392  ap.add_argument("appname", nargs=1, metavar="appname", help="name of the app being 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="?", const="", 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  appname = config.appname[0]
 407  
 408  if not os.path.exists(app_bundle):
 409      sys.stderr.write(f"Error: Could not find app bundle \"{app_bundle}\"\n")
 410      sys.exit(1)
 411  
 412  # ------------------------------------------------
 413  
 414  if os.path.exists("dist"):
 415      print("+ Removing existing dist folder +")
 416      shutil.rmtree("dist")
 417  
 418  if os.path.exists(appname + ".zip"):
 419      print("+ Removing existing .zip +")
 420      os.unlink(appname + ".zip")
 421  
 422  # ------------------------------------------------
 423  
 424  target = os.path.join("dist", "Limenka-Qt.app")
 425  
 426  print("+ Copying source bundle +")
 427  if verbose:
 428      print(app_bundle, "->", target)
 429  
 430  os.mkdir("dist")
 431  shutil.copytree(app_bundle, target, symlinks=True)
 432  
 433  applicationBundle = ApplicationBundleInfo(target)
 434  
 435  # ------------------------------------------------
 436  
 437  print("+ Deploying frameworks +")
 438  
 439  try:
 440      deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
 441      if deploymentInfo.qtPath is None:
 442          deploymentInfo.qtPath = os.getenv("QTDIR", None)
 443          if deploymentInfo.qtPath is None:
 444              sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
 445              config.plugins = False
 446  except RuntimeError as e:
 447      sys.stderr.write(f"Error: {str(e)}\n")
 448      sys.exit(1)
 449  
 450  # ------------------------------------------------
 451  
 452  if config.plugins:
 453      print("+ Deploying plugins +")
 454      
 455      try:
 456          deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
 457      except RuntimeError as e:
 458          sys.stderr.write(f"Error: {str(e)}\n")
 459          sys.exit(1)
 460  
 461  # ------------------------------------------------
 462  
 463  if config.translations_dir:
 464      if not Path(config.translations_dir[0]).exists():
 465          sys.stderr.write(f"Error: Could not find translation dir \"{config.translations_dir[0]}\"\n")
 466          sys.exit(1)
 467  
 468      print("+ Adding Qt translations +")
 469  
 470      translations = Path(config.translations_dir[0])
 471  
 472      regex = re.compile('qt_[a-z]*(.qm|_[A-Z]*.qm)')
 473  
 474      lang_files = [x for x in translations.iterdir() if regex.match(x.name)]
 475  
 476      for file in lang_files:
 477          if verbose:
 478              print(file.as_posix(), "->", os.path.join(applicationBundle.resourcesPath, file.name))
 479          shutil.copy2(file.as_posix(), os.path.join(applicationBundle.resourcesPath, file.name))
 480  
 481  # ------------------------------------------------
 482  
 483  print("+ Installing qt.conf +")
 484  
 485  qt_conf="""[Paths]
 486  Translations=Resources
 487  Plugins=PlugIns
 488  """
 489  
 490  with open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") as f:
 491      f.write(qt_conf.encode())
 492  
 493  # ------------------------------------------------
 494  
 495  if platform.system() == "Darwin":
 496      subprocess.check_call(f"codesign --deep --force --sign - {target}", shell=True)
 497  
 498  # ------------------------------------------------
 499  
 500  if config.zip is not None:
 501      shutil.make_archive('{}'.format(appname), format='zip', root_dir='dist', base_dir='Limenka-Qt.app')
 502  
 503  # ------------------------------------------------
 504  
 505  print("+ Done +")
 506  
 507  sys.exit(0)
 508