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)