4 # Copyright (C) 2011 Patrick "p2k" Schneider <me@p2k-network.org>
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 import subprocess, sys, re, os, shutil, stat, os.path
21 from time import sleep
22 from argparse import ArgumentParser
24 # This is ported from the original macdeployqt with modifications
26 class FrameworkInfo(object):
28 self.frameworkDirectory = ""
29 self.frameworkName = ""
30 self.frameworkPath = ""
31 self.binaryDirectory = ""
36 self.deployedInstallName = ""
37 self.sourceFilePath = ""
38 self.destinationDirectory = ""
39 self.sourceResourcesDirectory = ""
40 self.destinationResourcesDirectory = ""
42 def __eq__(self, other):
43 if self.__class__ == other.__class__:
44 return self.__dict__ == other.__dict__
49 return """ Framework name: %s
50 Framework directory: %s
57 Deployed install name: %s
59 Deployed Directory (relative to bundle): %s
60 """ % (self.frameworkName,
61 self.frameworkDirectory,
68 self.deployedInstallName,
70 self.destinationDirectory)
73 return self.frameworkName.endswith(".dylib")
75 def isQtFramework(self):
77 return self.frameworkName.startswith("libQt")
79 return self.frameworkName.startswith("Qt")
81 reOLine = re.compile(r'^(.+) \(compatibility version [0-9.]+, current version [0-9.]+\)$')
82 bundleFrameworkDirectory = "Contents/Frameworks"
83 bundleBinaryDirectory = "Contents/MacOS"
86 def fromOtoolLibraryLine(cls, line):
87 # Note: line must be trimmed
91 # Don't deploy system libraries (exception for libQtuitools and libQtlucene).
92 if line.startswith("/System/Library/") or line.startswith("@executable_path") or (line.startswith("/usr/lib/") and "libQt" not in line):
95 m = cls.reOLine.match(line)
97 raise RuntimeError("otool line could not be parsed: " + line)
102 info.sourceFilePath = path
103 info.installName = path
105 if path.endswith(".dylib"):
106 dirname, filename = os.path.split(path)
107 info.frameworkName = filename
108 info.frameworkDirectory = dirname
109 info.frameworkPath = path
111 info.binaryDirectory = dirname
112 info.binaryName = filename
113 info.binaryPath = path
116 info.installName = path
117 info.deployedInstallName = "@executable_path/../Frameworks/" + info.binaryName
118 info.sourceFilePath = path
119 info.destinationDirectory = cls.bundleFrameworkDirectory
121 parts = path.split("/")
123 # Search for the .framework directory
125 if part.endswith(".framework"):
129 raise RuntimeError("Could not find .framework or .dylib in otool line: " + line)
131 info.frameworkName = parts[i]
132 info.frameworkDirectory = "/".join(parts[:i])
133 info.frameworkPath = os.path.join(info.frameworkDirectory, info.frameworkName)
135 info.binaryName = parts[i+3]
136 info.binaryDirectory = "/".join(parts[i+1:i+3])
137 info.binaryPath = os.path.join(info.binaryDirectory, info.binaryName)
138 info.version = parts[i+2]
140 info.deployedInstallName = "@executable_path/../Frameworks/" + os.path.join(info.frameworkName, info.binaryPath)
141 info.destinationDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, info.binaryDirectory)
143 info.sourceResourcesDirectory = os.path.join(info.frameworkPath, "Resources")
144 info.destinationResourcesDirectory = os.path.join(cls.bundleFrameworkDirectory, info.frameworkName, "Resources")
148 class ApplicationBundleInfo(object):
149 def __init__(self, path):
151 appName = os.path.splitext(os.path.basename(path))[0]
152 self.binaryPath = os.path.join(path, "Contents", "MacOS", appName)
153 if not os.path.exists(self.binaryPath):
154 raise RuntimeError("Could not find bundle binary for " + path)
155 self.resourcesPath = os.path.join(path, "Contents", "Resources")
156 self.pluginPath = os.path.join(path, "Contents", "PlugIns")
158 class DeploymentInfo(object):
161 self.pluginPath = None
162 self.deployedFrameworks = []
164 def detectQtPath(self, frameworkDirectory):
165 parentDir = os.path.dirname(frameworkDirectory)
166 if os.path.exists(os.path.join(parentDir, "translations")):
167 # Classic layout, e.g. "/usr/local/Trolltech/Qt-4.x.x"
168 self.qtPath = parentDir
169 elif os.path.exists(os.path.join(parentDir, "share", "qt4", "translations")):
170 # MacPorts layout, e.g. "/opt/local/share/qt4"
171 self.qtPath = os.path.join(parentDir, "share", "qt4")
173 if self.qtPath is not None:
174 pluginPath = os.path.join(self.qtPath, "plugins")
175 if os.path.exists(pluginPath):
176 self.pluginPath = pluginPath
178 def usesFramework(self, name):
179 nameDot = "%s." % name
180 libNameDot = "lib%s." % name
181 for framework in self.deployedFrameworks:
182 if framework.endswith(".framework"):
183 if framework.startswith(nameDot):
185 elif framework.endswith(".dylib"):
186 if framework.startswith(libNameDot):
190 def getFrameworks(binaryPath, verbose):
192 print "Inspecting with otool: " + binaryPath
193 otool = subprocess.Popen(["otool", "-L", binaryPath], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
194 o_stdout, o_stderr = otool.communicate()
195 if otool.returncode != 0:
197 sys.stderr.write(o_stderr)
199 raise RuntimeError("otool failed with return code %d" % otool.returncode)
201 otoolLines = o_stdout.split("\n")
202 otoolLines.pop(0) # First line is the inspected binary
203 if ".framework" in binaryPath or binaryPath.endswith(".dylib"):
204 otoolLines.pop(0) # Frameworks and dylibs list themselves as a dependency.
207 for line in otoolLines:
208 info = FrameworkInfo.fromOtoolLibraryLine(line.strip())
211 print "Found framework:"
213 libraries.append(info)
217 def runInstallNameTool(action, *args):
218 subprocess.check_call(["install_name_tool", "-"+action] + list(args))
220 def changeInstallName(oldName, newName, binaryPath, verbose):
222 print "Using install_name_tool:"
223 print " in", binaryPath
224 print " change reference", oldName
226 runInstallNameTool("change", oldName, newName, binaryPath)
228 def changeIdentification(id, binaryPath, verbose):
230 print "Using install_name_tool:"
231 print " change identification in", binaryPath
233 runInstallNameTool("id", id, binaryPath)
235 def runStrip(binaryPath, verbose):
238 print " stripped", binaryPath
239 subprocess.check_call(["strip", "-x", binaryPath])
241 def copyFramework(framework, path, verbose):
242 fromPath = framework.sourceFilePath
243 toDir = os.path.join(path, framework.destinationDirectory)
244 toPath = os.path.join(toDir, framework.binaryName)
246 if not os.path.exists(fromPath):
247 raise RuntimeError("No file at " + fromPath)
249 if os.path.exists(toPath):
250 return None # Already there
252 if not os.path.exists(toDir):
255 shutil.copy2(fromPath, toPath)
257 print "Copied:", fromPath
260 permissions = os.stat(toPath)
261 if not permissions.st_mode & stat.S_IWRITE:
262 os.chmod(toPath, permissions.st_mode | stat.S_IWRITE)
264 if not framework.isDylib(): # Copy resources for real frameworks
265 fromResourcesDir = framework.sourceResourcesDirectory
266 if os.path.exists(fromResourcesDir):
267 toResourcesDir = os.path.join(path, framework.destinationResourcesDirectory)
268 shutil.copytree(fromResourcesDir, toResourcesDir)
270 print "Copied resources:", fromResourcesDir
271 print " to:", toResourcesDir
272 elif framework.frameworkName.startswith("libQtGui"): # Copy qt_menu.nib (applies to non-framework layout)
273 qtMenuNibSourcePath = os.path.join(framework.frameworkDirectory, "Resources", "qt_menu.nib")
274 qtMenuNibDestinationPath = os.path.join(path, "Contents", "Resources", "qt_menu.nib")
275 if os.path.exists(qtMenuNibSourcePath) and not os.path.exists(qtMenuNibDestinationPath):
276 shutil.copytree(qtMenuNibSourcePath, qtMenuNibDestinationPath)
278 print "Copied for libQtGui:", qtMenuNibSourcePath
279 print " to:", qtMenuNibDestinationPath
283 def deployFrameworks(frameworks, bundlePath, binaryPath, strip, verbose, deploymentInfo=None):
284 if deploymentInfo is None:
285 deploymentInfo = DeploymentInfo()
287 while len(frameworks) > 0:
288 framework = frameworks.pop(0)
289 deploymentInfo.deployedFrameworks.append(framework.frameworkName)
292 print "Processing", framework.frameworkName, "..."
294 # Get the Qt path from one of the Qt frameworks
295 if deploymentInfo.qtPath is None and framework.isQtFramework():
296 deploymentInfo.detectQtPath(framework.frameworkDirectory)
298 if framework.installName.startswith("@executable_path"):
300 print framework.frameworkName, "already deployed, skipping."
303 # install_name_tool the new id into the binary
304 changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
306 # Copy farmework to app bundle.
307 deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
308 # Skip the rest if already was deployed.
309 if deployedBinaryPath is None:
313 runStrip(deployedBinaryPath, verbose)
315 # install_name_tool it a new id.
316 changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
317 # Check for framework dependencies
318 dependencies = getFrameworks(deployedBinaryPath, verbose)
320 for dependency in dependencies:
321 changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
323 # Deploy framework if necessary.
324 if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
325 frameworks.append(dependency)
327 return deploymentInfo
329 def deployFrameworksForAppBundle(applicationBundle, strip, verbose):
330 frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
331 if len(frameworks) == 0 and verbose >= 1:
332 print "Warning: Could not find any external frameworks to deploy in %s." % (applicationBundle.path)
333 return DeploymentInfo()
335 return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
337 def deployPlugins(appBundleInfo, deploymentInfo, strip, verbose):
338 # Lookup available plugins, exclude unneeded
340 for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
341 pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
342 if pluginDirectory == "designer":
343 # Skip designer plugins
345 elif pluginDirectory == "phonon":
346 # Deploy the phonon plugins only if phonon is in use
347 if not deploymentInfo.usesFramework("phonon"):
349 elif pluginDirectory == "sqldrivers":
350 # Deploy the sql plugins only if QtSql is in use
351 if not deploymentInfo.usesFramework("QtSql"):
353 elif pluginDirectory == "script":
354 # Deploy the script plugins only if QtScript is in use
355 if not deploymentInfo.usesFramework("QtScript"):
357 elif pluginDirectory == "qmltooling":
358 # Deploy the qml plugins only if QtDeclarative is in use
359 if not deploymentInfo.usesFramework("QtDeclarative"):
361 elif pluginDirectory == "bearer":
362 # Deploy the bearer plugins only if QtNetwork is in use
363 if not deploymentInfo.usesFramework("QtNetwork"):
366 for pluginName in filenames:
367 pluginPath = os.path.join(pluginDirectory, pluginName)
368 if pluginName.endswith("_debug.dylib"):
371 elif pluginPath == "imageformats/libqsvg.dylib" or pluginPath == "iconengines/libqsvgicon.dylib":
372 # Deploy the svg plugins only if QtSvg is in use
373 if not deploymentInfo.usesFramework("QtSvg"):
375 elif pluginPath == "accessible/libqtaccessiblecompatwidgets.dylib":
376 # Deploy accessibility for Qt3Support only if the Qt3Support is in use
377 if not deploymentInfo.usesFramework("Qt3Support"):
379 elif pluginPath == "graphicssystems/libqglgraphicssystem.dylib":
380 # Deploy the opengl graphicssystem plugin only if QtOpenGL is in use
381 if not deploymentInfo.usesFramework("QtOpenGL"):
384 plugins.append((pluginDirectory, pluginName))
386 for pluginDirectory, pluginName in plugins:
388 print "Processing plugin", os.path.join(pluginDirectory, pluginName), "..."
390 sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
391 destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
392 if not os.path.exists(destinationDirectory):
393 os.makedirs(destinationDirectory)
395 destinationPath = os.path.join(destinationDirectory, pluginName)
396 shutil.copy2(sourcePath, destinationPath)
398 print "Copied:", sourcePath
399 print " to:", destinationPath
402 runStrip(destinationPath, verbose)
404 dependencies = getFrameworks(destinationPath, verbose)
406 for dependency in dependencies:
407 changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
409 # Deploy framework if necessary.
410 if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
411 deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
414 translations=Resources
418 ap = ArgumentParser(description="""Improved version of macdeployqt.
420 Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .dmg file.
421 Note, that the "dist" folder will be deleted before deploying on each run.
423 Optionally, Qt translation files (.qm) and additional resources can be added to the bundle.""")
425 ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
426 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")
427 ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment")
428 ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries")
429 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")
430 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")
431 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")
432 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")
434 config = ap.parse_args()
436 verbose = config.verbose[0]
438 # ------------------------------------------------
440 app_bundle = config.app_bundle[0]
442 if not os.path.exists(app_bundle):
444 sys.stderr.write("Error: Could not find app bundle \"%s\"\n" % (app_bundle))
447 app_bundle_name = os.path.splitext(os.path.basename(app_bundle))[0]
449 # ------------------------------------------------
451 for p in config.add_resources:
453 print "Checking for \"%s\"..." % p
454 if not os.path.exists(p):
456 sys.stderr.write("Error: Could not find additional resource file \"%s\"\n" % (p))
459 # ------------------------------------------------
461 if len(config.fancy) == 1:
463 print "Fancy: Importing plistlib..."
468 sys.stderr.write("Error: Could not import plistlib which is required for fancy disk images.\n")
472 print "Fancy: Importing appscript..."
477 sys.stderr.write("Error: Could not import appscript which is required for fancy disk images.\n")
478 sys.stderr.write("Please install it e.g. with \"sudo easy_install appscript\".\n")
483 print "Fancy: Loading \"%s\"..." % p
484 if not os.path.exists(p):
486 sys.stderr.write("Error: Could not find fancy disk image plist at \"%s\"\n" % (p))
490 fancy = plistlib.readPlist(p)
493 sys.stderr.write("Error: Could not parse fancy disk image plist at \"%s\"\n" % (p))
497 assert not fancy.has_key("window_bounds") or (isinstance(fancy["window_bounds"], list) and len(fancy["window_bounds"]) == 4)
498 assert not fancy.has_key("background_picture") or isinstance(fancy["background_picture"], str)
499 assert not fancy.has_key("icon_size") or isinstance(fancy["icon_size"], int)
500 assert not fancy.has_key("applications_symlink") or isinstance(fancy["applications_symlink"], bool)
501 if fancy.has_key("items_position"):
502 assert isinstance(fancy["items_position"], dict)
503 for key, value in fancy["items_position"].iteritems():
504 assert isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], int)
507 sys.stderr.write("Error: Bad format of fancy disk image plist at \"%s\"\n" % (p))
510 if fancy.has_key("background_picture"):
511 bp = fancy["background_picture"]
513 print "Fancy: Resolving background picture \"%s\"..." % bp
514 if not os.path.exists(bp):
515 bp = os.path.join(os.path.dirname(p), bp)
516 if not os.path.exists(bp):
518 sys.stderr.write("Error: Could not find background picture at \"%s\" or \"%s\"\n" % (fancy["background_picture"], bp))
521 fancy["background_picture"] = bp
525 # ------------------------------------------------
527 if os.path.exists("dist"):
529 print "+ Removing old dist folder +"
531 shutil.rmtree("dist")
533 # ------------------------------------------------
535 target = os.path.join("dist", app_bundle)
538 print "+ Copying source bundle +"
540 print app_bundle, "->", target
543 shutil.copytree(app_bundle, target)
545 applicationBundle = ApplicationBundleInfo(target)
547 # ------------------------------------------------
550 print "+ Deploying frameworks +"
553 deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
554 if deploymentInfo.qtPath is None:
555 deploymentInfo.qtPath = os.getenv("QTDIR", None)
556 if deploymentInfo.qtPath is None:
558 sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
559 config.plugins = False
560 except RuntimeError as e:
562 sys.stderr.write("Error: %s\n" % str(e))
565 # ------------------------------------------------
569 print "+ Deploying plugins +"
572 deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
573 except RuntimeError as e:
575 sys.stderr.write("Error: %s\n" % str(e))
578 # ------------------------------------------------
580 if len(config.add_qt_tr) == 0:
583 qt_tr_dir = os.path.join(deploymentInfo.qtPath, "translations")
584 add_qt_tr = ["qt_%s.qm" % lng for lng in config.add_qt_tr[0].split(",")]
585 for lng_file in add_qt_tr:
586 p = os.path.join(qt_tr_dir, lng_file)
588 print "Checking for \"%s\"..." % p
589 if not os.path.exists(p):
591 sys.stderr.write("Error: Could not find Qt translation file \"%s\"\n" % (lng_file))
594 # ------------------------------------------------
597 print "+ Installing qt.conf +"
599 f = open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb")
603 # ------------------------------------------------
605 if len(add_qt_tr) > 0 and verbose >= 2:
606 print "+ Adding Qt translations +"
608 for lng_file in add_qt_tr:
610 print os.path.join(qt_tr_dir, lng_file), "->", os.path.join(applicationBundle.resourcesPath, lng_file)
611 shutil.copy2(os.path.join(qt_tr_dir, lng_file), os.path.join(applicationBundle.resourcesPath, lng_file))
613 # ------------------------------------------------
615 if len(config.add_resources) > 0 and verbose >= 2:
616 print "+ Adding additional resources +"
618 for p in config.add_resources:
619 t = os.path.join(applicationBundle.resourcesPath, os.path.basename(p))
623 shutil.copytree(p, t)
627 # ------------------------------------------------
629 if config.dmg is not None:
630 def runHDIUtil(verb, image_basename, **kwargs):
631 hdiutil_args = ["hdiutil", verb, image_basename + ".dmg"]
632 if kwargs.has_key("capture_stdout"):
633 del kwargs["capture_stdout"]
634 run = subprocess.check_output
637 hdiutil_args.append("-quiet")
639 hdiutil_args.append("-verbose")
640 run = subprocess.check_call
642 for key, value in kwargs.iteritems():
643 hdiutil_args.append("-" + key)
644 if not value is True:
645 hdiutil_args.append(str(value))
647 return run(hdiutil_args)
651 print "+ Creating .dmg disk image +"
653 print "+ Preparing .dmg disk image +"
656 dmg_name = config.dmg
658 spl = app_bundle_name.split(" ")
659 dmg_name = spl[0] + "".join(p.capitalize() for p in spl[1:])
663 runHDIUtil("create", dmg_name, srcfolder="dist", format="UDBZ", volname=app_bundle_name, ov=True)
664 except subprocess.CalledProcessError as e:
665 sys.exit(e.returncode)
668 print "Determining size of \"dist\"..."
670 for path, dirs, files in os.walk("dist"):
672 size += os.path.getsize(os.path.join(path, file))
673 size += int(size * 0.1)
676 print "Creating temp image for modification..."
678 runHDIUtil("create", dmg_name + ".temp", srcfolder="dist", format="UDRW", size=size, volname=app_bundle_name, ov=True)
679 except subprocess.CalledProcessError as e:
680 sys.exit(e.returncode)
683 print "Attaching temp image..."
685 output = runHDIUtil("attach", dmg_name + ".temp", readwrite=True, noverify=True, noautoopen=True, capture_stdout=True)
686 except subprocess.CalledProcessError as e:
687 sys.exit(e.returncode)
689 m = re.search("/Volumes/(.+$)", output)
690 disk_root = m.group(0)
691 disk_name = m.group(1)
694 print "+ Applying fancy settings +"
696 if fancy.has_key("background_picture"):
697 bg_path = os.path.join(disk_root, os.path.basename(fancy["background_picture"]))
699 print fancy["background_picture"], "->", bg_path
700 shutil.copy2(fancy["background_picture"], bg_path)
704 if fancy.get("applications_symlink", False):
705 os.symlink("/Applications", os.path.join(disk_root, "Applications"))
707 finder = appscript.app("Finder")
708 disk = finder.disks[disk_name]
710 window = disk.container_window
711 window.current_view.set(appscript.k.icon_view)
712 window.toolbar_visible.set(False)
713 window.statusbar_visible.set(False)
714 if fancy.has_key("window_bounds"):
715 window.bounds.set(fancy["window_bounds"])
716 view_options = window.icon_view_options
717 view_options.arrangement.set(appscript.k.not_arranged)
718 if fancy.has_key("icon_size"):
719 view_options.icon_size.set(fancy["icon_size"])
720 if bg_path is not None:
721 view_options.background_picture.set(disk.files[os.path.basename(bg_path)])
722 if fancy.has_key("items_position"):
723 for name, position in fancy["items_position"].iteritems():
724 window.items[name].position.set(position)
726 if bg_path is not None:
727 subprocess.call(["SetFile", "-a", "V", bg_path])
728 disk.update(registering_applications=False)
733 print "+ Finalizing .dmg disk image +"
736 runHDIUtil("convert", dmg_name + ".temp", format="UDBZ", o=dmg_name + ".dmg", ov=True)
737 except subprocess.CalledProcessError as e:
738 sys.exit(e.returncode)
740 os.unlink(dmg_name + ".temp.dmg")
742 # ------------------------------------------------