diff --git a/code/client/munkiimport b/code/client/munkiimport index fd75af6a..ed8ecb02 100755 --- a/code/client/munkiimport +++ b/code/client/munkiimport @@ -30,6 +30,7 @@ import subprocess import time from optparse import OptionParser, BadOptionError, AmbiguousOptionError +from munkilib import iconutils from munkilib import munkicommon from munkilib import FoundationPlist @@ -216,6 +217,142 @@ def copyItemToRepo(itempath, vers, subdirectory=''): return os.path.join(subdirectory, item_name) +def iconExistsInRepo(pkginfo): + """Returns True if there is an icon for this item in the repo""" + icon_name = pkginfo.get('icon_name') or pkginfo['name'] + if not os.path.splitext(icon_name)[1]: + icon_name += u'.png' + icon_path = os.path.join(REPO_PATH, u'icons', icon_name) + if os.path.exists(icon_path): + return True + return False + + +def generate_png_from_copy_from_dmg_item(dmg_path, pkginfo): + '''Generates a product icon from a copy_from_dmg item + and uploads to the repo''' + destination_path = os.path.join(REPO_PATH, 'icons') + if not os.path.exists(destination_path): + try: + os.makedirs(destination_path) + except OSError, errmsg: + print >> sys.stderr, ('Could not create %s: %s' % + (destination_path, errmsg)) + + mountpoints = munkicommon.mountdmg(dmg_path) + if mountpoints: + mountpoint = mountpoints[0] + apps = [item for item in pkginfo.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 = iconutils.findIconForApp(app_path) + if icon_path: + png_path = os.path.join( + destination_path, pkginfo['name'] + u'.png') + result = iconutils.convertIconToPNG(icon_path, png_path) + if result: + print 'Created icon: %s' % png_path + else: + print >> sys.stderr, ( + u'\tError converting %s to png.' % icon_path) + else: + print 'No application icons found.' + else: + print 'No application icons found.' + munkicommon.unmountdmg(mountpoint) + + +def generate_pngs_from_installer_pkg(item_path, pkginfo): + '''Generates a product icon (or candidate icons) from + an installer pkg and uploads to the repo''' + destination_path = os.path.join(REPO_PATH, 'icons') + if not os.path.exists(destination_path): + try: + os.makedirs(destination_path) + except OSError, errmsg: + print >> sys.stderr, ('Could not create %s: %s' % + (destination_path, errmsg)) + + icon_paths = [] + mountpoint = None + pkg_path = None + if munkicommon.hasValidDiskImageExt(item_path): + dmg_path = item_path + mountpoints = munkicommon.mountdmg(dmg_path) + if mountpoints: + mountpoint = mountpoints[0] + if pkginfo.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 = iconutils.extractAppIconsFromBundlePkg(pkg_path) + else: + icon_paths = iconutils.extractAppIconsFromFlatPkg(pkg_path) + + if mountpoint: + munkicommon.unmountdmg(mountpoint) + + if len(icon_paths) == 1: + png_path = os.path.join( + destination_path, pkginfo['name'] + u'.png') + result = convertIconToPNG(icon_paths[0], png_path) + if result: + print 'Created icon: %s' % png_path + else: + print >> sys.stderr, u'Error converting %s to png.' % icon_paths[0] + elif len(icon_paths) > 1: + index = 1 + for icon_path in icon_paths: + png_path = os.path.join( + destination_path, + pkginfo['name'] + '_' + str(index) + u'.png') + result = iconutils.convertIconToPNG(icon_path, png_path) + if result: + print 'Created icon: %s' % png_path + else: + print >> sys.stderr, u'Error converting %s to png.' % icon_path + index += 1 + else: + print 'No application icons found.' + + +def copyIconToRepo(iconpath): + """Saves a product icon to the repo""" + destination_path = os.path.join(REPO_PATH, 'icons') + if not os.path.exists(destination_path): + try: + os.makedirs(destination_path) + except OSError, errmsg: + raise RepoCopyError('Could not create %s: %s' % + (destination_path, errmsg)) + icon_name = os.path.basename(iconpath) + destination_path_name = os.path.join(destination_path, icon_name) + + if os.path.exists(destination_path_name): + # remove any existing icon in the repo + try: + os.unlink(destination_path_name) + except OSError, errmsg: + raise RepoCopyError('Could not remove existing %s' % + (destination_path_name)) + print 'Copying %s to %s...' % (icon_name, destination_path_name) + cmd = ['/bin/cp', iconpath, destination_path_name] + retcode = subprocess.call(cmd) + if retcode: + raise RepoCopyError('Unable to copy %s to %s' % + (iconpath, destination_path_name)) + + def copyPkginfoToRepo(pkginfo, subdirectory=''): """Saves pkginfo to munki_repo_path/pkgsinfo/subdirectory""" # less error checking because we copy the installer_item @@ -612,17 +749,17 @@ def main(): help='Print the version of the munki tools and exit.') parser.add_option('--verbose', '-v', action='store_true', help='Print more output.') - + options, arguments = parser.parse_args() - + if options.version: print munkicommon.get_version() exit(0) - + if options.configure: configure() exit(0) - + NOINTERACTIVE = options.nointeractive VERBOSE = options.verbose REPO_PATH = pref('repo_path') @@ -630,21 +767,21 @@ def main(): if options.repo_path: if not os.path.exists(options.repo_path) and not options.repo_url: - print >> sys.stderr, ('Munki repo path override provided but ' - 'folder does not exist. Please either ' - 'provide --repo_url if you wish to map a ' - 'share, or correct the path and try again.') + print >> sys.stderr, ( + 'Munki repo path override provided but folder does not exist. ' + 'Please either provide --repo_url if you wish to connect to a' + 'file share, or correct the path and try again.') exit(-1) REPO_PATH = options.repo_path if options.repo_url: REPO_URL = options.repo_url - + if len(arguments) == 0: parser.print_usage() exit(0) - + if '--apple-update' in arguments: APPLEMETADATA = True # Verify that arguments, presumed to be for @@ -652,14 +789,14 @@ def main(): installer_item = makePkgInfo(options=arguments, test_mode=True) if not installer_item and not APPLEMETADATA: cleanupAndExit(-1) - + if not APPLEMETADATA: # Remove the installer_item from arguments arguments.remove(installer_item) - + # Strip trailing '/' from installer_item installer_item = installer_item.rstrip('/') - + # Check if the item is a mount point for a disk image if munkicommon.pathIsVolumeMountPoint(installer_item): # Get the disk image path for the mount point @@ -713,7 +850,7 @@ def main(): if not '--catalog' in arguments and not '-c' in arguments: default_catalog = pref('default_catalog') or 'testing' arguments.extend(['--catalog', default_catalog]) - pkginfo = makePkgInfo(arguments, False) + pkginfo = makePkgInfo(arguments, test_mode=False) if not pkginfo: # makepkginfo returned an error print >> sys.stderr, 'Getting package info failed.' @@ -792,7 +929,7 @@ def main(): newvalue = raw_input_with_default(prompt, default) pkginfo['catalogs'] = [item.strip() for item in newvalue.split(',')] - + if not APPLEMETADATA: if 'receipts' not in pkginfo and 'installs' not in pkginfo: print >> sys.stderr, ('WARNING: There are no receipts and no ' @@ -800,7 +937,7 @@ def main(): 'item. You will need to add at least ' 'one item to the \'installs\' list.') #TO-DO: provide a way to add 'installs' items right here - + print for (name, key) in editfields: print '%15s: %s' % (name, pkginfo.get(key,'').encode('UTF-8')) @@ -810,7 +947,7 @@ def main(): answer = raw_input('Import this item? [y/n] ') if not answer.lower().startswith('y'): cleanupAndExit(0) - + if options.subdirectory == '': pkgs_path = os.path.join(REPO_PATH, 'pkgs') if installer_item.startswith(pkgs_path) and not APPLEMETADATA: @@ -821,11 +958,27 @@ def main(): installer_item_dirpath[len(pkgs_path)+1:] options.subdirectory = promptForSubdirectory( options.subdirectory) - + + if not iconExistsInRepo(pkginfo): + print 'No existing product icon found.' + answer = raw_input('Attempt to create a product icon? [y/n] ') + if answer.lower().startswith('y'): + print 'Attempting to extract and upload icon...' + installer_type = pkginfo.get('installer_type') + if installer_type == 'copy_from_dmg': + generate_png_from_copy_from_dmg_item( + installer_item, pkginfo) + elif installer_type in [None, '']: + generate_pngs_from_installer_pkg(installer_item, pkginfo) + else: + print >> sys.stderr, ( + 'Can\'t generate icons from installer_type: %s.' + % installer_type) + # fix in case user accidentally starts subdirectory with a slash if options.subdirectory.startswith('/'): options.subdirectory = options.subdirectory[1:] - + if not APPLEMETADATA: try: uploaded_pkgpath = copyItemToRepo(installer_item, @@ -834,18 +987,25 @@ def main(): except RepoCopyError, errmsg: print >> sys.stderr, errmsg cleanupAndExit(-1) - + # adjust the installer_item_location to match # the actual location and name pkginfo['installer_item_location'] = uploaded_pkgpath - + + # if we have an icon, upload it + if False: #options.iconpath: + try: + copyIconToRepo(options.iconpath) + except RepoCopyError, errmsg: + print >> sys.stderr, errmsg + # installer_item upload was successful, so upload pkginfo to repo try: pkginfo_path = copyPkginfoToRepo(pkginfo, options.subdirectory) except RepoCopyError, errmsg: print >> sys.stderr, errmsg cleanupAndExit(-1) - + if not options.nointeractive: # open the pkginfo file in the user's editor openPkginfoInEditor(pkginfo_path) diff --git a/code/client/munkilib/iconutils.py b/code/client/munkilib/iconutils.py new file mode 100644 index 00000000..306d0fa6 --- /dev/null +++ b/code/client/munkilib/iconutils.py @@ -0,0 +1,224 @@ +#!/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. + +""" +iconutils + +Created by Greg Neagle on 2014-05-15. + +Functions to work with product images ('icons') for Managed Software Center +""" + +import glob +import sys +import os + +import subprocess +import tempfile +import shutil + +from Foundation import NSData +from AppKit import NSBitmapImageRep, NSPNGFileType +import munkicommon +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, u'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, u'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() + if proc.returncode: + print_err_utf8(u'Could not get bom files from %s' % pkg_path) + return [] + 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)) + if not pkgname.endswith(u'.pkg'): + # no subpackages; this is a component pkg + pkgname = '' + 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() + if proc.returncode: + print_err_utf8(u'Could not lsbom %s' % bomfile) + # record paths to all app Info.plist files + pkg_dict[pkgname]= [ + os.path.normpath(line) for line in output.splitlines() + if line.endswith(u'.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=u'/tmp'), u'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, u'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) + else: + print_err_utf8( + u'pax could not read files from %s' % archive_path) + return [] + else: + print_err_utf8(u'Could not expand %s' % pkg_path) + # clean up our expanded flat package; we no longer need it + shutil.rmtree(pkgtmp) + return icon_paths + + +def findInfoPlistPathsInBundlePkg(pkg_path): + '''Returns a dict with pkg paths as keys and filename lists + as values''' + pkg_dict = {} + bomfile = os.path.join(pkg_path, u'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, u'Contents') + if os.path.isdir(pkg_contents_dir): + 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) + else: + pkgs = [] + for pkg in pkgs: + full_path = os.path.join(pkg_contents_dir, pkg) + pkg_dict.update(findInfoPlistPathsInBundlePkg(full_path)) + return pkg_dict + + +def extractAppIconsFromBundlePkg(pkg_path): + '''Returns a list of paths for application icons found + inside the bundle pkg at pkg_path''' + pkg_dict = findInfoPlistPathsInBundlePkg(pkg_path) + icon_paths = [] + exporttmp = tempfile.mkdtemp(dir='/tmp') + for pkg in pkg_dict: + archive_path = os.path.join(pkg, u'Contents/Archive.pax.gz') + err = extractAppBitsFromPkgArchive(archive_path, exporttmp) + if err == 0: + for info_path in pkg_dict[pkg]: + full_path = os.path.normpath(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, u'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 []