1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354
|
#----------------------------------------------------------------------
# Name: wx.lib.softwareupdate
# Purpose: A mixin class using Esky that allows a frozen application
# to update itself when new versions of the software become
# available.
#
# Author: Robin Dunn
#
# Created: 1-Aug-2011
# RCS-ID: $Id$
# Copyright: (c) 2011 by Total Control Software
# Licence: wxWindows license
#----------------------------------------------------------------------
"""
This module provides a class designed to be mixed with wx.App to form a
derived class which is able to auto-self-update the application when new
versions are released. It is built upon the Esky package, available in PyPi at
http://pypi.python.org/pypi/esky.
In order for the software update to work the application must be put into an
esky bundle using the bdist_esky distutils command, which in turn will use
py2app, py2exe or etc. to freeze the actual application. See Esky's docs for
more details. The code in this module will only have effect if the application
is frozen, and is silently ignored otherwise.
"""
import wx
import sys
import os
import atexit
import urllib2
from wx.lib.dialogs import MultiMessageBox
isFrozenApp = hasattr(sys, 'frozen')
if isFrozenApp:
import esky
import esky.util
# wx 2.8 doesn't have [SG]etAppDisplayname...
try:
wx.App.GetAppDisplayName
except AttributeError:
wx.App.GetAppDisplayName = wx.App.GetAppName
wx.App.SetAppDisplayName = wx.App.SetAppName
SOT = 0
#if 'wxMac' in wx.PlatformInfo:
# SOT = wx.STAY_ON_TOP
#----------------------------------------------------------------------
class UpdateAbortedError(RuntimeError):
pass
class SoftwareUpdate(object):
"""
Mix this class with wx.App and call InitForUpdates from the derived class'
OnInit method. Be sure that the wx.App has set a display name
(self.SetAppDisplayName) as that value will be used in the update dialogs.
"""
_caption = "Software Update"
_networkFailureMsg = (
"Unable to connect to %s to check for updates.\n\n"
"Perhaps your network is not enabled, the update server is down, or your"
"firewall is blocking the connection.")
def InitUpdates(self, updatesURL, changelogURL=None, icon=None):
"""
Set up the Esky object for doing software updates. Passing either the
base URL (with a trailing '/') for the location of the update
packages, or an instance of a class derived from the
esky.finder.VersionFinder class is required. A custom VersionFinder
can be used to find and fetch the newer verison of the software in
some other way, if desired.
Call this method from the app's OnInit method.
"""
if isFrozenApp:
self._esky = esky.Esky(sys.executable, updatesURL)
self._updatesURL = updatesURL
self._changelogURL = changelogURL
self._icon = icon
self._pd = None
self._checkInProgress = False
try:
# get rid of the prior version if it is still here.
if self._esky.needs_cleanup():
self._esky.cleanup()
except:
pass
self._fixSysExecutable()
def AutoCheckForUpdate(self, frequencyInDays, parentWindow=None, cfg=None):
"""
If it has been frequencyInDays since the last auto-check then check if
a software update is available and prompt the user to download and
install it. This can be called after a application has started up, and
if there is no update available the user will not be bothered.
"""
if not isFrozenApp:
return
if cfg is None:
cfg = wx.Config.Get()
oldPath = cfg.GetPath()
cfg.SetPath('/autoUpdate')
lastCheck = cfg.ReadInt('lastCheck', 0)
lastCheckVersion = cfg.Read('lastCheckVersion', '')
today = int(wx.DateTime.Today().GetJulianDayNumber())
active = self._esky.active_version
if (today - lastCheck >= frequencyInDays
or lastCheckVersion != active):
self.CheckForUpdate(True, parentWindow, cfg)
cfg.SetPath(oldPath)
def CheckForUpdate(self, silentUnlessUpdate=False, parentWindow=None, cfg=None):
"""
This method will check for the availability of a new update, and will
prompt the user with details if there is one there. By default it will
also tell the user if there is not a new update, but you can pass
silentUnlessUpdate=True to not bother the user if there isn't a new
update available.
This method should be called from an event handler for a "Check for
updates" menu item, or something similar. The actual update check
will be run in a background thread and this function will return
immediately after starting the thread so the application is not
blocked if there is network communication problems. A callback to the
GUI thread will be made to do the update or report problems as
needed.
"""
if not isFrozenApp or self._checkInProgress:
return
self._checkInProgress = True
def doFindUpdate():
try:
newest = self._esky.find_update()
chLogTxt = ''
if newest is not None and self._changelogURL:
req = urllib2.urlopen(self._changelogURL, timeout=4)
chLogTxt = req.read()
req.close()
return (newest, chLogTxt)
except urllib2.URLError:
return 'URLError'
def processResults(result):
result = result.get()
self._checkInProgress = False
if result == 'URLError':
if not silentUnlessUpdate:
MultiMessageBox(
self._networkFailureMsg % self._updatesURL,
self._caption, parent=parentWindow, icon=self._icon,
style=wx.OK|SOT)
return
active = self._esky.active_version
if cfg:
oldPath = cfg.GetPath()
cfg.SetPath('/autoUpdate')
today = int(wx.DateTime.Today().GetJulianDayNumber())
cfg.WriteInt('lastCheck', today)
cfg.Write('lastCheckVersion', active)
cfg.Flush()
cfg.SetPath(oldPath)
newest, chLogTxt = result
if newest is None:
if not silentUnlessUpdate:
MultiMessageBox("You are already running the newest verison of %s." %
self.GetAppDisplayName(),
self._caption, parent=parentWindow, icon=self._icon,
style=wx.OK|SOT)
return
self._parentWindow = parentWindow
resp = MultiMessageBox("A new version of %s is available.\n\n"
"You are currently running verison %s; version %s is now "
"available for download. Do you wish to install it now?"
% (self.GetAppDisplayName(), active, newest),
self._caption, msg2=chLogTxt, style=wx.YES_NO|SOT,
parent=parentWindow, icon=self._icon,
btnLabels={wx.ID_YES:"Yes, install now",
wx.ID_NO:"No, maybe later"})
if resp != wx.YES:
return
# Ok, there is a little trickery going on here. We don't know yet if
# the user wants to restart the application after the update is
# complete, but since atexit functions are executed in a LIFO order we
# need to registar our function before we call auto_update and Esky
# possibly registers its own atexit function, because we want ours to
# be run *after* theirs. So we'll create an instance of an info object
# and register its method now, and then fill in the details below
# once we decide what we want to do.
class RestartInfo(object):
def __init__(self):
self.exe = None
def restart(self):
if self.exe is not None:
# Execute the program, replacing this process
os.execv(self.exe, [self.exe] + sys.argv[1:])
info = RestartInfo()
atexit.register(info.restart)
try:
# Let Esky handle all the rest of the update process so we can
# take advantage of the error checking and priviledge elevation
# (if neccessary) that they have done so we don't have to worry
# about that ourselves like we would if we broke down the proccess
# into component steps.
self._esky.auto_update(self._updateProgress)
except UpdateAbortedError:
MultiMessageBox("Update canceled.", self._caption,
parent=parentWindow, icon=self._icon,
style=wx.OK|SOT)
if self._pd:
self._pd.Destroy()
self.InitUpdates(self._updatesURL, self._changelogURL, self._icon)
return
# Ask the user if they want the application to be restarted.
resp = MultiMessageBox("The upgrade to %s %s is ready to use; the application will "
"need to be restarted to begin using the new release.\n\n"
"Restart %s now?"
% (self.GetAppDisplayName(), newest, self.GetAppDisplayName()),
self._caption, style=wx.YES_NO|SOT,
parent=parentWindow, icon=self._icon,
btnLabels={wx.ID_YES:"Yes, restart now",
wx.ID_NO:"No, I'll restart later"})
if resp == wx.YES:
# Close all windows in this application...
for w in wx.GetTopLevelWindows():
if isinstance(w, wx.Dialog):
w.Destroy()
elif isinstance(w, wx.Frame):
w.Close(True) # force close (can't be cancelled)
wx.Yield()
# ...find the path of the esky bootstrap/wrapper program...
exe = esky.util.appexe_from_executable(sys.executable)
# ...and tell our RestartInfo object about it.
info.exe = exe
# Make sure the CWD not in the current version's appdir, so it can
# hopefully be cleaned up either as we exit or as the next verison
# is starting.
os.chdir(os.path.dirname(exe))
# With all the top level windows closed the MainLoop should exit
# automatically, but just in case tell it to exit so we can have a
# normal-as-possible shutdown of this process. Hopefully there
# isn't anything happening after we return from this function that
# matters.
self.ExitMainLoop()
return
# Start the worker thread that will check for an update, it will call
# processResults when it is finished.
import wx.lib.delayedresult as dr
dr.startWorker(processResults, doFindUpdate)
def _updateProgress(self, status):
# Show progress of the download and install. This function is passed to Esky
# functions to use as a callback.
if self._pd is None and status.get('status') != 'done':
self._pd = wx.ProgressDialog('Software Update', ' '*40,
style=wx.PD_CAN_ABORT|wx.PD_APP_MODAL,
parent=self._parentWindow)
self._pd.Update(0, '')
if self._parentWindow:
self._pd.CenterOnParent()
simpleMsgMap = { 'searching' : 'Searching...',
'retrying' : 'Retrying...',
'ready' : 'Download complete...',
'installing' : 'Installing...',
'cleaning up' : 'Cleaning up...',}
if status.get('status') in simpleMsgMap:
self._doUpdateProgress(True, simpleMsgMap[status.get('status')])
elif status.get('status') == 'found':
self._doUpdateProgress(True, 'Found version %s...' % status.get('new_version'))
elif status.get('status') == 'downloading':
received = status.get('received')
size = status.get('size')
currentPercentage = 1.0 * received / size * 100
if currentPercentage > 99.5:
self._doUpdateProgress(False, "Unzipping...", int(currentPercentage))
else:
self._doUpdateProgress(False, "Downloading...", int(currentPercentage))
elif status.get('status') == 'done':
if self._pd:
self._pd.Destroy()
self._pd = None
wx.Yield()
def _doUpdateProgress(self, pulse, message, value=0):
if pulse:
keepGoing, skip = self._pd.Pulse(message)
else:
keepGoing, skip = self._pd.Update(value, message)
if not keepGoing: # user pressed the cancel button
self._pd.Destroy()
self._pd = None
raise UpdateAbortedError()
def _fixSysExecutable(self):
# It looks like at least some versions of py2app are setting
# sys.executable to ApplicationName.app/Contents/MacOS/python instead
# of ApplicationName.app/Contents/MacOS/applicationname, which is what
# should be used to relaunch the application. Other freezer tools set
# sys.executable to the actual executable as expected, so we'll tweak
# the setting here for Macs too.
if sys.platform == "darwin" and hasattr(sys, 'frozen') \
and sys.frozen == 'macosx_app' and sys.executable.endswith('MacOS/python'):
names = os.listdir(os.path.dirname(sys.executable))
assert len(names) == 2 # there should be only 2
for name in names:
if name != 'python':
sys.executable = os.path.join(os.path.dirname(sys.executable), name)
break
#----------------------------------------------------------------------
|