#!/usr/bin/env python # # Copyright (C) 2011 Patrick "p2k" Schneider # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # import subprocess, sys, re, os, shutil, stat, os.path from time import sleep from argparse import ArgumentParser # This is ported from the original macdeployqt with modifications class FrameworkInfo(object): def __init__(self): self.frameworkDirectory = "" self.frameworkName = "" self.frameworkPath = "" self.binaryDirectory = "" self.binaryName = "" self.binaryPath = "" self.version = "" self.installName = "" self.deployedInstallName = "" self.sourceFilePath = "" self.destinationDirectory = "" self.sourceResourcesDirectory = "" self.destinationResourcesDirectory = "" def __eq__(self, other): if self.__class__ == other.__class__: return self.__dict__ == other.__dict__ else: return False def __str__(self): return """ Framework name: %s Framework directory: %s Framework path: %s Binary name: %s Binary directory: %s Binary path: %s Version: %s Install name: %s Deployed install name: %s Source file Path: %s Deployed Directory (relative to bundle): %s """ % (self.frameworkName, self.frameworkDirectory, self.frameworkPath, self.binaryName, self.binaryDirectory, self.binaryPath, self.version, self.installName, self.deployedInstallName, self.sourceFilePath, self.destinationDirectory) def isDylib(self): return self.frameworkName.endswith(".dylib") def isQtFramework(self): if self.isDylib(): return self.frameworkName.startswith("libQt") else: return self.frameworkName.startswith("Qt") reOLine = re.compile(r'^(.+) \(compatibility version [0-9.]+, current version [0-9.]+\)$') bundleFrameworkDirectory = "Contents/Frameworks" bundleBinaryDirectory = "Contents/MacOS" @classmethod def fromOtoolLibraryLine(cls, line): # Note: line must be trimmed if line == "": return None # Don't deploy system libraries (exception for libQtuitools and libQtlucene). if line.startswith("/System/Library/") or line.startswith("@executable_path") or (line.startswith("/usr/lib/") and "libQt" not in line): return None m = cls.reOLine.match(line) if m is None: raise RuntimeError("otool line could not be parsed: " + line) path = m.group(1) info = cls() info.sourceFilePath = path info.installName = path if path.endswith(".dylib"): dirname, filename = os.path.split(path) info.frameworkName = filename info.frameworkDirectory = dirname info.frameworkPath = path info.binaryDirectory = dirname info.binaryName = filename info.binaryPath = path info.version = "-" info.installName = path info.deployedInstallName = "@executable_path/../Frameworks/" + info.binaryName info.sourceFilePath = path info.destinationDirectory = cls.bundleFrameworkDirectory else: parts = path.split("/") i = 0 # Search for the .framework directory for part in parts: if part.endswith(".framework"): break i += 1 if i == len(parts): raise RuntimeError("Could not find .framework or .dylib in otool line: " + line) info.frameworkName = parts[i] info.frameworkDirectory = "/".join(parts[:i]) info.frameworkPath = os.path.join(info.frameworkDirectory, info.frameworkName) info.binaryName = parts[i+3] info.binaryDirectory = "/".join(parts[i+1:i+3]) info.binaryPath = os.path.join(info.binaryDirectory, info.binaryName) info.version = parts[i+2] info.deployedInstallName = "@executable_path/../Frameworks/" + os.path.join(info.frameworkName, info.binaryPath) info.destinationDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, info.binaryDirectory) info.sourceResourcesDirectory = os.path.join(info.frameworkPath, "Resources") info.destinationResourcesDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Resources") return info class ApplicationBundleInfo(object): def __init__(self, path): self.path = path appName = os.path.splitext(os.path.basename(path))[0] self.binaryPath = os.path.join(path, "Contents", "MacOS", appName) if not os.path.exists(self.binaryPath): raise RuntimeError("Could not find bundle binary for " + path) self.resourcesPath = os.path.join(path, "Contents", "Resources") self.pluginPath = os.path.join(path, "Contents", "PlugIns") class DeploymentInfo(object): def __init__(self): self.qtPath = None self.pluginPath = None self.deployedFrameworks = [] def detectQtPath(self, frameworkDirectory): parentDir = os.path.dirname(frameworkDirectory) if os.path.exists(os.path.join(parentDir, "translations")): # Classic layout, e.g. "/usr/local/Trolltech/Qt-4.x.x" self.qtPath = parentDir elif os.path.exists(os.path.join(parentDir, "share", "qt4", "translations")): # MacPorts layout, e.g. "/opt/local/share/qt4" self.qtPath = os.path.join(parentDir, "share", "qt4") if self.qtPath is not None: pluginPath = os.path.join(self.qtPath, "plugins") if os.path.exists(pluginPath): self.pluginPath = pluginPath def usesFramework(self, name): nameDot = "%s." % name libNameDot = "lib%s." % name for framework in self.deployedFrameworks: if framework.endswith(".framework"): if framework.startswith(nameDot): return True elif framework.endswith(".dylib"): if framework.startswith(libNameDot): return True return False def getFrameworks(binaryPath, verbose): if verbose >= 3: print "Inspecting with otool: " + binaryPath otool = subprocess.Popen(["otool", "-L", binaryPath], stdout=subprocess.PIPE, stderr=subprocess.PIPE) o_stdout, o_stderr = otool.communicate() if otool.returncode != 0: if verbose >= 1: sys.stderr.write(o_stderr) sys.stderr.flush() raise RuntimeError("otool failed with return code %d" % otool.returncode) otoolLines = o_stdout.split("\n") otoolLines.pop(0) # First line is the inspected binary if ".framework" in binaryPath or binaryPath.endswith(".dylib"): otoolLines.pop(0) # Frameworks and dylibs list themselves as a dependency. libraries = [] for line in otoolLines: info = FrameworkInfo.fromOtoolLibraryLine(line.strip()) if info is not None: if verbose >= 3: print "Found framework:" print info libraries.append(info) return libraries def runInstallNameTool(action, *args): subprocess.check_call(["install_name_tool", "-"+action] + list(args)) def changeInstallName(oldName, newName, binaryPath, verbose): if verbose >= 3: print "Using install_name_tool:" print " in", binaryPath print " change reference", oldName print " to", newName runInstallNameTool("change", oldName, newName, binaryPath) def changeIdentification(id, binaryPath, verbose): if verbose >= 3: print "Using install_name_tool:" print " change identification in", binaryPath print " to", id runInstallNameTool("id", id, binaryPath) def runStrip(binaryPath, verbose): if verbose >= 3: print "Using strip:" print " stripped", binaryPath subprocess.check_call(["strip", "-x", binaryPath]) def copyFramework(framework, path, verbose): fromPath = framework.sourceFilePath toDir = os.path.join(path, framework.destinationDirectory) toPath = os.path.join(toDir, framework.binaryName) if not os.path.exists(fromPath): raise RuntimeError("No file at " + fromPath) if os.path.exists(toPath): return None # Already there if not os.path.exists(toDir): os.makedirs(toDir) shutil.copy2(fromPath, toPath) if verbose >= 3: print "Copied:", fromPath print " to:", toPath permissions = os.stat(toPath) if not permissions.st_mode & stat.S_IWRITE: os.chmod(toPath, permissions.st_mode | stat.S_IWRITE) if not framework.isDylib(): # Copy resources for real frameworks fromResourcesDir = framework.sourceResourcesDirectory if os.path.exists(fromResourcesDir): toResourcesDir = os.path.join(path, framework.destinationResourcesDirectory) shutil.copytree(fromResourcesDir, toResourcesDir) if verbose >= 3: print "Copied resources:", fromResourcesDir print " to:", toResourcesDir elif framework.frameworkName.startswith("libQtGui"): # Copy qt_menu.nib (applies to non-framework layout) qtMenuNibSourcePath = os.path.join(framework.frameworkDirectory, "Resources", "qt_menu.nib") qtMenuNibDestinationPath = os.path.join(path, "Contents", "Resources", "qt_menu.nib") if os.path.exists(qtMenuNibSourcePath) and not os.path.exists(qtMenuNibDestinationPath): shutil.copytree(qtMenuNibSourcePath, qtMenuNibDestinationPath) if verbose >= 3: print "Copied for libQtGui:", qtMenuNibSourcePath print " to:", qtMenuNibDestinationPath return toPath def deployFrameworks(frameworks, bundlePath, binaryPath, strip, verbose, deploymentInfo=None): if deploymentInfo is None: deploymentInfo = DeploymentInfo() while len(frameworks) > 0: framework = frameworks.pop(0) deploymentInfo.deployedFrameworks.append(framework.frameworkName) if verbose >= 2: print "Processing", framework.frameworkName, "..." # Get the Qt path from one of the Qt frameworks if deploymentInfo.qtPath is None and framework.isQtFramework(): deploymentInfo.detectQtPath(framework.frameworkDirectory) if framework.installName.startswith("@executable_path"): if verbose >= 2: print framework.frameworkName, "already deployed, skipping." continue # install_name_tool the new id into the binary changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose) # Copy farmework to app bundle. deployedBinaryPath = copyFramework(framework, bundlePath, verbose) # Skip the rest if already was deployed. if deployedBinaryPath is None: continue if strip: runStrip(deployedBinaryPath, verbose) # install_name_tool it a new id. changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose) # Check for framework dependencies dependencies = getFrameworks(deployedBinaryPath, verbose) for dependency in dependencies: changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose) # Deploy framework if necessary. if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks: frameworks.append(dependency) return deploymentInfo def deployFrameworksForAppBundle(applicationBundle, strip, verbose): frameworks = getFrameworks(applicationBundle.binaryPath, verbose) if len(frameworks) == 0 and verbose >= 1: print "Warning: Could not find any external frameworks to deploy in %s." % (applicationBundle.path) return DeploymentInfo() else: return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose) def deployPlugins(appBundleInfo, deploymentInfo, strip, verbose): # Lookup available plugins, exclude unneeded plugins = [] for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath): pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath) if pluginDirectory == "designer": # Skip designer plugins continue elif pluginDirectory == "phonon": # Deploy the phonon plugins only if phonon is in use if not deploymentInfo.usesFramework("phonon"): continue elif pluginDirectory == "sqldrivers": # Deploy the sql plugins only if QtSql is in use if not deploymentInfo.usesFramework("QtSql"): continue elif pluginDirectory == "script": # Deploy the script plugins only if QtScript is in use if not deploymentInfo.usesFramework("QtScript"): continue elif pluginDirectory == "qmltooling": # Deploy the qml plugins only if QtDeclarative is in use if not deploymentInfo.usesFramework("QtDeclarative"): continue elif pluginDirectory == "bearer": # Deploy the bearer plugins only if QtNetwork is in use if not deploymentInfo.usesFramework("QtNetwork"): continue for pluginName in filenames: pluginPath = os.path.join(pluginDirectory, pluginName) if pluginName.endswith("_debug.dylib"): # Skip debug plugins continue elif pluginPath == "imageformats/libqsvg.dylib" or pluginPath == "iconengines/libqsvgicon.dylib": # Deploy the svg plugins only if QtSvg is in use if not deploymentInfo.usesFramework("QtSvg"): continue elif pluginPath == "accessible/libqtaccessiblecompatwidgets.dylib": # Deploy accessibility for Qt3Support only if the Qt3Support is in use if not deploymentInfo.usesFramework("Qt3Support"): continue elif pluginPath == "graphicssystems/libqglgraphicssystem.dylib": # Deploy the opengl graphicssystem plugin only if QtOpenGL is in use if not deploymentInfo.usesFramework("QtOpenGL"): continue plugins.append((pluginDirectory, pluginName)) for pluginDirectory, pluginName in plugins: if verbose >= 2: print "Processing plugin", os.path.join(pluginDirectory, pluginName), "..." sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName) destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory) if not os.path.exists(destinationDirectory): os.makedirs(destinationDirectory) destinationPath = os.path.join(destinationDirectory, pluginName) shutil.copy2(sourcePath, destinationPath) if verbose >= 3: print "Copied:", sourcePath print " to:", destinationPath if strip: runStrip(destinationPath, verbose) dependencies = getFrameworks(destinationPath, verbose) for dependency in dependencies: changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose) # Deploy framework if necessary. if dependency.frameworkName not in deploymentInfo.deployedFrameworks: deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo) qt_conf="""[Paths] translations=Resources plugins=PlugIns """ ap = ArgumentParser(description="""Improved version of macdeployqt. Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .dmg file. Note, that the "dist" folder will be deleted before deploying on each run. Optionally, Qt translation files (.qm) and additional resources can be added to the bundle.""") ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed") ap.add_argument("-verbose", type=int, nargs=1, default=[1], metavar="<0-3>", help="0 = no output, 1 = error/warning (default), 2 = normal, 3 = debug") ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment") ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries") ap.add_argument("-dmg", nargs="?", const="", metavar="basename", help="create a .dmg disk image; if basename is not specified, a camel-cased version of the app name is used") ap.add_argument("-fancy", nargs=1, metavar="plist", default=[], help="make a fancy looking disk image using the given plist file with instructions; requires -dmg to work") ap.add_argument("-add-qt-tr", nargs=1, metavar="languages", default=[], help="add Qt translation files to the bundle's ressources; the language list must be separated with commas, not with whitespace") ap.add_argument("-add-resources", nargs="+", metavar="path", default=[], help="list of additional files or folders to be copied into the bundle's resources; must be the last argument") config = ap.parse_args() verbose = config.verbose[0] # ------------------------------------------------ app_bundle = config.app_bundle[0] if not os.path.exists(app_bundle): if verbose >= 1: sys.stderr.write("Error: Could not find app bundle \"%s\"\n" % (app_bundle)) sys.exit(1) app_bundle_name = os.path.splitext(os.path.basename(app_bundle))[0] # ------------------------------------------------ for p in config.add_resources: if verbose >= 3: print "Checking for \"%s\"..." % p if not os.path.exists(p): if verbose >= 1: sys.stderr.write("Error: Could not find additional resource file \"%s\"\n" % (p)) sys.exit(1) # ------------------------------------------------ if len(config.fancy) == 1: if verbose >= 3: print "Fancy: Importing plistlib..." try: import plistlib except ImportError: if verbose >= 1: sys.stderr.write("Error: Could not import plistlib which is required for fancy disk images.\n") sys.exit(1) if verbose >= 3: print "Fancy: Importing appscript..." try: import appscript except ImportError: if verbose >= 1: sys.stderr.write("Error: Could not import appscript which is required for fancy disk images.\n") sys.stderr.write("Please install it e.g. with \"sudo easy_install appscript\".\n") sys.exit(1) p = config.fancy[0] if verbose >= 3: print "Fancy: Loading \"%s\"..." % p if not os.path.exists(p): if verbose >= 1: sys.stderr.write("Error: Could not find fancy disk image plist at \"%s\"\n" % (p)) sys.exit(1) try: fancy = plistlib.readPlist(p) except: if verbose >= 1: sys.stderr.write("Error: Could not parse fancy disk image plist at \"%s\"\n" % (p)) sys.exit(1) try: assert not fancy.has_key("window_bounds") or (isinstance(fancy["window_bounds"], list) and len(fancy["window_bounds"]) == 4) assert not fancy.has_key("background_picture") or isinstance(fancy["background_picture"], str) assert not fancy.has_key("icon_size") or isinstance(fancy["icon_size"], int) assert not fancy.has_key("applications_symlink") or isinstance(fancy["applications_symlink"], bool) if fancy.has_key("items_position"): assert isinstance(fancy["items_position"], dict) for key, value in fancy["items_position"].iteritems(): assert isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], int) except: if verbose >= 1: sys.stderr.write("Error: Bad format of fancy disk image plist at \"%s\"\n" % (p)) sys.exit(1) if fancy.has_key("background_picture"): bp = fancy["background_picture"] if verbose >= 3: print "Fancy: Resolving background picture \"%s\"..." % bp if not os.path.exists(bp): bp = os.path.join(os.path.dirname(p), bp) if not os.path.exists(bp): if verbose >= 1: sys.stderr.write("Error: Could not find background picture at \"%s\" or \"%s\"\n" % (fancy["background_picture"], bp)) sys.exit(1) else: fancy["background_picture"] = bp else: fancy = None # ------------------------------------------------ if os.path.exists("dist"): if verbose >= 2: print "+ Removing old dist folder +" shutil.rmtree("dist") # ------------------------------------------------ target = os.path.join("dist", app_bundle) if verbose >= 2: print "+ Copying source bundle +" if verbose >= 3: print app_bundle, "->", target os.mkdir("dist") shutil.copytree(app_bundle, target) applicationBundle = ApplicationBundleInfo(target) # ------------------------------------------------ if verbose >= 2: print "+ Deploying frameworks +" try: deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose) if deploymentInfo.qtPath is None: deploymentInfo.qtPath = os.getenv("QTDIR", None) if deploymentInfo.qtPath is None: if verbose >= 1: sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n") config.plugins = False except RuntimeError as e: if verbose >= 1: sys.stderr.write("Error: %s\n" % str(e)) sys.exit(ret) # ------------------------------------------------ if config.plugins: if verbose >= 2: print "+ Deploying plugins +" try: deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose) except RuntimeError as e: if verbose >= 1: sys.stderr.write("Error: %s\n" % str(e)) sys.exit(ret) # ------------------------------------------------ if len(config.add_qt_tr) == 0: add_qt_tr = [] else: qt_tr_dir = os.path.join(deploymentInfo.qtPath, "translations") add_qt_tr = ["qt_%s.qm" % lng for lng in config.add_qt_tr[0].split(",")] for lng_file in add_qt_tr: p = os.path.join(qt_tr_dir, lng_file) if verbose >= 3: print "Checking for \"%s\"..." % p if not os.path.exists(p): if verbose >= 1: sys.stderr.write("Error: Could not find Qt translation file \"%s\"\n" % (lng_file)) sys.exit(1) # ------------------------------------------------ if verbose >= 2: print "+ Installing qt.conf +" f = open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb") f.write(qt_conf) f.close() # ------------------------------------------------ if len(add_qt_tr) > 0 and verbose >= 2: print "+ Adding Qt translations +" for lng_file in add_qt_tr: if verbose >= 3: print os.path.join(qt_tr_dir, lng_file), "->", os.path.join(applicationBundle.resourcesPath, lng_file) shutil.copy2(os.path.join(qt_tr_dir, lng_file), os.path.join(applicationBundle.resourcesPath, lng_file)) # ------------------------------------------------ if len(config.add_resources) > 0 and verbose >= 2: print "+ Adding additional resources +" for p in config.add_resources: t = os.path.join(applicationBundle.resourcesPath, os.path.basename(p)) if verbose >= 3: print p, "->", t if os.path.isdir(p): shutil.copytree(p, t) else: shutil.copy2(p, t) # ------------------------------------------------ if config.dmg is not None: def runHDIUtil(verb, image_basename, **kwargs): hdiutil_args = ["hdiutil", verb, image_basename + ".dmg"] if kwargs.has_key("capture_stdout"): del kwargs["capture_stdout"] run = subprocess.check_output else: if verbose < 2: hdiutil_args.append("-quiet") elif verbose >= 3: hdiutil_args.append("-verbose") run = subprocess.check_call for key, value in kwargs.iteritems(): hdiutil_args.append("-" + key) if not value is True: hdiutil_args.append(str(value)) return run(hdiutil_args) if verbose >= 2: if fancy is None: print "+ Creating .dmg disk image +" else: print "+ Preparing .dmg disk image +" if config.dmg != "": dmg_name = config.dmg else: spl = app_bundle_name.split(" ") dmg_name = spl[0] + "".join(p.capitalize() for p in spl[1:]) if fancy is None: try: runHDIUtil("create", dmg_name, srcfolder="dist", format="UDBZ", volname=app_bundle_name, ov=True) except subprocess.CalledProcessError as e: sys.exit(e.returncode) else: if verbose >= 3: print "Determining size of \"dist\"..." size = 0 for path, dirs, files in os.walk("dist"): for file in files: size += os.path.getsize(os.path.join(path, file)) size += int(size * 0.1) if verbose >= 3: print "Creating temp image for modification..." try: runHDIUtil("create", dmg_name + ".temp", srcfolder="dist", format="UDRW", size=size, volname=app_bundle_name, ov=True) except subprocess.CalledProcessError as e: sys.exit(e.returncode) if verbose >= 3: print "Attaching temp image..." try: output = runHDIUtil("attach", dmg_name + ".temp", readwrite=True, noverify=True, noautoopen=True, capture_stdout=True) except subprocess.CalledProcessError as e: sys.exit(e.returncode) m = re.search("/Volumes/(.+$)", output) disk_root = m.group(0) disk_name = m.group(1) if verbose >= 2: print "+ Applying fancy settings +" if fancy.has_key("background_picture"): bg_path = os.path.join(disk_root, os.path.basename(fancy["background_picture"])) if verbose >= 3: print fancy["background_picture"], "->", bg_path shutil.copy2(fancy["background_picture"], bg_path) else: bg_path = None if fancy.get("applications_symlink", False): os.symlink("/Applications", os.path.join(disk_root, "Applications")) finder = appscript.app("Finder") disk = finder.disks[disk_name] disk.open() window = disk.container_window window.current_view.set(appscript.k.icon_view) window.toolbar_visible.set(False) window.statusbar_visible.set(False) if fancy.has_key("window_bounds"): window.bounds.set(fancy["window_bounds"]) view_options = window.icon_view_options view_options.arrangement.set(appscript.k.not_arranged) if fancy.has_key("icon_size"): view_options.icon_size.set(fancy["icon_size"]) if bg_path is not None: view_options.background_picture.set(disk.files[os.path.basename(bg_path)]) if fancy.has_key("items_position"): for name, position in fancy["items_position"].iteritems(): window.items[name].position.set(position) disk.close() if bg_path is not None: subprocess.call(["SetFile", "-a", "V", bg_path]) disk.update(registering_applications=False) sleep(2) disk.eject() if verbose >= 2: print "+ Finalizing .dmg disk image +" try: runHDIUtil("convert", dmg_name + ".temp", format="UDBZ", o=dmg_name + ".dmg", ov=True) except subprocess.CalledProcessError as e: sys.exit(e.returncode) os.unlink(dmg_name + ".temp.dmg") # ------------------------------------------------ if verbose >= 2: print "+ Done +" sys.exit(0)