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, 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 if not framework.isDylib(): # Copy resources for real frameworks
261 fromResourcesDir = framework.sourceResourcesDirectory
262 if os.path.exists(fromResourcesDir):
263 toResourcesDir = os.path.join(path, framework.destinationResourcesDirectory)
264 shutil.copytree(fromResourcesDir, toResourcesDir)
266 print "Copied resources:", fromResourcesDir
267 print " to:", toResourcesDir
268 elif framework.frameworkName.startswith("libQtGui"): # Copy qt_menu.nib (applies to non-framework layout)
269 qtMenuNibSourcePath = os.path.join(framework.frameworkDirectory, "Resources", "qt_menu.nib")
270 qtMenuNibDestinationPath = os.path.join(path, "Contents", "Resources", "qt_menu.nib")
271 if os.path.exists(qtMenuNibSourcePath) and not os.path.exists(qtMenuNibDestinationPath):
272 shutil.copytree(qtMenuNibSourcePath, qtMenuNibDestinationPath)
274 print "Copied for libQtGui:", qtMenuNibSourcePath
275 print " to:", qtMenuNibDestinationPath
279 def deployFrameworks(frameworks, bundlePath, binaryPath, strip, verbose, deploymentInfo=None):
280 if deploymentInfo is None:
281 deploymentInfo = DeploymentInfo()
283 while len(frameworks) > 0:
284 framework = frameworks.pop(0)
285 deploymentInfo.deployedFrameworks.append(framework.frameworkName)
288 print "Processing", framework.frameworkName, "..."
290 # Get the Qt path from one of the Qt frameworks
291 if deploymentInfo.qtPath is None and framework.isQtFramework():
292 deploymentInfo.detectQtPath(framework.frameworkDirectory)
294 if framework.installName.startswith("@executable_path"):
296 print framework.frameworkName, "already deployed, skipping."
299 # install_name_tool the new id into the binary
300 changeInstallName(framework.installName, framework.deployedInstallName, binaryPath, verbose)
302 # Copy farmework to app bundle.
303 deployedBinaryPath = copyFramework(framework, bundlePath, verbose)
304 # Skip the rest if already was deployed.
305 if deployedBinaryPath is None:
309 runStrip(deployedBinaryPath, verbose)
311 # install_name_tool it a new id.
312 changeIdentification(framework.deployedInstallName, deployedBinaryPath, verbose)
313 # Check for framework dependencies
314 dependencies = getFrameworks(deployedBinaryPath, verbose)
316 for dependency in dependencies:
317 changeInstallName(dependency.installName, dependency.deployedInstallName, deployedBinaryPath, verbose)
319 # Deploy framework if necessary.
320 if dependency.frameworkName not in deploymentInfo.deployedFrameworks and dependency not in frameworks:
321 frameworks.append(dependency)
323 return deploymentInfo
325 def deployFrameworksForAppBundle(applicationBundle, strip, verbose):
326 frameworks = getFrameworks(applicationBundle.binaryPath, verbose)
327 if len(frameworks) == 0 and verbose >= 1:
328 print "Warning: Could not find any external frameworks to deploy in %s." % (applicationBundle.path)
329 return DeploymentInfo()
331 return deployFrameworks(frameworks, applicationBundle.path, applicationBundle.binaryPath, strip, verbose)
333 def deployPlugins(appBundleInfo, deploymentInfo, strip, verbose):
334 # Lookup available plugins, exclude unneeded
336 for dirpath, dirnames, filenames in os.walk(deploymentInfo.pluginPath):
337 pluginDirectory = os.path.relpath(dirpath, deploymentInfo.pluginPath)
338 if pluginDirectory == "designer":
339 # Skip designer plugins
341 elif pluginDirectory == "phonon":
342 # Deploy the phonon plugins only if phonon is in use
343 if not deploymentInfo.usesFramework("phonon"):
345 elif pluginDirectory == "sqldrivers":
346 # Deploy the sql plugins only if QtSql is in use
347 if not deploymentInfo.usesFramework("QtSql"):
349 elif pluginDirectory == "script":
350 # Deploy the script plugins only if QtScript is in use
351 if not deploymentInfo.usesFramework("QtScript"):
353 elif pluginDirectory == "qmltooling":
354 # Deploy the qml plugins only if QtDeclarative is in use
355 if not deploymentInfo.usesFramework("QtDeclarative"):
357 elif pluginDirectory == "bearer":
358 # Deploy the bearer plugins only if QtNetwork is in use
359 if not deploymentInfo.usesFramework("QtNetwork"):
362 for pluginName in filenames:
363 pluginPath = os.path.join(pluginDirectory, pluginName)
364 if pluginName.endswith("_debug.dylib"):
367 elif pluginPath == "imageformats/libqsvg.dylib" or pluginPath == "iconengines/libqsvgicon.dylib":
368 # Deploy the svg plugins only if QtSvg is in use
369 if not deploymentInfo.usesFramework("QtSvg"):
371 elif pluginPath == "accessible/libqtaccessiblecompatwidgets.dylib":
372 # Deploy accessibility for Qt3Support only if the Qt3Support is in use
373 if not deploymentInfo.usesFramework("Qt3Support"):
375 elif pluginPath == "graphicssystems/libqglgraphicssystem.dylib":
376 # Deploy the opengl graphicssystem plugin only if QtOpenGL is in use
377 if not deploymentInfo.usesFramework("QtOpenGL"):
380 plugins.append((pluginDirectory, pluginName))
382 for pluginDirectory, pluginName in plugins:
384 print "Processing plugin", os.path.join(pluginDirectory, pluginName), "..."
386 sourcePath = os.path.join(deploymentInfo.pluginPath, pluginDirectory, pluginName)
387 destinationDirectory = os.path.join(appBundleInfo.pluginPath, pluginDirectory)
388 if not os.path.exists(destinationDirectory):
389 os.makedirs(destinationDirectory)
391 destinationPath = os.path.join(destinationDirectory, pluginName)
392 shutil.copy2(sourcePath, destinationPath)
394 print "Copied:", sourcePath
395 print " to:", destinationPath
398 runStrip(destinationPath, verbose)
400 dependencies = getFrameworks(destinationPath, verbose)
402 for dependency in dependencies:
403 changeInstallName(dependency.installName, dependency.deployedInstallName, destinationPath, verbose)
405 # Deploy framework if necessary.
406 if dependency.frameworkName not in deploymentInfo.deployedFrameworks:
407 deployFrameworks([dependency], appBundleInfo.path, destinationPath, strip, verbose, deploymentInfo)
410 translations=Resources
414 ap = ArgumentParser(description="""Improved version of macdeployqt.
416 Outputs a ready-to-deploy app in a folder "dist" and optionally wraps it in a .dmg file.
417 Note, that the "dist" folder will be deleted before deploying on each run.
419 Optionally, Qt translation files (.qm) and additional resources can be added to the bundle.""")
421 ap.add_argument("app_bundle", nargs=1, metavar="app-bundle", help="application bundle to be deployed")
422 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")
423 ap.add_argument("-no-plugins", dest="plugins", action="store_false", default=True, help="skip plugin deployment")
424 ap.add_argument("-no-strip", dest="strip", action="store_false", default=True, help="don't run 'strip' on the binaries")
425 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")
426 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")
427 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")
428 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")
430 config = ap.parse_args()
432 verbose = config.verbose[0]
434 # ------------------------------------------------
436 app_bundle = config.app_bundle[0]
438 if not os.path.exists(app_bundle):
440 sys.stderr.write("Error: Could not find app bundle \"%s\"\n" % (app_bundle))
443 app_bundle_name = os.path.splitext(os.path.basename(app_bundle))[0]
445 # ------------------------------------------------
447 for p in config.add_resources:
449 print "Checking for \"%s\"..." % p
450 if not os.path.exists(p):
452 sys.stderr.write("Error: Could not find additional resource file \"%s\"\n" % (p))
455 # ------------------------------------------------
457 if len(config.fancy) == 1:
459 print "Fancy: Importing plistlib..."
464 sys.stderr.write("Error: Could not import plistlib which is required for fancy disk images.\n")
468 print "Fancy: Importing appscript..."
473 sys.stderr.write("Error: Could not import appscript which is required for fancy disk images.\n")
474 sys.stderr.write("Please install it e.g. with \"sudo easy_install appscript\".\n")
479 print "Fancy: Loading \"%s\"..." % p
480 if not os.path.exists(p):
482 sys.stderr.write("Error: Could not find fancy disk image plist at \"%s\"\n" % (p))
486 fancy = plistlib.readPlist(p)
489 sys.stderr.write("Error: Could not parse fancy disk image plist at \"%s\"\n" % (p))
493 assert not fancy.has_key("window_bounds") or (isinstance(fancy["window_bounds"], list) and len(fancy["window_bounds"]) == 4)
494 assert not fancy.has_key("background_picture") or isinstance(fancy["background_picture"], str)
495 assert not fancy.has_key("icon_size") or isinstance(fancy["icon_size"], int)
496 assert not fancy.has_key("applications_symlink") or isinstance(fancy["applications_symlink"], bool)
497 if fancy.has_key("items_position"):
498 assert isinstance(fancy["items_position"], dict)
499 for key, value in fancy["items_position"].iteritems():
500 assert isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], int)
503 sys.stderr.write("Error: Bad format of fancy disk image plist at \"%s\"\n" % (p))
506 if fancy.has_key("background_picture"):
507 bp = fancy["background_picture"]
509 print "Fancy: Resolving background picture \"%s\"..." % bp
510 if not os.path.exists(bp):
511 bp = os.path.join(os.path.dirname(p), bp)
512 if not os.path.exists(bp):
514 sys.stderr.write("Error: Could not find background picture at \"%s\" or \"%s\"\n" % (fancy["background_picture"], bp))
517 fancy["background_picture"] = bp
521 # ------------------------------------------------
523 if os.path.exists("dist"):
525 print "+ Removing old dist folder +"
527 shutil.rmtree("dist")
529 # ------------------------------------------------
531 target = os.path.join("dist", app_bundle)
534 print "+ Copying source bundle +"
536 print app_bundle, "->", target
539 shutil.copytree(app_bundle, target)
541 applicationBundle = ApplicationBundleInfo(target)
543 # ------------------------------------------------
546 print "+ Deploying frameworks +"
549 deploymentInfo = deployFrameworksForAppBundle(applicationBundle, config.strip, verbose)
550 if deploymentInfo.qtPath is None:
551 deploymentInfo.qtPath = os.getenv("QTDIR", None)
552 if deploymentInfo.qtPath is None:
554 sys.stderr.write("Warning: Could not detect Qt's path, skipping plugin deployment!\n")
555 config.plugins = False
556 except RuntimeError as e:
558 sys.stderr.write("Error: %s\n" % str(e))
561 # ------------------------------------------------
565 print "+ Deploying plugins +"
568 deployPlugins(applicationBundle, deploymentInfo, config.strip, verbose)
569 except RuntimeError as e:
571 sys.stderr.write("Error: %s\n" % str(e))
574 # ------------------------------------------------
576 if len(config.add_qt_tr) == 0:
579 qt_tr_dir = os.path.join(deploymentInfo.qtPath, "translations")
580 add_qt_tr = ["qt_%s.qm" % lng for lng in config.add_qt_tr[0].split(",")]
581 for lng_file in add_qt_tr:
582 p = os.path.join(qt_tr_dir, lng_file)
584 print "Checking for \"%s\"..." % p
585 if not os.path.exists(p):
587 sys.stderr.write("Error: Could not find Qt translation file \"%s\"\n" % (lng_file))
590 # ------------------------------------------------
593 print "+ Installing qt.conf +"
595 f = open(os.path.join(applicationBundle.resourcesPath, "qt.conf"), "wb")
599 # ------------------------------------------------
601 if len(add_qt_tr) > 0 and verbose >= 2:
602 print "+ Adding Qt translations +"
604 for lng_file in add_qt_tr:
606 print os.path.join(qt_tr_dir, lng_file), "->", os.path.join(applicationBundle.resourcesPath, lng_file)
607 shutil.copy2(os.path.join(qt_tr_dir, lng_file), os.path.join(applicationBundle.resourcesPath, lng_file))
609 # ------------------------------------------------
611 if len(config.add_resources) > 0 and verbose >= 2:
612 print "+ Adding additional resources +"
614 for p in config.add_resources:
615 t = os.path.join(applicationBundle.resourcesPath, os.path.basename(p))
619 shutil.copytree(p, t)
623 # ------------------------------------------------
625 if config.dmg is not None:
626 def runHDIUtil(verb, image_basename, **kwargs):
627 hdiutil_args = ["hdiutil", verb, image_basename + ".dmg"]
628 if kwargs.has_key("capture_stdout"):
629 del kwargs["capture_stdout"]
630 run = subprocess.check_output
633 hdiutil_args.append("-quiet")
635 hdiutil_args.append("-verbose")
636 run = subprocess.check_call
638 for key, value in kwargs.iteritems():
639 hdiutil_args.append("-" + key)
640 if not value is True:
641 hdiutil_args.append(str(value))
643 return run(hdiutil_args)
647 print "+ Creating .dmg disk image +"
649 print "+ Preparing .dmg disk image +"
652 dmg_name = config.dmg
654 spl = app_bundle_name.split(" ")
655 dmg_name = spl[0] + "".join(p.capitalize() for p in spl[1:])
659 runHDIUtil("create", dmg_name, srcfolder="dist", format="UDBZ", volname=app_bundle_name, ov=True)
660 except subprocess.CalledProcessError as e:
661 sys.exit(e.returncode)
664 print "Determining size of \"dist\"..."
666 for path, dirs, files in os.walk("dist"):
668 size += os.path.getsize(os.path.join(path, file))
669 size += int(size * 0.1)
672 print "Creating temp image for modification..."
674 runHDIUtil("create", dmg_name + ".temp", srcfolder="dist", format="UDRW", size=size, volname=app_bundle_name, ov=True)
675 except subprocess.CalledProcessError as e:
676 sys.exit(e.returncode)
679 print "Attaching temp image..."
681 output = runHDIUtil("attach", dmg_name + ".temp", readwrite=True, noverify=True, noautoopen=True, capture_stdout=True)
682 except subprocess.CalledProcessError as e:
683 sys.exit(e.returncode)
685 m = re.search("/Volumes/(.+$)", output)
686 disk_root = m.group(0)
687 disk_name = m.group(1)
690 print "+ Applying fancy settings +"
692 if fancy.has_key("background_picture"):
693 bg_path = os.path.join(disk_root, os.path.basename(fancy["background_picture"]))
695 print fancy["background_picture"], "->", bg_path
696 shutil.copy2(fancy["background_picture"], bg_path)
700 if fancy.get("applications_symlink", False):
701 os.symlink("/Applications", os.path.join(disk_root, "Applications"))
703 finder = appscript.app("Finder")
704 disk = finder.disks[disk_name]
706 window = disk.container_window
707 window.current_view.set(appscript.k.icon_view)
708 window.toolbar_visible.set(False)
709 window.statusbar_visible.set(False)
710 if fancy.has_key("window_bounds"):
711 window.bounds.set(fancy["window_bounds"])
712 view_options = window.icon_view_options
713 view_options.arrangement.set(appscript.k.not_arranged)
714 if fancy.has_key("icon_size"):
715 view_options.icon_size.set(fancy["icon_size"])
716 if bg_path is not None:
717 view_options.background_picture.set(disk.files[os.path.basename(bg_path)])
718 if fancy.has_key("items_position"):
719 for name, position in fancy["items_position"].iteritems():
720 window.items[name].position.set(position)
722 if bg_path is not None:
723 subprocess.call(["SetFile", "-a", "V", bg_path])
724 disk.update(registering_applications=False)
729 print "+ Finalizing .dmg disk image +"
732 runHDIUtil("convert", dmg_name + ".temp", format="UDBZ", o=dmg_name + ".dmg", ov=True)
733 except subprocess.CalledProcessError as e:
734 sys.exit(e.returncode)
736 os.unlink(dmg_name + ".temp.dmg")
738 # ------------------------------------------------