diff --git a/src/updater/__init__.py b/src/updater/__init__.py index 49b1cd04..cff6566c 100644 --- a/src/updater/__init__.py +++ b/src/updater/__init__.py @@ -1,2 +1,12 @@ -import updater -import update_manager \ No newline at end of file +import glob +import os.path +import platform + +def find_datafiles(): + system = platform.system() + if system == 'Windows': + file_ext = '*.exe' + else: + file_ext = '*.sh' + path = os.path.abspath(os.path.join(__path__[0], 'bootstrappers', file_ext)) + return [('', glob.glob(path))] diff --git a/src/updater/update.py b/src/updater/update.py new file mode 100644 index 00000000..ccd56fce --- /dev/null +++ b/src/updater/update.py @@ -0,0 +1,114 @@ +from logging import getLogger +logger = getLogger('update') + +import contextlib +import io +import os +import platform +import requests +import tempfile +try: + import czipfile as zipfile +except ImportError: + import zipfile + +from platform_utils import paths + +def perform_update(endpoint, current_version, app_name='', password=None, update_available_callback=None, progress_callback=None, update_complete_callback=None): + requests_session = create_requests_session(app_name=app_name, version=current_version) + available_update = find_update(endpoint, requests_session=requests_session) + if not available_update: + return + available_version = available_update['current_version'] + if not str(available_version) > str(current_version) or platform.system() not in available_update['downloads']: + return + available_description = available_update.get('description', None) + update_url = available_update ['downloads'][platform.system()] + logger.info("A new update is available. Version %s" % available_version) + if callable(update_available_callback) and not update_available_callback(version=available_version, description=available_description): #update_available_callback should return a falsy value to stop the process + logger.info("User canceled update.") + return + base_path = tempfile.mkdtemp() + download_path = os.path.join(base_path, 'update.zip') + update_path = os.path.join(base_path, 'update') + downloaded = download_update(update_url, download_path, requests_session=requests_session, progress_callback=progress_callback) + extracted = extract_update(downloaded, update_path, password=password) + bootstrap_path = move_bootstrap(extracted) + execute_bootstrap(bootstrap_path, extracted) + logger.info("Update prepared for installation.") + if callable(update_complete_callback): + update_complete_callback() + +def create_requests_session(app_name=None, version=None): + user_agent = '' + session = requests.session() + if app_name: + user_agent = ' %s/%r' % (app_name, version) + session.headers['User-Agent'] = session.headers['User-Agent'] + user_agent + return session + +def find_update(endpoint, requests_session): + response = requests_session.get(endpoint) + response.raise_for_status() + content = response.json() + return content + +def download_update(update_url, update_destination, requests_session, progress_callback=None, chunk_size=io.DEFAULT_BUFFER_SIZE): + total_downloaded = total_size = 0 + with io.open(update_destination, 'w+b') as outfile: + download = requests_session.get(update_url, stream=True) + total_size = int(download.headers.get('content-length', 0)) + logger.debug("Total update size: %d" % total_size) + download.raise_for_status() + for chunk in download.iter_content(chunk_size): + outfile.write(chunk) + total_downloaded += len(chunk) + if callable(progress_callback): + call_callback(progress_callback, total_downloaded, total_size) + logger.debug("Update downloaded") + return update_destination + +def extract_update(update_archive, destination, password=None): + """Given an update archive, extracts it. Returns the directory to which it has been extracted""" + with contextlib.closing(zipfile.ZipFile(update_archive)) as archive: + if password: + archive.setpassword(password) + archive.extractall(path=destination) + logger.debug("Update extracted") + return destination + +def move_bootstrap(extracted_path): + working_path = os.path.abspath(os.path.join(extracted_path, '..')) + if platform.system() == 'Darwin': + extracted_path = os.path.join(extracted_path, 'Contents', 'Resources') + downloaded_bootstrap = os.path.join(extracted_path, bootstrap_name()) + new_bootstrap_path = os.path.join(working_path, bootstrap_name()) + os.rename(downloaded_bootstrap, new_bootstrap_path) + return new_bootstrap_path + +def execute_bootstrap(bootstrap_path, source_path): + arguments = r'"%s" "%s" "%s" "%s"' % (os.getpid(), source_path, paths.app_path(), paths.get_executable()) + if platform.system() == 'Windows': + import win32api + win32api.ShellExecute(0, 'open', bootstrap_path, arguments, '', 5) + else: + import subprocess + make_executable(bootstrap_path) + subprocess.Popen(['%s %s' % (bootstrap_path, arguments)], shell=True) + logger.info("Bootstrap executed") + +def bootstrap_name(): + if platform.system() == 'Windows': return 'bootstrap.exe' + if platform.system() == 'Darwin': return 'bootstrap-mac.sh' + return 'bootstrap-lin.sh' + +def make_executable(path): + import stat + st = os.stat(path) + os.chmod(path, st.st_mode | stat.S_IEXEC) + +def call_callback(callback, *args, **kwargs): +# try: + callback(*args, **kwargs) +# except: +# logger.exception("Failed calling callback %r with args %r and kwargs %r" % (callback, args, kwargs)) diff --git a/src/updater/update_manager.py b/src/updater/update_manager.py deleted file mode 100644 index 5a99e63b..00000000 --- a/src/updater/update_manager.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -import os, sys, wx -from mysc import paths -import application, updater -from mysc.thread_utils import call_threaded -import logging as original_logger -log = original_logger.getLogger("update_manager") - -def check_for_update(msg=False): - log.debug(u"Checking for updates...") - url = updater.find_update_url(application.update_url, application.version) - if url is None: - if msg == True: - wx.MessageDialog(None, _(u"Your TW Blue version is up to date"), _(u"Update"), style=wx.OK).ShowModal() - return - else: - log.debug(u"New version from %s " % url) - new_path = os.path.join(paths.app_path("delete-me"), 'updates', 'update.zip') - log.debug(u"Descargando actualizaciĆ³n en %s" % new_path) - d = wx.MessageDialog(None, _(u"There's a new TW Blue version available. Would you like to download it now?"), _(u"New version for %s") % application.name, style=wx.YES|wx.NO|wx.ICON_WARNING) - if d.ShowModal() == wx.ID_YES: - progress = wx.ProgressDialog(_(u"Download in Progress"), _(u"Downloading the new version..."), parent=None, maximum=100, style = wx.PD_APP_MODAL) - def update(percent): - if percent == 100: - progress.Destroy() - else: - progress.Update(percent, _(u"Update")) - def update_complete(): - wx.MessageDialog(None, _(u"The new TW Blue version has been downloaded and installed. Press OK to start the application."), _(u"Done!")).ShowModal() - sys.exit() - app_updater = updater.AutoUpdater(url, new_path, 'bootstrap.exe', app_path=paths.app_path(), postexecute=paths.app_path("TWBlue.exe"), finish_callback=update_complete, percentage_callback=update) - app_updater.start_update() - progress.ShowModal() - else: - return - diff --git a/src/updater/updater.py b/src/updater/updater.py index f52b70da..de04b603 100644 --- a/src/updater/updater.py +++ b/src/updater/updater.py @@ -1,220 +1,12 @@ -#AutoUpdater -#Released under an MIT license - -import logging -logger = logging.getLogger('updater') - +# -*- coding: utf-8 -*- import application -from urllib import FancyURLopener, URLopener -import urllib2 -from functools import total_ordering -import hashlib -import os -try: - from czipfile import ZipFile -except ImportError: - from zipfile import ZipFile -import subprocess -import stat +import update import platform -import shutil -import json -if platform.system() == 'Windows': - import win32api +#if platform.system() == "Windows": +from wxUpdater import * - -class AutoUpdater(object): - - def __init__(self, URL, save_location, bootstrapper, app_path, postexecute=None, password=None, MD5=None, percentage_callback=None, finish_callback=None): - """Supply a URL/location/bootstrapper filename to download a zip file from - The finish_callback argument should be a Python function it'll call when done""" - #Let's download the file using urllib - self.complete = 0 - self.finish_callback = finish_callback #What to do on exit - self.percentage_callback = percentage_callback or self.print_percentage_callback - self.URL = URL - self.bootstrapper = bootstrapper - #The application path on the Mac should be 1 directory up from where the .app file is. - tempstr = "" - if (platform.system() == "Darwin"): - for x in (app_path.split("/")): - if (".app" in x): - break - else: - tempstr = os.path.join(tempstr, x) - app_path = "/" + tempstr + "/" - #The post-execution path should include the .app file - tempstr = "" - for x in (postexecute.split("/")): - if (".app" in x): - tempstr = os.path.join(tempstr, x) - break - else: - tempstr = os.path.join(tempstr, x) - postexecute = "/" + tempstr - self.app_path = app_path - self.postexecute = postexecute - logging.info("apppath: " + str(app_path)) - logging.info("postexecute: " + str(postexecute)) - self.password = password - self.MD5 = MD5 - self.save_location = save_location - #self.save_location contains the full path, including the blabla.zip - self.save_directory = os.path.join(*os.path.split(save_location)[:-1]) - #self.save_directory doesn't contain the blabla.zip - - def prepare_staging_directory(self): - if not os.path.exists(self.save_directory): - #We need to make all folders but the last one - os.makedirs(self.save_directory) - logger.info("Created staging directory %s" % self.save_directory) - - def transfer_callback(self, count, bSize, tSize): - """Callback to update percentage of download""" - percent = int(count*bSize*100/tSize) - self.percentage_callback(percent) - - @staticmethod - def print_percentage_callback(percent): - print percent - - def start_update(self): - """Called to start the whole process""" - logger.debug("URL: %s SL: %s" % (self.URL, self.save_location)) - self.prepare_staging_directory() - Listy = CustomURLOpener().retrieve(self.URL, self.save_location, reporthook=self.transfer_callback) - if self.MD5: - #Check the MD5 - if self.MD5File(location) != self.MD5: - #ReDownload - self.start_update() - self.download_complete(Listy[0]) - - def MD5File(self, fileName): - "Custom function that will get the Md5 sum of our file" - file_reference=open(fileName, 'rb').read() - return hashlib.md5(file_reference).hexdigest() - - def download_complete(self, location): - """Called when the file is done downloading, and MD5 has been successfull""" - logger.debug("Download complete.") - zippy = ZipFile(location, mode='r') - extracted_path = os.path.join(self.save_directory, os.path.basename(location).strip(".zip")) - zippy.extractall(extracted_path, pwd=self.password) - bootstrapper_path = os.path.join(self.save_directory, self.bootstrapper) #where we will find our bootstrapper - old_bootstrapper_path = os.path.join(extracted_path, self.bootstrapper) - if os.path.exists(bootstrapper_path): - os.chmod(bootstrapper_path, 666) - os.remove(bootstrapper_path) - shutil.move(old_bootstrapper_path, self.save_directory) #move bootstrapper - os.chmod(bootstrapper_path, stat.S_IRUSR|stat.S_IXUSR) - if platform.system() == "Windows": - bootstrapper_command = r'%s' % bootstrapper_path - bootstrapper_args = r'"%s" "%s" "%s" "%s"' % (os.getpid(), extracted_path, self.app_path, self.postexecute) - win32api.ShellExecute(0, 'open', bootstrapper_command, bootstrapper_args, "", 5) - else: - #bootstrapper_command = [r'sh "%s" -l "%s" -d "%s" "%s"' % (bootstrapper_path, self.app_path, extracted_path, str(os.getpid()))] - bootstrapper_command = r'"%s" "%s" "%s" "%s" "%s"' % (bootstrapper_path, os.getpid(), extracted_path, self.app_path, self.postexecute) - shell = True - #logging.debug("Final bootstrapper command: %r" % bootstrapper_command) - subprocess.Popen([bootstrapper_command], shell=shell) - self.complete = 1 - if callable(self.finish_callback): - self.finish_callback() - - def cleanup(self): - """Delete stuff""" - try: - shutil.rmtree(self.save_directory) - except any: - return - -def find_update_url(URL, version): - """Return a URL to an update of the application for the current platform at the given URL if one exists, or None"" - Assumes Windows, Linux, or Mac""" - response = urllib2.urlopen(URL) - json_str = response.read().strip("\n") - json_p = json.loads(json_str) - if is_newer(version, json_p['current_version']): - if application.snapshot == False: return json_p['downloads'][platform.system()+platform.architecture()[0][:2]] - else: return json_p['downloads'][platform.system()] - - -def is_newer(local_version, remote_version): - """Returns True if the remote version is newer than the local version.""" - return Version(remote_version) > local_version - - - -@total_ordering -class Version(object): - VERSION_QUALIFIERS = { - 'alpha': 1, - 'beta': 2, - 'rc': 3 - } - - def __init__(self, version): - self.version = version - self.version_qualifier = None - self.version_qualifier_num = None - self.sub_version = None - if isinstance(version, basestring): - version = version.lower() - if '-' not in version: - for q in self.VERSION_QUALIFIERS: - if q in version: - self.version_qualifier = q - self.version_qualifier_num = self.VERSION_QUALIFIERS[q] - split_version = version.split(q) - self.version_number = float(split_version[0]) - if len(split_version) > 1: - self.sub_version = split_version[1] - return - self.version_number= float(version) - return - split_version = version.split('-') - self.version_number= float(split_version[0]) - self.version_qualifier = split_version [1] - self.version_qualifier_num = self.VERSION_QUALIFIERS[self.version_qualifier] - if len(split_version) == 3: - self.sub_version = int(split_version[2]) - else: - self.version_number= float(version) - - def __lt__(self, other): - if not isinstance(other, self.__class__): - other = Version(other) - if other.version_qualifier == self.version_qualifier == None: - return self.version_number< other.version_number - if self.version_number < other.version_number: - return True - elif self.version_number > other.version_number: - return False - if other.version_number == self.version_number and not other.version_qualifier_num and self.version_qualifier_num: - return True - if other.version_number == self.version_number and self.version_qualifier_num == self.version_qualifier_num and self.sub_version < other.sub_version: - return True - return self.version_qualifier_num < other.version_qualifier_num - - def __gt__(self, other): - if not isinstance(other, self.__class__): - other = Version(other) - if other.version_qualifier == self.version_qualifier == None: - return self.version_number > other.version_number - if self.version_number < other.version_number: - return False - elif self.version_number > other.version_number: - return True - if other.version_number == self.version_number and not other.version_qualifier_num and self.version_qualifier_num: - return False - if other.version_number == self.version_number and self.version_qualifier_num == self.version_qualifier_num and self.sub_version > other.sub_version: - return True - return self.version_qualifier_num > other.version_qualifier_num - - - - -class CustomURLOpener(FancyURLopener): - def http_error_default(*a, **k): - return URLopener.http_error_default(*a, **k) +def do_update(): + try: + update.perform_update(endpoint=application.update_url, current_version=application.version, app_name=application.name, update_available_callback=available_update_dialog, progress_callback=progress_callback, update_complete_callback=update_finished) + except: + pass \ No newline at end of file diff --git a/src/updater/utils.py b/src/updater/utils.py new file mode 100644 index 00000000..9a20ab26 --- /dev/null +++ b/src/updater/utils.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +def convert_bytes(n): + K, M, G, T, P = 1 << 10, 1 << 20, 1 << 30, 1 << 40, 1 << 50 + if n >= P: + return '%.2fPb' % (float(n) / T) + elif n >= T: + return '%.2fTb' % (float(n) / T) + elif n >= G: + return '%.2fGb' % (float(n) / G) + elif n >= M: + return '%.2fMb' % (float(n) / M) + elif n >= K: + return '%.2fKb' % (float(n) / K) + else: + return '%d' % n + +def seconds_to_string(seconds, precision=0): + day = seconds // 86400 + hour = seconds // 3600 + min = (seconds // 60) % 60 + sec = seconds - (hour * 3600) - (min * 60) + sec_spec = "." + str(precision) + "f" + sec_string = sec.__format__(sec_spec) + string = "" + if day == 1: + string += _(u"%d day, ") % day + elif day >= 2: + string += _(u"%d days, ") % day + if (hour == 1): + string += _(u"%d hour, ") % hour + elif (hour >= 2): + string += _("%d hours, ") % hour + if (min == 1): + string += _(u"%d minute, ") % min + elif (min >= 2): + string += _(u"%d minutes, ") % min + if sec >= 0 and sec <= 2: + string += _(u"%s second") % sec_string + else: + string += _(u"%s seconds") % sec_string + return string \ No newline at end of file diff --git a/src/updater/wxUpdater.py b/src/updater/wxUpdater.py new file mode 100644 index 00000000..b66910b2 --- /dev/null +++ b/src/updater/wxUpdater.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +import wx +import application +import utils + +progress_dialog = None + +def available_update_dialog(version, description): + dialog = wx.MessageDialog(None, _(u"There's a new %s version available. Would you like to download it now?\n\n %s version: %s\n\nChanges:\n%s") % (application.name, application.name, version, description), _(u"New version for %s") % application.name, style=wx.YES|wx.NO|wx.ICON_WARNING) + if dialog.ShowModal() == wx.ID_YES: + return True + else: + return False + + +def create_progress_dialog(): + return wx.ProgressDialog(_(u"Download in Progress"), _(u"Downloading the new version..."), parent=None, maximum=100) + +def progress_callback(total_downloaded, total_size): + global progress_dialog + if progress_dialog == None: + progress_dialog = create_progress_dialog() + progress_dialog.Show() + if total_downloaded == total_size: + progress_dialog.Destroy() + else: + progress_dialog.Update((total_downloaded*100)/total_size, _(u"Updating... %s of %s") % (str(utils.convert_bytes(total_downloaded)), str(utils.convert_bytes(total_size)))) + +def update_finished(): + ms = wx.MessageDialog(None, _(u"The new Lees version has been downloaded and installed. Press OK to start the application."), _(u"Done!")).ShowModal() \ No newline at end of file