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)