/ contrib / macdeploy / macdeployqtplus
macdeployqtplus
  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 fromOtoolLibraryLine(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"otool 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 otool 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, "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      if verbose:
186          print(f"Inspecting with otool: {binaryPath}")
187      otoolbin=os.getenv("OTOOL", "otool")
188      otool = run([otoolbin, "-L", binaryPath], stdout=PIPE, stderr=PIPE, text=True)
189      if otool.returncode != 0:
190          sys.stderr.write(otool.stderr)
191          sys.stderr.flush()
192          raise RuntimeError(f"otool failed with return code {otool.returncode}")
193  
194      otoolLines = otool.stdout.split("\n")
195      otoolLines.pop(0) # First line is the inspected binary
196      if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
197          otoolLines.pop(0) # Frameworks and dylibs list themselves as a dependency.
198      
199      libraries = []
200      for line in otoolLines:
201          line = line.replace("@loader_path", os.path.dirname(binaryPath))
202          info = FrameworkInfo.fromOtoolLibraryLine(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", "Bitcoin-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='Bitcoin-Qt.app')
502  
503  # ------------------------------------------------
504  
505  print("+ Done +")
506  
507  sys.exit(0)