diff --git a/src/gui_qt/lib/utils.py b/src/gui_qt/lib/utils.py index 45f79c9..bafa3c6 100644 --- a/src/gui_qt/lib/utils.py +++ b/src/gui_qt/lib/utils.py @@ -1,9 +1,13 @@ -import sys +from __future__ import annotations + +from functools import lru_cache from os import getenv, stat +from os.path import exists import hashlib import subprocess from datetime import datetime from contextlib import contextmanager +from pathlib import Path import requests from numpy import linspace @@ -58,9 +62,7 @@ class RdBuCMap: class UpdateDialog(QtWidgets.QDialog): - host = 'mirror.infra.pkm' - bucket = 'nmreval' - version = 'NMReval-latest-x86_64' + startDownload = QtCore.pyqtSignal(list) def __init__(self, filename: str = None, parent=None): super().__init__(parent=parent) @@ -69,11 +71,20 @@ class UpdateDialog(QtWidgets.QDialog): if filename is None: filename = getenv('APPIMAGE') self._appfile = filename - self._url_zsync = f'http://{self.host}/{self.bucket}/{self.version}.AppImage.zsync' - self.process = QtCore.QProcess(self) + self.success = False + self.updater = Updater() - self.look_for_updates() + self.thread = QtCore.QThread(self) + self.thread.start() + self.helper = Downloader() + self.startDownload.connect(self.helper.run_download) + self.helper.progressChanged.connect(self.status.setText) + self.helper.finished.connect(self.finish_update) + self.helper.started.connect(self.status.show) + self.helper.moveToThread(self.thread) + + self.look_for_updates(self._appfile) def _init_ui(self): self.setWindowTitle('Updates') @@ -85,50 +96,135 @@ class UpdateDialog(QtWidgets.QDialog): layout.addSpacing(10) + self.status = QtWidgets.QLabel() + self.status.hide() + layout.addWidget(self.status) + layout.addSpacing(10) + self.dialog_button = QtWidgets.QDialogButtonBox() - self.dialog_button.accepted.connect(self.accept) - self.dialog_button.rejected.connect(self.reject) + self.dialog_button.accepted.connect(self.update_appimage) + self.dialog_button.rejected.connect(self.close) layout.addWidget(self.dialog_button) self.setLayout(layout) - def look_for_updates(self): + def look_for_updates(self, filename=None): # Download zsync file of latest Appimage, look for SHA-1 hash and compare with hash of AppImage - m_time_zsync, checksum_zsync = self.get_zsync() - m_time_file, checksum_file = self.get_appimage_info() + is_updateble, m_time_file, m_time_zsync = self.updater.get_update_information(filename) if m_time_zsync is None: label_text = '

Retrieval of version information failed.

' \ '

Please try later (or complain to people that it does not work).

' dialog_bttns = QtWidgets.QDialogButtonBox.Close else: - label_text = f'

Found most recent update: {m_time_zsync.strftime("%d %B %Y %H:%M")}

' + label_text = f'

Date of most recent AppImage: {m_time_zsync.strftime("%d %B %Y %H:%M")}

' if m_time_file is None: - label_text += 'No AppImage file found, press Ok to downlaod latest version.' + label_text += 'No AppImage file found, press Ok to download latest version.' dialog_bttns = QtWidgets.QDialogButtonBox.Ok|QtWidgets.QDialogButtonBox.Close else: label_text += f'

Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}

' - if not ((checksum_file is not None) and (checksum_zsync is not None)): - label_text += 'Could not determine if this version is newer, please update manually (if necessary).' + if is_updateble is None: + self.status.setText('Could not determine if this version is newer, please update manually (if necessary).') dialog_bttns = QtWidgets.QDialogButtonBox.Close - elif checksum_file != checksum_zsync: - label_text += f'

Newer version available. Update?

' + elif is_updateble: + self.status.setText(f'

Newer version available. Press Ok to download new version, Cancel to ignore.') dialog_bttns = QtWidgets.QDialogButtonBox.Ok|QtWidgets.QDialogButtonBox.Cancel else: - label_text += f'

Version is already the newest

