#!/usr/bin/python """\ @file update_install.py @author Nat Goodspeed @date 2012-12-20 @brief Update the containing Second Life application bundle to the version in the specified disk image file. This Python implementation is derived from the previous mac-updater application, a funky mix of C++, classic C and Objective-C. $LicenseInfo:firstyear=2012&license=viewerlgpl$ Copyright (c) 2012, Linden Research, Inc. $/LicenseInfo$ """ import os import sys import cgitb from contextlib import contextmanager import errno import glob import plistlib import re import shutil import subprocess import tempfile import time from janitor import Janitor from messageframe import MessageFrame import Tkinter, tkMessageBox TITLE = "Second Life Viewer Updater" # Magic bundle identifier used by all Second Life viewer bundles BUNDLE_IDENTIFIER = "com.secondlife.indra.viewer" # Magic OS directory name that causes Cocoa viewer to crash on OS X 10.7.5 # (see MAINT-3331) STATE_DIR = os.path.join( os.environ["HOME"], "Library", "Saved Application State", BUNDLE_IDENTIFIER + ".savedState") # Global handle to the MessageFrame so we can update message FRAME = None # Global handle to logfile, once it's open LOGF = None # **************************************************************************** # Logging and messaging # # This script is normally run implicitly by the old viewer to update to the # new viewer. Its UI consists of a MessageFrame and possibly a Tk error box. # Log details to updater.log -- especially uncaught exceptions! # **************************************************************************** def log(message): """write message only to LOGF (also called by status() and fail())""" # If we don't even have LOGF open yet, at least write to Console log logf = LOGF or sys.stderr logf.writelines((time.strftime("%Y-%m-%dT%H:%M:%SZ ", time.gmtime()), message, '\n')) logf.flush() def status(message): """display and log normal progress message""" log(message) global FRAME if not FRAME: FRAME = MessageFrame(message, TITLE) else: FRAME.set(message) def fail(message): """log message, produce error box, then terminate with nonzero rc""" log(message) # If we haven't yet called status() (we don't yet have a FRAME), perform a # bit of trickery to bypass the spurious "main window" that Tkinter would # otherwise pop up if the first call is showerror(). if not FRAME: root = Tkinter.Tk() root.withdraw() # If we do have a LOGF available, mention it in the error box. if LOGF: message = "%s\n(Updater log in %s)" % (message, LOGF.name) # We explicitly specify the WARNING icon because, at least on the Tkinter # bundled with the system-default Python 2.7 on Mac OS X 10.7.4, the # ERROR, QUESTION and INFO icons are all the silly Tk rocket ship. At # least WARNING has an exclamation in a yellow triangle, even though # overlaid by a smaller image of the rocket ship. tkMessageBox.showerror(TITLE, """An error occurred while updating Second Life: %s Please download the latest viewer from www.secondlife.com.""" % message, icon=tkMessageBox.WARNING) sys.exit(1) def exception(err): """call fail() with an exception instance""" fail("%s exception: %s" % (err.__class__.__name__, str(err))) def excepthook(type, value, traceback): """ Store this hook function into sys.excepthook until we have a logfile. """ # At least in older Python versions, it could be tricky to produce a # string from 'type' and 'value'. For instance, an OSError exception would # pass type=OSError and value=some_tuple. Empirically, this funky # expression seems to work. exception(type(*value)) sys.excepthook = excepthook class ExceptHook(object): """ Store an instance of this class into sys.excepthook once we have a logfile open. """ def __init__(self, logfile): # There's no magic to the cgitb.enable() function -- it merely stores # an instance of cgitb.Hook into sys.excepthook, passing enable()'s # params into Hook.__init__(). Sadly, enable() doesn't forward all its # params using (*args, **kwds) syntax -- another story. But the point # is that all the goodness is in the cgitb.Hook class. Capture an # instance. self.hook = cgitb.Hook(file=logfile, format="text") def __call__(self, type, value, traceback): # produce nice text traceback to logfile self.hook(type, value, traceback) # Now display an error box. excepthook(type, value, traceback) def write_marker(markerfile, markertext): log("writing %r to %s" % (markertext, markerfile)) try: with open(markerfile, "w") as markerf: markerf.write(markertext) except IOError, err: # write_marker() is invoked by fail(), and fail() is invoked by other # error-handling functions. If we try to invoke any of those, we'll # get infinite recursion. If for any reason we can't write markerfile, # try to log it -- otherwise shrug. log("%s exception: %s" % (err.__class__.__name__, err)) # **************************************************************************** # Utility # **************************************************************************** @contextmanager def allow_errno(errn): """ Execute body of 'with' statement, accepting OSError with specific errno 'errn'. Propagate any other exception, or an OSError with any other errno. """ try: # run the body of the 'with' statement yield except OSError, err: # unless errno == passed errn, re-raise the exception if err.errno != errn: raise # **************************************************************************** # Main script logic # **************************************************************************** def main(dmgfile, markerfile, markertext): # Should we fail, we're supposed to write 'markertext' to 'markerfile'. # Wrap the fail() function so we do that. global fail oldfail = fail def fail(message): write_marker(markerfile, markertext) oldfail(message) try: # Starting with the Cocoafied viewer, we'll find viewer logs in # ~/Library/Application Support/$CFBundleIdentifier/logs rather than in # ~/Library/Application Support/SecondLife/logs as before. This could be # obnoxious -- but we Happen To Know that markerfile is a path specified # within the viewer's logs directory. Use that. logsdir = os.path.dirname(markerfile) # Move the old updater.log file out of the way logname = os.path.join(logsdir, "updater.log") # Nonexistence is okay. Anything else, not so much. with allow_errno(errno.ENOENT): os.rename(logname, logname + ".old") # Open new updater.log. global LOGF LOGF = open(logname, "w") # Now that LOGF is in fact open for business, use it to log any further # uncaught exceptions. sys.excepthook = ExceptHook(LOGF) # log how this script was invoked log(' '.join(repr(arg) for arg in sys.argv)) # prepare for other cleanup with Janitor(LOGF) as janitor: # Under some circumstances, this script seems to be invoked with a # nonexistent pathname. Check for that. if not os.path.isfile(dmgfile): fail(dmgfile + " has been deleted") # Try to derive the name of the running viewer app bundle from our # own pathname. (Hopefully the old viewer won't copy this script # to a temp dir before running!) # Somewhat peculiarly, this script is currently packaged in # Appname.app/Contents/MacOS with the viewer executable. But even # if we decide to move it to Appname.app/Contents/Resources, we'll # still find Appname.app two levels up from dirname(__file__). appdir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) if not appdir.endswith(".app"): # This can happen if either this script has been copied before # being executed, or if it's in an unexpected place in the app # bundle. fail(appdir + " is not an application directory") # We need to install into appdir's parent directory -- can we? installdir = os.path.abspath(os.path.join(appdir, os.pardir)) if not os.access(installdir, os.W_OK): fail("Can't modify " + installdir) # invent a temporary directory tempdir = tempfile.mkdtemp() log("created " + tempdir) # clean it up when we leave janitor.later(shutil.rmtree, tempdir) status("Mounting image...") mntdir = os.path.join(tempdir, "mnt") log("mkdir " + mntdir) os.mkdir(mntdir) command = ["hdiutil", "attach", dmgfile, "-mountpoint", mntdir] log(' '.join(command)) # Instantiating subprocess.Popen launches a child process with the # specified command line. stdout=PIPE passes a pipe to its stdout. hdiutil = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=LOGF) # Popen.communicate() reads that pipe until the child process # terminates, returning (stdout, stderr) output. Select just stdout. hdiutil_out = hdiutil.communicate()[0] if hdiutil.returncode != 0: fail("Couldn't mount " + dmgfile) # hdiutil should report the devnode. Find that. found = re.search(r"/dev/[^ ]*\b", hdiutil_out) if not found: # If we don't spot the devnode, log it and continue -- we only # use it to detach it. Don't fail the whole update if we can't # clean up properly. log("Couldn't spot devnode in hdiutil output:\n" + hdiutil_out) else: # If we do spot the devnode, detach it when done. janitor.later(subprocess.call, ["hdiutil", "detach", found.group(0)], stdout=LOGF, stderr=subprocess.STDOUT) status("Searching for app bundle...") for candidate in glob.glob(os.path.join(mntdir, "*.app")): log("Considering " + candidate) try: # By convention, a valid Mac app bundle has a # Contents/Info.plist file containing at least # CFBundleIdentifier. CFBundleIdentifier = \ plistlib.readPlist(os.path.join(candidate, "Contents", "Info.plist"))["CFBundleIdentifier"] except Exception, err: # might be IOError, xml.parsers.expat.ExpatError, KeyError # Any of these means it's not a valid app bundle. Instead # of aborting, just skip this candidate and continue. log("%s not a valid app bundle: %s: %s" % (candidate, err.__class__.__name__, err)) continue if CFBundleIdentifier == BUNDLE_IDENTIFIER: break log("unrecognized CFBundleIdentifier: " + CFBundleIdentifier) else: fail("Could not find Second Life viewer in " + dmgfile) # Here 'candidate' is the new viewer to install log("Found " + candidate) # This logic was changed to make Mac updates behave more like # Windows. Most of the time, the user doesn't change the name of # the app bundle on our .dmg installer (e.g. "Second Life Beta # Viewer.app"). Most of the time, the version manager directs a # given viewer to update to another .dmg containing an app bundle # with THE SAME name. In that case, everything behaves as usual. # The case that was changed is when the version manager offers (or # mandates) an update to a .dmg containing a different app bundle # name. This can happen, for instance, to a user who's downloaded # a "project beta" viewer, and the project subsequently publishes # a Release Candidate viewer. Say the project beta's app bundle # name is something like "Second Life Beta Neato.app". Anyone # launching that viewer will be offered an update to the # corresponding Release Candidate viewer -- which will be built as # a release viewer, with app bundle name "Second Life Viewer.app". # On Windows, we run the NSIS installer, which will update/replace # the embedded install directory name, e.g. Second Life Viewer. # But the Mac installer used to locate the app bundle name in the # mounted .dmg file, then ignore that name, copying its contents # into the app bundle directory of the running viewer. That is, # we'd install the Release Candidate from the .dmg's "Second # Life.app" into "/Applications/Second Life Beta Neato.app". This # is undesired behavior. # Instead, having found the app bundle name on the mounted .dmg, # we try to install that app bundle name into the parent directory # of the running app bundle. # Are we installing a different app bundle name? If so, call it # out, both in the log and for the user -- this is an odd case. # (Presumably they've already agreed to a similar notification in # the viewer before the viewer launched this script, but still.) bundlename = os.path.basename(candidate) if os.path.basename(appdir) == bundlename: # updating the running app bundle, which we KNOW exists appexists = True else: # installing some other app bundle newapp = os.path.join(installdir, bundlename) appexists = os.path.exists(newapp) message = "Note: %s %s %s" % \ (appdir, "updating" if appexists else "installing new", newapp) status(message) # okay, we have no further need of the name of the running app # bundle. appdir = newapp status("Preparing to copy files...") if appexists: # move old viewer to temp location in case copy from .dmg fails aside = os.path.join(tempdir, os.path.basename(appdir)) log("mv %r %r" % (appdir, aside)) # Use shutil.move() instead of os.rename(). move() first tries # os.rename(), but falls back to shutil.copytree() if the dest is # on a different filesystem. shutil.move(appdir, aside) status("Copying files...") # shutil.copytree()'s target must not already exist. But we just # moved appdir out of the way. log("cp -p %r %r" % (candidate, appdir)) try: # The viewer app bundle does include internal symlinks. Keep them # as symlinks. shutil.copytree(candidate, appdir, symlinks=True) except Exception, err: # copy failed -- try to restore previous viewer before crumping type, value, traceback = sys.exc_info() if appexists: log("exception response: mv %r %r" % (aside, appdir)) shutil.move(aside, appdir) # let our previously-set sys.excepthook handle this raise type, value, traceback status("Cleaning up...") log("touch " + appdir) os.utime(appdir, None) # set to current time # MAINT-3331: remove STATE_DIR. Empirically, this resolves a # persistent, mysterious crash after updating our viewer on an OS # X 10.7.5 system. log("rm -rf '%s'" % STATE_DIR) with allow_errno(errno.ENOENT): shutil.rmtree(STATE_DIR) command = ["open", appdir] log(' '.join(command)) subprocess.check_call(command, stdout=LOGF, stderr=subprocess.STDOUT) # If all the above succeeded, delete the .dmg file. We don't do this # as a janitor.later() operation because we only want to do it if we # get this far successfully. Note that this is out of the scope of the # Janitor: we must detach the .dmg before removing it! log("rm " + dmgfile) os.remove(dmgfile) except Exception, err: # Because we carefully set sys.excepthook -- and even modify it to log # the problem once we have our log file open -- you might think we # could just let exceptions propagate. But when we do that, on # exception in this block, we FIRST restore the no-side-effects fail() # and THEN implicitly call sys.excepthook(), which calls the (no-side- # effects) fail(). Explicitly call sys.excepthook() BEFORE restoring # fail(). Only then do we get the enriched fail() behavior. sys.excepthook(*sys.exc_info()) finally: # When we leave main() -- for whatever reason -- reset fail() the way # it was before, because the bound markerfile, markertext params # passed to this main() call are no longer applicable. fail = oldfail if __name__ == "__main__": # We expect this script to be invoked with: # - the pathname to the .dmg we intend to install; # - the pathname to an update-error marker file to create on failure; # - the content to write into the marker file. main(*sys.argv[1:])