413 lines
18 KiB
Python
Executable File
413 lines
18 KiB
Python
Executable File
#!/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:])
|