' + self.status.setText(f'Version may be already up-to-date.') dialog_bttns = QtWidgets.QDialogButtonBox.Close self.label.setText(label_text) self.dialog_button.setStandardButtons(dialog_bttns) - def get_zsync(self): + @QtCore.pyqtSlot() + def update_appimage(self): + if self._appfile is None: + args = [self.updater.zsync_url] + else: + # this breaks the download for some reason + args = ['-i', self._appfile, self.updater.zsync_url] + + args = [self.updater.zsync_url] + + self.dialog_button.setEnabled(False) + + self.startDownload.emit(args) + self.status.show() + + @QtCore.pyqtSlot(int) + def finish_update(self, retcode: int): + # print('finished with', retcode) + self.success = retcode == 0 + if retcode == 0: + self.status.setText('Download complete.') + else: + self.status.setText(f'Download failed :( with return code {retcode}.') + self.dialog_button.setStandardButtons(QtWidgets.QDialogButtonBox.Close) + self.dialog_button.setEnabled(True) + + def closeEvent(self, evt): + self.thread.quit() + self.thread.wait() + + if self.success: + appname = self.updater.get_zsync()[2] + if self._appfile is not None: + appimage_path = appname + old_version = Path(self._appfile).rename(self._appfile+'.old') + appimage_path = Path(appimage_path).replace(self._appfile) + else: + appimage_path = Path().cwd() / appname + # rename to version-agnostic name + appimage_path = appimage_path.rename('NMReval.AppImage') + + _ = QtWidgets.QMessageBox.information(self, 'Complete', + f'New AppImage available at
{appimage_path}') + + super().closeEvent(evt) + + +class Downloader(QtCore.QObject): + started = QtCore.pyqtSignal() + finished = QtCore.pyqtSignal(int) + progressChanged = QtCore.pyqtSignal(str) + + @QtCore.pyqtSlot(list) + def run_download(self, args: list[str]): + process = subprocess.Popen(['zsync'] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1, universal_newlines=True) + while True: + nextline = process.stdout.readline().strip() + if nextline: + self.progressChanged.emit(nextline) + + # line = process.stderr.readline().strip() + + if process.poll() is not None: + break + + self.finished.emit(process.returncode) + + +class Updater: + host = 'mirror.infra.pkm' + bucket = 'nmreval' + version = 'NMReval-latest-x86_64' + + @property + def zsync_url(self): + return f'http://{Updater.host}/{Updater.bucket}/{Updater.version}.AppImage.zsync' + + @staticmethod + @lru_cache(3) + def get_zsync(): + url_zsync = f'http://{Updater.host}/{Updater.bucket}/{Updater.version}.AppImage.zsync' m_time_zsync = None checksum_zsync = None zsync_file = None + filename = None try: - response = requests.get(self._url_zsync) + response = requests.get(url_zsync) if response.status_code == requests.codes['\o/']: zsync_file = response.content except Exception as e: @@ -142,49 +238,39 @@ class UpdateDialog(QtWidgets.QDialog): m_time_zsync = datetime.strptime(str(val, encoding='utf-8'), '%a, %d %b %Y %H:%M:%S %z').astimezone(None) elif kw == b'SHA-1': checksum_zsync = str(val, encoding='utf-8') + elif kw == b'Filename': + filename = str(val, encoding='utf-8') except ValueError: # stop when empty line is reached break - return m_time_zsync, checksum_zsync + return m_time_zsync, checksum_zsync, filename - def get_appimage_info(self): - if self._appfile is None: + @staticmethod + def get_appimage_info(filename: str): + if filename is None: return None, None - stat_mtime = stat(self._appfile).st_mtime + if not exists(filename): + return None, None + + stat_mtime = stat(filename).st_mtime m_time_file = datetime.fromtimestamp(stat_mtime).replace(microsecond=0) - with open(self._appfile, 'rb') as f: + with open(filename, 'rb') as f: checksum_file = hashlib.sha1(f.read()).hexdigest() return m_time_file, checksum_file - def update_appimage(self): - if self._appfile is None: - args = [self._url_zsync] + @staticmethod + def get_update_information(filename: str) -> tuple[(bool | None), datetime, datetime]: + m_time_zsync, checksum_zsync, appname = Updater.get_zsync() + m_time_file, checksum_file = Updater.get_appimage_info(filename) + + if not ((checksum_file is not None) and (checksum_zsync is not None)): + return None, m_time_file, m_time_zsync else: - args = ['-i', self._appfile, self._url_zsync] - - self.process.readyReadStandardOutput.connect(self.onReadyReadStandardOutput) - self.process.readyReadStandardError.connect(self.onReadyReadStandardOutput) - self.process.start('zsync', args) - - if not self.process.waitForFinished(): - return False - - return True - - def onReadyReadStandardOutput(self): - result = self.process.readAllStandardOutput().data().decode() - self.label.setText(result) - - - def accept(self): - if self.update_appimage(): - _ = QtWidgets.QMessageBox.information(self, 'Updates', 'Download finished. Execute AppImage for new version.') - super().accept() - + return checksum_file != checksum_zsync, m_time_file, m_time_zsync def open_bug_report(): @@ -205,12 +291,3 @@ def open_bug_report(): import webbrowser webbrowser.open(full_url) - - - -if __name__ == '__main__': - app = QtWidgets.QApplication([]) - w = UpdateDialog() - w.show() - - sys.exit(app.exec()) diff --git a/src/gui_qt/main/mainwindow.py b/src/gui_qt/main/mainwindow.py index 9c091cf..1ccad4a 100644 --- a/src/gui_qt/main/mainwindow.py +++ b/src/gui_qt/main/mainwindow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import pathlib import re from pathlib import Path @@ -27,7 +28,7 @@ from ..math.smooth import QSmooth from ..nmr.coupling_calc import QCoupCalcDialog from ..nmr.t1_from_tau import QRelaxCalc from .._py.basewindow import Ui_BaseWindow -from ..lib.utils import UpdateDialog, open_bug_report +from ..lib.utils import UpdateDialog, open_bug_report, Updater class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @@ -68,6 +69,10 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): self._init_gui() self._init_signals() + if os.getenv('APPIMAGE') is not None: + if Updater.get_update_information(os.getenv('APPIMAGE'))[0]: + self.look_for_update() + def _init_gui(self): self.setupUi(self) make_action_icons(self)