diff --git a/code/client/iconimporter b/code/client/iconimporter new file mode 100755 index 00000000..ffb4b431 --- /dev/null +++ b/code/client/iconimporter @@ -0,0 +1,427 @@ +#!/usr/bin/python +# encoding: utf-8 +# +# Copyright 2010-2014 Greg Neagle. +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +iconimporter + +Created by Greg Neagle on 2014-03-03. + +Converts and imports icons as png files for Munki repo +""" +import glob +import sys +import os +from optparse import OptionParser + +import subprocess +import tempfile +import shutil + +from Foundation import NSData +from AppKit import NSBitmapImageRep, NSPNGFileType +from munkilib import munkicommon +from munkilib import FoundationPlist + + +def convertIconToPNG(icon_path, destination_path, desired_pixel_height=350): + '''Converts an icns file to a png file, choosing the representation + closest to (but >= if possible) the desired_pixel_height. + Returns True if successful, False otherwise''' + if os.path.exists(icon_path): + image_data = NSData.dataWithContentsOfFile_(icon_path) + bitmap_reps = NSBitmapImageRep.imageRepsWithData_(image_data) + chosen_rep = None + for bitmap_rep in bitmap_reps: + if not chosen_rep: + chosen_rep = bitmap_rep + elif (bitmap_rep.pixelsHigh() >= desired_pixel_height + and bitmap_rep.pixelsHigh() < chosen_rep.pixelsHigh()): + chosen_rep = bitmap_rep + if chosen_rep: + png_data = chosen_rep.representationUsingType_properties_( + NSPNGFileType, None) + png_data.writeToFile_atomically_(destination_path, False) + return True + return False + + +def findIconForApp(app_path): + '''Finds the icon file for app_path. Returns a path or None.''' + if not os.path.exists(app_path): + return None + try: + info = FoundationPlist.readPlist( + os.path.join(app_path, 'Contents/Info.plist')) + except (FoundationPlist.FoundationPlistException): + return None + app_name = os.path.basename(app_path) + icon_filename = info.get('CFBundleIconFile', app_name) + icon_path = os.path.join(app_path, 'Contents/Resources', icon_filename) + if not os.path.splitext(icon_path)[1]: + # no file extension, so add '.icns' + icon_path += '.icns' + if os.path.exists(icon_path): + return icon_path + return None + + +def extractAppBitsFromPkgArchive(archive_path, target_dir): + '''Extracts application Info.plist and .icns files into target_dir + from a package archive file. Returns the result code of the + pax extract operation.''' + result = -999 + if os.path.exists(archive_path): + original_dir = os.getcwd() + os.chdir(target_dir) + cmd = ['/bin/pax', '-rzf', archive_path, + '*.app/Contents/Info.plist', + '*.app/Contents/Resources/*.icns'] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + (output, errors) = proc.communicate() + result = proc.returncode + os.chdir(original_dir) + return result + + +def extractAppIconsFromFlatPkg(pkg_path): + '''Extracts application icons from a flat package. + Returns a list of paths to icns files.''' + cmd = ['/usr/sbin/pkgutil', '--bom', pkg_path] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + (output, errors) = proc.communicate() + bomfilepaths = output.splitlines() + pkg_dict = {} + for bomfile in bomfilepaths: + # bomfile path is of the form: + # /tmp/FlashPlayer.pkg.boms.2Rxa1z/AdobeFlashPlayerComponent.pkg/Bom + pkgname = os.path.basename(os.path.dirname(bomfile)) + cmd = ['/usr/bin/lsbom', '-s', bomfile] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + (output, errors) = proc.communicate() + # record paths to all app Info.plist files + pkg_dict[pkgname]= [line for line in output.splitlines() + if line.endswith('.app/Contents/Info.plist')] + if not pkg_dict[pkgname]: + # remove empty lists + del(pkg_dict[pkgname]) + if not pkg_dict: + return [] + icon_paths = [] + pkgtmp = os.path.join(tempfile.mkdtemp(dir='/tmp'), 'pkg') + exporttmp = tempfile.mkdtemp(dir='/tmp') + cmd = ['/usr/sbin/pkgutil', '--expand', pkg_path, pkgtmp] + result = subprocess.call(cmd) + if result == 0: + for pkg in pkg_dict: + archive_path = os.path.join(pkgtmp, pkg, 'Payload') + err = extractAppBitsFromPkgArchive(archive_path, exporttmp) + if err == 0: + for info_path in pkg_dict[pkg]: + full_path = os.path.join(exporttmp, info_path) + # convert path to Info.plist to path to app + app_path = os.path.dirname(os.path.dirname(full_path)) + icon_path = findIconForApp(app_path) + if icon_path: + icon_paths.append(icon_path) + # clean up our expanded flat package; we no longer need it + shutil.rmtree(pkgtmp) + return icon_paths + + +def extractAppIconsFromBundlePkg(pkg_path): + pkg_dict = {} + bomfile = os.path.join(pkg_path, 'Contents', 'Archive.bom') + if os.path.exists(bomfile): + info_paths = getAppInfoPathsFromBundleComponentPkg(pkg_path) + if info_paths: + pkg_dict[pkg_path] = info_paths + else: + # mpkg or dist pkg; look for component pkgs within + pkg_dict = {} + original_dir = os.getcwd() + pkg_contents_dir = os.path.join(pkg_path, 'Contents') + os.chdir(pkg_contents_dir) + pkgs = (glob.glob('*.pkg') + glob.glob('*/*.pkg') + + glob.glob('*/*/*.pkg') + glob.glob('*.mpkg') + + glob.glob('*/*.mpkg') + glob.glob('*/*/*.mpkg')) + os.chdir(original_dir) + for pkg in pkgs: + full_path = os.path.join(pkg_contents_dir, pkg) + info_paths = getAppInfoPathsFromBundleComponentPkg(full_path) + if info_paths: + pkg_dict[full_path] = info_paths + + icon_paths = [] + exporttmp = tempfile.mkdtemp(dir='/tmp') + for pkg in pkg_dict: + archive_path = os.path.join(pkg, 'Contents/Archive.pax.gz') + err = extractAppBitsFromPkgArchive(archive_path, exporttmp) + if err == 0: + for info_path in pkg_dict[pkg]: + full_path = os.path.join(exporttmp, info_path) + app_path = os.path.dirname(os.path.dirname(full_path)) + icon_path = findIconForApp(app_path) + if icon_path: + icon_paths.append(icon_path) + return icon_paths + + +def getAppInfoPathsFromBundleComponentPkg(pkg_path): + '''Returns a list of paths to application Info.plists''' + bomfile = os.path.join(pkg_path, 'Contents', 'Archive.bom') + if os.path.exists(bomfile): + cmd = ['/usr/bin/lsbom', '-s', bomfile] + proc = subprocess.Popen(cmd, shell=False, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + (output, errors) = proc.communicate() + return [line for line in output.splitlines() + if line.endswith('.app/Contents/Info.plist')] + return [] + + +def generate_png_from_copy_from_dmg_item(install_item, repo_path): + dmgpath = os.path.join( + repo_path, 'pkgs', install_item['installer_item_location']) + mountpoints = munkicommon.mountdmg(dmgpath) + if mountpoints: + mountpoint = mountpoints[0] + apps = [item for item in install_item.get('items_to_copy', []) + if item.get('source_item', '').endswith('.app')] + if len(apps): + app_path = os.path.join(mountpoint, apps[0]['source_item']) + icon_path = findIconForApp(app_path) + if icon_path: + png_path = os.path.join( + repo_path, 'icons', install_item['name'] + '.png') + result = convertIconToPNG(icon_path, png_path) + if result: + print_utf8(u'\tWrote: %s' % png_path) + else: + print_err_utf8(u'\tError converting %s to png.' % icon_path) + else: + print_utf8(u'\tNo application icons found.') + else: + print_utf8(u'\tNo application icons found.') + munkicommon.unmountdmg(mountpoint) + + +def generate_pngs_from_installer_pkg(install_item, repo_path): + icon_paths = [] + mountpoint = None + pkg_path = None + item_path = os.path.join( + repo_path, 'pkgs', install_item['installer_item_location']) + if munkicommon.hasValidDiskImageExt(item_path): + dmg_path = item_path + mountpoints = munkicommon.mountdmg(dmg_path) + if mountpoints: + mountpoint = mountpoints[0] + if install_item.get('package_path'): + pkg_path = os.path.join( + mountpoint, install_item['package_path']) + else: + # find first item that appears to be a pkg at the root + for fileitem in munkicommon.listdir(mountpoints[0]): + if munkicommon.hasValidPackageExt(fileitem): + pkg_path = os.path.join(mountpoint, fileitem) + break + elif munkicommon.hasValidPackageExt(item_path): + pkg_path = item_path + if pkg_path: + if os.path.isdir(pkg_path): + icon_paths = extractAppIconsFromBundlePkg(pkg_path) + else: + icon_paths = extractAppIconsFromFlatPkg(pkg_path) + + if mountpoint: + munkicommon.unmountdmg(mountpoint) + + if len(icon_paths) == 1: + png_path = os.path.join( + repo_path, 'icons', install_item['name'] + '.png') + result = convertIconToPNG(icon_paths[0], png_path) + if result: + print_utf8(u'\tWrote: %s' % png_path) + elif len(icon_paths) > 1: + index = 1 + for icon_path in icon_paths: + png_path = os.path.join( + repo_path, 'icons', + install_item['name'] + '_' + str(index) + '.png') + result = convertIconToPNG(icon_path, png_path) + if result: + print_utf8(u'\tWrote: %s' % png_path) + index += 1 + else: + print_utf8(u'\tNo application icons found.') + + +def findItemsToCheck(repo_path, itemlist=None): + '''Builds a list of items to check; only the latest version + of an item is retained. If itemlist is given, include items + only on that list.''' + all_catalog_path = os.path.join(repo_path, 'catalogs/all') + catalogitems = FoundationPlist.readPlist(all_catalog_path) + itemdb = {} + for catalogitem in catalogitems: + if itemlist and catalogitem['name'] not in itemlist: + continue + name = catalogitem['name'] + if name not in itemdb: + itemdb[name] = catalogitem + elif (munkicommon.MunkiLooseVersion(catalogitem['version']) + > munkicommon.MunkiLooseVersion(itemdb[name]['version'])): + itemdb[name] = catalogitem + pkg_list = [] + for key in itemdb.keys(): + pkg_list.append(itemdb[key]) + return pkg_list + + +def generate_pngs_from_munki_items(repo_path, force=False, itemlist=None): + itemlist = findItemsToCheck(repo_path, itemlist=None) + icons_dir = os.path.join(repo_path, 'icons') + if not os.path.exists(icons_dir): + os.makedir(icons_dir) + for item in itemlist: + print_utf8(u'Processing %s...' % item['name']) + icon_name = item.get('icon_name') or item['name'] + if not os.path.splitext(icon_name)[1]: + icon_name += '.png' + icon_path = os.path.join( + repo_path, 'icons', icon_name) + if os.path.exists(icon_path) and not force: + print_utf8(u'Found existing icon at %s' % icon_name) + continue + installer_type = item.get('installer_type') + if installer_type == 'copy_from_dmg': + generate_png_from_copy_from_dmg_item(item, repo_path) + elif installer_type in [None, '']: + generate_pngs_from_installer_pkg(item, repo_path) + else: + print_utf8(u'\tCan\'t process installer_type: %s' % installer_type) + + +def getConditionalOptionalItems(plist): + '''Returns a set of optional_installs names from any + conditional_items in the plist''' + optional_items = set() + for item in plist.get('conditional_items', []): + if item.get('conditional_items'): + optional_items.update(getConditionalOptionalItems(item)) + if item.get('optional_items'): + optional_items.update(item['optional_items']) + return optional_items + + +def findAllOptionalInstalls(repo_path): + optional_items = set() + errors = [] + manifests_path = os.path.join(repo_path, 'manifests') + # Walk through the manifest files + for dirpath, dirnames, filenames in os.walk(manifests_path): + for dirname in dirnames: + # don't recurse into directories that start + # with a period. + if dirname.startswith('.'): + dirnames.remove(dirname) + for filename in filenames: + if filename.startswith('.'): + # skip files that start with a period as well + continue + + filepath = os.path.join(dirpath, filename) + + # Try to read the manifest file + try: + manifest = FoundationPlist.readPlist(filepath) + except FoundationPlist.FoundationPlistException, inst: + errors.append("Unexpected error for %s: %s" % (filepath, inst)) + continue + if manifest: + optional_items.update( + set(manifest.get('optional_installs', []))) + optional_items.update(getConditionalOptionalItems(manifest)) + + return list(optional_items) + + +def print_utf8(text): + '''Print Unicode text as UTF-8''' + print text.encode('UTF-8') + + +def print_err_utf8(text): + '''Print Unicode text to stderr as UTF-8''' + print >> sys.stderr, text.encode('UTF-8') + + +def pref(prefname): + """Returns a preference for prefname""" + try: + _prefs = plistlib.readPlist(PREFSPATH) + except Exception: + return None + if prefname in _prefs: + return _prefs[prefname] + else: + return None + + +PREFSNAME = 'com.googlecode.munki.munkiimport.plist' +PREFSPATH = os.path.expanduser(os.path.join('~/Library/Preferences', + PREFSNAME)) +def main(): + '''Main''' + usage = "usage: %prog [options] [/path/to/repo_root]" + p = OptionParser(usage=usage) + p.add_option('--force', '-f', action='store_true', dest='force', + help='Create pngs even if there is an existing icon in the repo.') + p.set_defaults(force=False) + options, arguments = p.parse_args() + + # Make sure we have a path to work with + repo_path = None + if len(arguments) == 0: + repo_path = pref('repo_path') + if not repo_path: + print_err_utf8("Need to specify a path to the repo root!") + exit(-1) + else: + print_utf8("Using repo path: %s" % repo_path) + else: + repo_path = arguments[0].rstrip("/") + + # Make sure the repo path exists + if not os.path.exists(repo_path): + print_err_utf8("Repo root path %s doesn't exist!" % repo_path) + exit(-1) + + # generate icons! + generate_pngs_from_munki_items(repo_path, force=options.force) + +if __name__ == '__main__': + main() + + \ No newline at end of file