/ 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 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)