Merge branch 'status-launch-and-cache-on-checksum'

This commit is contained in:
Greg Neagle
2011-09-27 09:26:12 -07:00
12 changed files with 295 additions and 111 deletions
+2
View File
@@ -1,3 +1,5 @@
# .DS_Store files!
.DS_Store
# /code/Managed Software Update
# build directory changes with each build
@@ -143,6 +143,9 @@ class MSUAppDelegate(NSObject):
or consoleuser == u"loginwindow"):
# Status Window only, so we should just quit
munki.log("MSU", "exit_munkistatus")
# clear launch trigger file so we aren't immediately
# relaunched by launchd
munki.clearLaunchTrigger()
NSApp.terminate_(self)
# The managedsoftwareupdate run will have changed state preferences
@@ -347,6 +350,7 @@ class MSUAppDelegate(NSObject):
self.update_view_controller.updateNowBtn.setEnabled_(YES)
self.update_view_controller.optionalSoftwareBtn.setHidden_(YES)
else:
self._listofupdates = []
self.update_view_controller.updateNowBtn.setEnabled_(NO)
self.getOptionalInstalls()
+27 -8
View File
@@ -37,6 +37,8 @@ from Foundation import CFPreferencesAppSynchronize
INSTALLATLOGOUTFILE = "/private/tmp/com.googlecode.munki.installatlogout"
UPDATECHECKLAUNCHFILE = \
"/private/tmp/.com.googlecode.munki.updatecheck.launchd"
INSTALLWITHOUTLOGOUTFILE = \
"/private/tmp/.com.googlecode.munki.managedinstall.launchd"
MSULOGDIR = \
"/Users/Shared/.com.googlecode.munki.ManagedSoftwareUpdate.logs"
MSULOGFILE = "%s.log"
@@ -253,8 +255,12 @@ def stringFromDate(nsdate):
def startUpdateCheck():
'''Does launchd magic to run managedsoftwareupdate as root.'''
result = call(["/usr/bin/touch", UPDATECHECKLAUNCHFILE])
return result
try:
if not os.path.exists(UPDATECHECKLAUNCHFILE):
open(UPDATECHECKLAUNCHFILE, 'w').close()
return 0
except (OSError, IOError):
return 1
def getAppleUpdates():
@@ -348,22 +354,35 @@ def logoutAndUpdate():
try:
if not os.path.exists(INSTALLATLOGOUTFILE):
f = open(INSTALLATLOGOUTFILE, 'w')
f.close()
open(INSTALLATLOGOUTFILE, 'w').close()
logoutNow()
except (OSError, IOError):
return 1
def clearLaunchTrigger():
'''Clear the trigger file that fast-launches us at loginwindow.
typically because we have been launched in statusmode at the
loginwindow to perform a logout-install.'''
try:
if os.path.exists(INSTALLATLOGOUTFILE):
os.unlink(INSTALLATLOGOUTFILE)
except (OSError, IOError):
return 1
def justUpdate():
'''Trigger managedinstaller via launchd KeepAlive path trigger
We touch a file that launchd is is watching
launchd, in turn,
launches managedsoftwareupdate --installwithnologout as root'''
cmd = ["/usr/bin/touch",
"/private/tmp/.com.googlecode.munki.managedinstall.launchd"]
return call(cmd)
try:
if not os.path.exists(INSTALLWITHOUTLOGOUTFILE):
open(INSTALLWITHOUTLOGOUTFILE, 'w').close()
return 0
except (OSError, IOError):
return 1
def getRunningProcesses():
"""Returns a list of paths of running processes"""
+91 -48
View File
@@ -143,6 +143,50 @@ def initMunkiDirs():
return True
def runScript(script, display_name, runtype):
"""Run an external script. Do not run if the permissions on the external
script file are weaker than the current executable."""
result = 0
if os.path.exists(script):
munkicommon.display_status('Performing %s tasks...' % display_name)
else:
return result
try:
utils.verifyFileOnlyWritableByMunkiAndRoot(script)
except utils.VerifyFilePermissionsError, e:
# preflight/postflight is insecure, but if the currently executing
# file is insecure too we are no worse off.
try:
utils.verifyFileOnlyWritableByMunkiAndRoot(__file__)
except utils.VerifyFilePermissionsError, e:
# OK, managedsoftwareupdate is insecure anyway - warn & execute.
munkicommon.display_warning('Multiple munki executable scripts '
'have insecure file permissions. Executing '
'%s anyway. Error: %s' % (display_name, e))
else:
# Just the preflight/postflight is insecure. Do not execute.
munkicommon.display_warning('Skipping execution of %s due to '
'insecure file permissions. Error: %s' % (display_name, e))
return result
try:
result, stdout, stderr = utils.runExternalScript(
script, allow_insecure=True, script_args=[runtype])
if result:
munkicommon.display_info('%s return code: %d'
% (display_name, result))
if stdout:
munkicommon.display_info('%s stdout: %s' % (display_name, stdout))
if stderr:
munkicommon.display_info('%s stderr: %s' % (display_name, stderr))
except utils.ScriptNotFoundError:
pass # script is not required, so pass
except utils.RunExternalScriptError, e:
munkicommon.display_warning(str(e))
return result
def doInstallTasks(only_unattended=False):
"""Perform our installation/removal tasks.
@@ -426,6 +470,7 @@ def main():
# delete triggerfile if _not_ checkandinstallatstartup
os.unlink(filename)
if not user_triggered:
# no trigger file was found -- how'd we get launched?
munkicommon.cleanUpTmpDir()
exit(0)
@@ -467,24 +512,22 @@ def main():
# set munkicommon globals
munkicommon.munkistatusoutput = options.munkistatusoutput
munkicommon.verbose = options.verbose
if options.installonly:
# we're only installing, not checking, so we should copy
# some report values from the prior run
munkicommon.readreport()
# start a new report
munkicommon.report['StartTime'] = munkicommon.format_time()
munkicommon.report['RunType'] = runtype
# Clearing arrays must be run before any call to display_warning/error.
munkicommon.report['Errors'] = []
munkicommon.report['Warnings'] = []
# run the preflight script if it exists
preflightscript = os.path.join(scriptdir, 'preflight')
if os.path.exists(preflightscript):
munkicommon.display_status('Performing preflight tasks...')
try:
result, stdout, stderr = utils.runExternalScript(
preflightscript, runtype)
if stdout:
munkicommon.display_info('preflight stdout: %s', stdout)
if stderr:
munkicommon.display_info('preflight stderr: %s', stderr)
except utils.ScriptNotFoundError:
result = 0
pass # script is not required, so pass
except utils.RunExternalScriptError, e:
result = 0
munkicommon.display_warning(str(e))
result = runScript(preflightscript, 'preflight', runtype)
if result:
# non-zero return code means don't run
@@ -562,19 +605,9 @@ def main():
munkicommon.reset_warnings()
munkicommon.rotate_main_log()
if options.installonly:
# we're only installing, not checking, so we should copy
# some report values from the prior run
munkicommon.readreport()
# archive the previous session's report
munkicommon.archive_report()
# start a new report
munkicommon.report['StartTime'] = munkicommon.format_time()
munkicommon.report['RunType'] = runtype
munkicommon.report['Errors'] = []
munkicommon.report['Warnings'] = []
munkicommon.log("### Starting managedsoftwareupdate run ###")
if options.verbose:
print 'Managed Software Update Tool'
@@ -632,7 +665,8 @@ def main():
munkicommon.savereport()
exit(-1)
# send a notification event so MSU can update its display if needed
# send a notification event so MSU can update its display
# if needed
sendUpdateNotification()
mustrestart = False
@@ -660,7 +694,15 @@ def main():
'not idle (keyboard or mouse activity).')
else: # there are GUI users
unused_force_action = updatecheck.checkForceInstallPackages()
doInstallTasks(only_unattended=True)
if not munkicommon.pref('SuppressAutoInstall'):
doInstallTasks(only_unattended=True)
else:
munkicommon.log('Skipping unattended installs because '
'SuppressAutoInstall is true.')
# send a notification event so MSU can update its display
# if needed
sendUpdateNotification()
force_action = updatecheck.checkForceInstallPackages()
# if any installs are still requiring force actions, just
# initiate a logout to get started. blocking apps might
@@ -674,6 +716,10 @@ def main():
# however Apple Updates have not been affected by the
# unattended install tasks (so that check is still valid).
if appleupdatesavailable or munkiUpdatesAvailable():
# it may have been more than a minute since we ran our
# original updatecheck so tickle the updatecheck time
# so MSU.app knows to display results immediately
recordUpdateCheckResult(1)
consoleuser = munkicommon.getconsoleuser()
if consoleuser == u'loginwindow':
# someone is logged in, but we're sitting at
@@ -716,21 +762,7 @@ def main():
# run the postflight script if it exists
postflightscript = os.path.join(scriptdir, 'postflight')
if os.path.exists(postflightscript):
munkicommon.display_status('Performing postflight tasks...')
try:
result, stdout, stderr = utils.runExternalScript(
postflightscript, runtype)
if result:
munkicommon.display_info('postflight return code: %d' % result)
if stdout:
munkicommon.display_info('postflight stdout: %s', stdout)
if stderr:
munkicommon.display_info('postflight stderr: %s', stderr)
except utils.ScriptNotFoundError:
pass # script is not required, so pass
except utils.RunExternalScriptError, e:
munkicommon.display_warning(str(e))
result = runScript(postflightscript, 'postflight', runtype)
# we ignore the result of the postflight
munkicommon.cleanUpTmpDir()
@@ -757,13 +789,24 @@ def main():
# no-one is logged in and the machine has been idle
# for a few seconds; kill the loginwindow
# (which will cause us to run again)
munkicommon.log(
'Killing loginwindow so we will run again...')
cmd = ['/usr/bin/killall', 'loginwindow']
unused_retcode = subprocess.call(cmd)
#munkicommon.log(
# 'Killing loginwindow so we will run again...')
#cmd = ['/usr/bin/killall', 'loginwindow']
#unused_retcode = subprocess.call(cmd)
# with the new LaunchAgent, we don't have to kill
# the loginwindow
pass
else:
# if the trigger file is present when we exit, we'll
# be relaunched by launchd, so we need to remove it
# to prevent automatic relaunch.
munkicommon.log(
'System not idle -- skipping killing loginwindow')
'System not idle -- '
'removing trigger file to prevent relaunch')
try:
os.unlink(checkandinstallatstartupflag)
except OSError:
pass
if __name__ == '__main__':
main()
+1
View File
@@ -684,6 +684,7 @@ class AppleUpdates(object):
return
apple_updates = pl_dict.get('AppleUpdates', [])
if apple_updates:
munkicommon.report['AppleUpdates'] = apple_updates
munkicommon.display_info(
'The following Apple Software Updates are available to '
'install:')
+104 -42
View File
@@ -47,6 +47,10 @@ from Foundation import NSDate
# This many hours before a force install deadline, start notifying the user.
FORCE_INSTALL_WARNING_HOURS = 4
# XATTR name storing the ETAG of the file when downloaded via http(s).
XATTR_ETAG = 'com.googlecode.munki.etag'
# XATTR name storing the sha256 of the file after original download by munki.
XATTR_SHA = 'com.googlecode.munki.sha256'
def makeCatalogDB(catalogitems):
"""Takes an array of catalog items and builds some indexes so we can
@@ -745,22 +749,19 @@ def download_installeritem(item_pl, installinfo, uninstalling=False):
oldverbose = munkicommon.verbose
munkicommon.verbose = oldverbose + 1
dl_message = 'Downloading %s...' % pkgname
expected_hash = item_pl.get(item_hash_key, None)
try:
changed = getResourceIfChangedAtomically(pkgurl, destinationpath,
resume=True,
message=dl_message)
message=dl_message,
expected_hash=expected_hash,
verify=True)
except MunkiDownloadError:
munkicommon.verbose = oldverbose
raise
# set verboseness back.
munkicommon.verbose = oldverbose
if changed:
package_verified = verifySoftwarePackageIntegrity(destinationpath,
item_pl,
item_hash_key)
if not package_verified:
raise PackageVerificationError()
def isItemInInstallInfo(manifestitem_pl, thelist, vers=''):
@@ -1202,7 +1203,7 @@ def evidenceThisIsInstalled(item_pl):
return False
def verifySoftwarePackageIntegrity(file_path, item_pl, item_key):
def verifySoftwarePackageIntegrity(file_path, item_hash, always_hash=False):
"""Verifies the integrity of the given software package.
The feature is controlled through the PackageVerificationMode key in
@@ -1218,47 +1219,54 @@ def verifySoftwarePackageIntegrity(file_path, item_pl, item_key):
Args:
file_path: The file to check integrity on.
item_pl: The item plist which contains the reference values.
item_key: The name of the key in plist which contains the hash.
item_hash: the sha256 hash expected.
always_hash: True/False always check (& return) the hash even if not
necessary for this function.
Returns:
(True/False, sha256-hash)
True if the package integrity could be validated. Otherwise, False.
"""
mode = munkicommon.pref('PackageVerificationMode')
chash = None
item_name = getInstallerItemBasename(file_path)
if always_hash:
chash = munkicommon.getsha256hash(file_path)
if not mode:
return True
return (True, chash)
elif mode.lower() == 'none':
munkicommon.display_warning('Package integrity checking is disabled.')
return True
return (True, chash)
elif mode.lower() == 'hash' or mode.lower() == 'hash_strict':
if item_key in item_pl:
if item_hash:
munkicommon.display_status('Verifying package integrity...')
item_hash = item_pl[item_key]
if (item_hash is not 'N/A' and
item_hash == munkicommon.getsha256hash(file_path)):
return True
if not chash:
chash = munkicommon.getsha256hash(file_path)
if item_hash == chash:
return (True, chash)
else:
munkicommon.display_error(
'Hash value integrity check for %s failed.' %
item_pl.get('name'))
return False
item_name)
return (False, chash)
else:
if mode.lower() == 'hash_strict':
munkicommon.display_error(
'Reference hash value for %s is missing in catalog.'
% item_pl.get('name'))
return False
% item_name)
return (False, chash)
else:
munkicommon.display_warning(
'Reference hash value missing for %s -- package '
'integrity verification skipped.' % item_pl.get('name'))
return True
'integrity verification skipped.' % item_name)
return (True, chash)
else:
munkicommon.display_error(
'The PackageVerificationMode in the ManagedInstalls.plist has an '
'illegal value: %s' % munkicommon.pref('PackageVerificationMode'))
return False
return (False, chash)
def getAutoRemovalItems(installinfo, cataloglist):
@@ -2334,11 +2342,7 @@ def curl(url, destinationpath, onlyifnewer=False, etag=None, resume=False,
# let's try to resume this download
print >> fileobj, 'continue-at -'
# if an existing etag, only resume if etags still match.
tempetag = None
if ('com.googlecode.munki.etag' in
xattr.listxattr(tempdownloadpath)):
tempetag = xattr.getxattr(tempdownloadpath,
'com.googlecode.munki.etag')
tempetag = getxattr(tempdownloadpath, XATTR_ETAG)
if tempetag:
# Note: If-Range is more efficient, but the response
# confuses curl (Error: 33 if etag not match).
@@ -2503,8 +2507,7 @@ def curl(url, destinationpath, onlyifnewer=False, etag=None, resume=False,
# try asking it anything challenging.
os.remove(tempdownloadpath)
elif header.get('etag'):
xattr.setxattr(tempdownloadpath,
'com.googlecode.munki.etag', header['etag'])
xattr.setxattr(tempdownloadpath, XATTR_ETAG, header['etag'])
# TODO: should we log this diagnostic here (we didn't previously)?
# Currently for a pkg all that is logged on failure is:
# "WARNING: Download of Firefox failed." with no detail. Logging at
@@ -2575,10 +2578,36 @@ def getDownloadCachePath(destinationpathprefix, url):
destinationpathprefix, getInstallerItemBasename(url))
def writeCachedChecksum(file_path, fhash=None):
"""Write the sha256 checksum of a file to an xattr so we do not need to
calculate it again. Optionally pass the recently calculated hash value.
"""
if not fhash:
fhash = munkicommon.getsha256hash(file_path)
if len(fhash) == 64:
xattr.setxattr(file_path, XATTR_SHA, fhash)
return fhash
return None
def getxattr(file, attr):
"""Get a named xattr from a file. Return None if not present"""
if attr in xattr.listxattr(file):
return xattr.getxattr(file, attr)
else:
return None
def getResourceIfChangedAtomically(url, destinationpath,
message=None, resume=False):
"""Gets file from a URL, checking first to see if it has changed on the
server.
message=None, resume=False,
expected_hash=None,
verify=False):
"""Gets file from a URL.
Checks first if there is already a file with the necessary checksum.
Then checks if the file has changed on the server, resuming or
re-downloading as necessary.
If the file has changed verify the pkg hash if so configured.
Supported schemes are http, https, file.
@@ -2587,19 +2616,54 @@ def getResourceIfChangedAtomically(url, destinationpath,
Raises a MunkiDownloadError derived class if there is an error."""
url_parse = urlparse.urlparse(url)
changed = False
# If we already have a downloaded file & its (cached) hash matches what
# we need, do nothing, return unchanged.
if resume and expected_hash and os.path.isfile(destinationpath):
xattr_hash = getxattr(destinationpath, XATTR_SHA)
if not xattr_hash:
xattr_hash = writeCachedChecksum(destinationpath)
if xattr_hash == expected_hash:
#File is already current, no change.
return False
elif munkicommon.pref('PackageVerificationMode').lower() in \
['hash_strict','hash']:
try:
os.unlink(destinationpath)
except OSError:
pass
munkicommon.log('Cached payload does not match hash in catalog, '
'will check if changed and redownload: %s' % destinationpath)
#continue with normal if-modified-since/etag update methods.
url_parse = urlparse.urlparse(url)
if url_parse.scheme in ['http', 'https']:
return getHTTPfileIfChangedAtomically(
changed = getHTTPfileIfChangedAtomically(
url, destinationpath, message, resume)
elif url_parse.scheme in ['file']:
return getFileIfChangedAtomically(
changed = getFileIfChangedAtomically(
url_parse.path, destinationpath)
# TODO: in theory NFS, AFP, or SMB could be supported here.
else:
raise MunkiDownloadError(
'Unsupported scheme for %s: %s' % (url, url_parse.scheme))
if changed and verify:
(verify_ok, fhash) = verifySoftwarePackageIntegrity(destinationpath,
expected_hash,
always_hash=True)
if not verify_ok:
try:
os.unlink(destinationpath)
except OSError:
pass
raise PackageVerificationError()
if fhash:
writeCachedChecksum(destinationpath, fhash=fhash)
return changed
def getFileIfChangedAtomically(path, destinationpath):
"""Gets file from path, checking first to see if it has changed on the
@@ -2700,10 +2764,9 @@ def getHTTPfileIfChangedAtomically(url, destinationpath,
if os.path.exists(destinationpath):
getonlyifnewer = True
# see if we have an etag attribute
if 'com.googlecode.munki.etag' in xattr.listxattr(destinationpath):
etag = getxattr(destinationpath, XATTR_ETAG)
if etag:
getonlyifnewer = False
etag = xattr.getxattr(destinationpath,
'com.googlecode.munki.etag')
try:
header = curl(url,
@@ -2743,8 +2806,7 @@ def getHTTPfileIfChangedAtomically(url, destinationpath,
os.utime(destinationpath, (time.time(), modtimeint))
if header.get('etag'):
# store etag in extended attribute for future use
xattr.setxattr(destinationpath,
'com.googlecode.munki.etag', header['etag'])
xattr.setxattr(destinationpath, XATTR_ETAG, header['etag'])
return True
+13 -10
View File
@@ -91,11 +91,12 @@ def verifyFileOnlyWritableByMunkiAndRoot(file_path):
'%s is not secure! %s' % (file_path, e.args[0]))
def runExternalScript(script, *args):
def runExternalScript(script, allow_insecure=False, script_args=[]):
"""Run a script (e.g. preflight/postflight) and return its exit status.
Args:
script: string path to the script to execute.
allow_insecure: bool skip the permissions check of executable.
args: args to pass to the script.
Returns:
Tuple. (integer exit status from script, str stdout, str stderr).
@@ -106,23 +107,25 @@ def runExternalScript(script, *args):
if not os.path.exists(script):
raise ScriptNotFoundError('script does not exist: %s' % script)
try:
verifyFileOnlyWritableByMunkiAndRoot(script)
except VerifyFilePermissionsError, e:
msg = ('Skipping execution due to failed file permissions '
'verification: %s\n%s' % (script, str(e)))
raise RunExternalScriptError(msg)
if not allow_insecure:
try:
verifyFileOnlyWritableByMunkiAndRoot(script)
except VerifyFilePermissionsError, e:
msg = ('Skipping execution due to failed file permissions '
'verification: %s\n%s' % (script, str(e)))
raise RunExternalScriptError(msg)
if os.access(script, os.X_OK):
cmd = [script]
if args:
cmd.extend(args)
if script_args:
cmd.extend(script_args)
proc = subprocess.Popen(cmd, shell=False,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(stdout, stderr) = proc.communicate()
return proc.returncode, stdout, stderr
return proc.returncode, stdout.decode('UTF-8','replace'), \
stderr.decode('UTF-8','replace')
else:
raise RunExternalScriptError('%s not executable' % script)
+6
View File
@@ -317,6 +317,7 @@ cp -X "$MUNKIROOT/code/client/munkilib/version.plist" "$COREROOT/usr/local/munki
if [ "$SVNREV" -lt "1302" ]; then
echo $SVNREV > "$COREROOT/usr/local/munki/munkilib/svnversion"
fi
# add Build Number and Git Revision to version.plist
/usr/libexec/PlistBuddy -c "Delete :BuildNumber" "$COREROOT/usr/local/munki/munkilib/version.plist" 2>/dev/null
/usr/libexec/PlistBuddy -c "Add :BuildNumber string $SVNREV" "$COREROOT/usr/local/munki/munkilib/version.plist"
@@ -362,6 +363,10 @@ done
# Set permissions.
chmod -R go-w "$ADMINROOT/usr/local/munki"
chmod +x "$ADMINROOT/usr/local/munki"
# make paths.d file
mkdir -p "$ADMINROOT/private/etc/paths.d"
echo "/usr/local/munki" > "$ADMINROOT/private/etc/paths.d/munki"
chmod -R 755 "$ADMINROOT/private"
# Create package info file.
ADMINSIZE=`du -sk $ADMINROOT | cut -f1`
@@ -527,6 +532,7 @@ sudo chown -hR root:wheel "$COREROOT/usr"
sudo chown -hR root:admin "$COREROOT/Library"
sudo chown -hR root:wheel "$ADMINROOT/usr"
sudo chown -hR root:wheel "$ADMINROOT/private"
sudo chown -hR root:admin "$APPROOT/Applications"
@@ -6,7 +6,6 @@
<string>com.googlecode.munki.MunkiStatus</string>
<key>LimitLoadToSessionType</key>
<array>
<string>LoginWindow</string>
<string>Aqua</string>
</array>
<key>EnvironmentVariables</key>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.googlecode.munki.MunkiStatusLogout</string>
<key>LimitLoadToSessionType</key>
<array>
<string>LoginWindow</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>ManagedSoftwareUpdateMode</key>
<string>MunkiStatus</string>
</dict>
<key>ProgramArguments</key>
<array>
<string>/Applications/Utilities/Managed Software Update.app/Contents/MacOS/Managed Software Update</string>
</array>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<dict>
<key>PathState</key>
<dict>
<key>/var/run/com.googlecode.munki.MunkiStatus</key>
<true/>
<key>/private/tmp/com.googlecode.munki.installatlogout</key>
<true/>
</dict>
</dict>
</dict>
</plist>
@@ -18,7 +18,19 @@
<string>--logoutinstall</string>
</array>
<key>RunAtLoad</key>
<true/>
<false/>
<key>KeepAlive</key>
<dict>
<key>PathState</key>
<dict>
<key>/private/tmp/com.googlecode.munki.installatlogout</key>
<true/>
<key>/Users/Shared/.com.googlecode.munki.checkandinstallatstartup</key>
<true/>
<key>/Users/Shared/.com.googlecode.munki.installatstartup</key>
<true/>
</dict>
</dict>
</dict>
</plist>
+1 -1
View File
@@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>0.8.0</string>
<string>0.8.0.1</string>
</dict>
</plist>