diff --git a/src/vorta/application.py b/src/vorta/application.py index 17ec6752a..d4fc83fe8 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -20,7 +20,7 @@ from vorta.store.connection import cleanup_db from vorta.store.models import BackupProfileModel, SettingsModel from vorta.tray_menu import TrayMenu -from vorta.utils import borg_compat, parse_args +from vorta.utils import borg_compat, parse_args, AsyncRunner from vorta.views.main_window import MainWindow logger = logging.getLogger(__name__) @@ -42,6 +42,8 @@ class VortaApp(QtSingleApplication): backup_log_event = QtCore.pyqtSignal(str, dict) backup_progress_event = QtCore.pyqtSignal(str) check_failed_event = QtCore.pyqtSignal(dict) + pre_backup_event = QtCore.pyqtSignal(int) + post_backup_event = QtCore.pyqtSignal(int, bool) def __init__(self, args_raw, single_app=False): super().__init__(str(APP_ID), args_raw) @@ -84,6 +86,8 @@ def __init__(self, args_raw, single_app=False): self.message_received_event.connect(self.message_received_event_response) self.check_failed_event.connect(self.check_failed_response) self.backup_log_event.connect(self.react_to_log) + self.pre_backup_event.connect(self.pre_backup_event_response) + self.post_backup_event.connect(self.post_backup_event_response) self.aboutToQuit.connect(self.quit_app_action) self.set_borg_details_action() if sys.platform == 'darwin': @@ -105,12 +109,13 @@ def quit_app_action(self): del self.tray cleanup_db() - def create_backup_action(self, profile_id=None): + @AsyncRunner + def create_backup_action(self, profile_id=None, app=None): if not profile_id: profile_id = self.main_window.current_profile.id profile = BackupProfileModel.get(id=profile_id) - msg = BorgCreateJob.prepare(profile) + msg = BorgCreateJob.prepare(profile, app=self) if msg['ok']: job = BorgCreateJob(msg['cmd'], msg, profile.repo.id) self.jobs_manager.add_job(job) @@ -146,6 +151,13 @@ def backup_cancelled_event_response(self): self.jobs_manager.cancel_all_jobs() self.tray.set_tray_icon() + def pre_backup_event_response(self, pid): + self.tray.set_tray_icon(active=True) + + def post_backup_event_response(self, pid, active=False): + if not active: + self.tray.set_tray_icon() + def message_received_event_response(self, message): if message == "open main window": self.open_main_window_action() diff --git a/src/vorta/borg/create.py b/src/vorta/borg/create.py index 171265c04..37b1ff503 100644 --- a/src/vorta/borg/create.py +++ b/src/vorta/borg/create.py @@ -63,12 +63,13 @@ def started_event(self): def finished_event(self, result): self.app.backup_finished_event.emit(result) self.result.emit(result) - self.pre_post_backup_cmd(self.params, cmd='post_backup_cmd', returncode=result['returncode']) + self.pre_post_backup_cmd(self.params, context='post_backup_cmd', app=self.app, returncode=result['returncode']) @classmethod - def pre_post_backup_cmd(cls, params, cmd='pre_backup_cmd', returncode=0): - cmd = getattr(params['profile'], cmd) + def pre_post_backup_cmd(cls, params, context='pre_backup_cmd', app=None, returncode=0): + cmd = getattr(params['profile'], context) if cmd: + profile_name = getattr(params['profile'], 'name') env = { **os.environ.copy(), 'repo_url': params['repo'].url, @@ -76,13 +77,20 @@ def pre_post_backup_cmd(cls, params, cmd='pre_backup_cmd', returncode=0): 'profile_slug': params['profile'].slug(), 'returncode': str(returncode), } - proc = subprocess.run(cmd, shell=True, env=env) + proc = subprocess.Popen(cmd, shell=True, env=env) + if context.startswith('pre'): + app.backup_progress_event.emit(f"[{profile_name}] {trans_late('messages', 'Waiting to start backup')}") + app.pre_backup_event.emit(proc.pid) + else: + app.post_backup_event.emit(proc.pid, True) + proc.wait() + app.post_backup_event.emit(None, False) return proc.returncode else: return 0 # 0 if no command was run. @classmethod - def prepare(cls, profile): + def prepare(cls, profile, app=None): """ `borg create` is called from different places and needs some preparation. Centralize it here and return the required arguments to the caller. @@ -133,7 +141,7 @@ def prepare(cls, profile): ret['repo'] = profile.repo # Run user-supplied pre-backup command - if cls.pre_post_backup_cmd(ret) != 0: + if cls.pre_post_backup_cmd(ret, app=app) != 0: ret['message'] = trans_late('messages', 'Pre-backup command returned non-zero exit code.') return ret diff --git a/src/vorta/scheduler.py b/src/vorta/scheduler.py index 9ff19681c..805468a3e 100644 --- a/src/vorta/scheduler.py +++ b/src/vorta/scheduler.py @@ -19,7 +19,7 @@ from vorta.i18n import translate from vorta.notifications import VortaNotifications from vorta.store.models import BackupProfileModel, EventLogModel -from vorta.utils import borg_compat +from vorta.utils import borg_compat, AsyncRunner logger = logging.getLogger(__name__) @@ -389,6 +389,7 @@ def next_job_for_profile(self, profile_id: int) -> ScheduleStatus: return ScheduleStatus(ScheduleStatusType.UNSCHEDULED) return ScheduleStatus(job['type'], time=job.get('dt')) + @AsyncRunner def create_backup(self, profile_id): notifier = VortaNotifications.pick() profile = BackupProfileModel.get_or_none(id=profile_id) @@ -410,7 +411,7 @@ def create_backup(self, profile_id): self.tr('Starting background backup for %s.') % profile.name, level='info', ) - msg = BorgCreateJob.prepare(profile) + msg = BorgCreateJob.prepare(profile, app=self.app) if msg['ok']: logger.info('Preparation for backup successful.') msg['category'] = 'scheduled' diff --git a/src/vorta/utils.py b/src/vorta/utils.py index fbe1e43fd..da17092da 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -14,7 +14,7 @@ import psutil from PyQt6 import QtCore -from PyQt6.QtCore import QFileInfo, QThread, pyqtSignal +from PyQt6.QtCore import QFileInfo, QObject, QThread, pyqtSignal from PyQt6.QtWidgets import QApplication, QFileDialog, QSystemTrayIcon from vorta.borg._compatibility import BorgCompatibility @@ -31,6 +31,53 @@ _network_status_monitor = None +class AsyncRunner(QObject): + ''' + Wrapper to run functions asynchronously from GUI thread, based on + https://gist.github.com/andgineer/026a617528c5740da24ec984ac282ee6#file-universal_decorator-py + + NB Only apply it to void functions, otherwise return values will be lost. + ''' + runner_thread = None + + def __init__(self, orig_func): + super(AsyncRunner, self).__init__() + self.orig_func = orig_func + self.__name__ = "AsyncRunner" + + def __call__(self, *args): + return self.orig_func(*args) + + def __get__(self, wrapped_instance, owner): + return AsyncRunner.Helper(self, wrapped_instance) + + class Helper(QObject): + def __init__(self, decorator_instance, wrapped_instance): + super(AsyncRunner.Helper, self).__init__() + self.decorator_instance = decorator_instance + self.wrapped_instance = wrapped_instance + + def __call__(self, *args, **kwargs): + self.runner = AsyncRunner.Runner(self.decorator_instance, self.wrapped_instance, *args, **kwargs) + self.runner.finished.connect(self.runner_finished) + self.runner.start() + + def runner_finished(self): + self.runner.wait(100) + self.runner = None + + class Runner(QtCore.QThread): + def __init__(self, decorator_instance, wrapped_instance, *args, **kwargs): + QtCore.QThread.__init__(self) + self.decorator_instance = decorator_instance + self.wrapped_instance = wrapped_instance + self.args = args + self.kwargs = kwargs + + def run(self): + self.decorator_instance(self.wrapped_instance, *self.args, **self.kwargs) + + class FilePathInfoAsync(QThread): signal = pyqtSignal(str, str, str) diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index a0bb988ea..e9b395a75 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -99,6 +99,8 @@ def __init__(self, parent=None): self.app.backup_log_event.connect(self.set_log) self.app.backup_progress_event.connect(self.set_progress) self.app.backup_cancelled_event.connect(self.backup_cancelled_event) + self.app.pre_backup_event.connect(self.pre_backup_event) + self.app.post_backup_event.connect(self.post_backup_event) # Init profile list self.populate_profile_selector() @@ -339,6 +341,17 @@ def backup_cancelled_event(self): self.set_log(self.tr('Task cancelled')) self.archiveTab.cancel_action() + def pre_backup_event(self, pid): + self._toggle_buttons(create_enabled=False) + self.set_log(self.tr(f"Running pre backup commands [PID: {pid}]")) + + def post_backup_event(self, pid, active=True): + if active: + self.set_log(self.tr(f"Running post backup commands [PID: {pid}]")) + else: + self._toggle_buttons(create_enabled=True) + self.set_log('') + def closeEvent(self, event): # Save window state in SettingsModel SettingsModel.update({SettingsModel.str_value: str(self.width())}).where(