From a0098f0737d50e25d0493317190808eaa960b1d6 Mon Sep 17 00:00:00 2001 From: Dominic Maluski Date: Mon, 1 Jun 2026 16:54:37 -0700 Subject: [PATCH 01/10] Provisional changes to qt/restoredialog.py to prevent user from exiting restoration early and causing problems. --- CHANGELOG.md | 1 + qt/restoredialog.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2f3b2ac..85341f69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ General Public License v2 (GPLv2). See LICENSES directory or go to ## Fixed - Prevent Back In Time crash when a plugin fails ([#2447](https://github.com/bit-team/backintime/issues/2447)) +- Prevent user from exiting the restore window while running causing backintime to freeze. (Dominic Maluski, @maluskid, [#2503](https://github.com/bit-team/backintime/issues/2503)) ## [1.6.1] (2026-02-10) diff --git a/qt/restoredialog.py b/qt/restoredialog.py index 908eda2f7..970a3a09d 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -9,6 +9,7 @@ # General Public License v2 (GPLv2). See LICENSES directory or go to # . """Module offering RestoreDialog""" +import time from pathlib import Path from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import (QDialog, @@ -106,6 +107,23 @@ def exec(self): def _slot_thread_finished(self): self._btn_close.setEnabled(True) + def closeEvent(self, event): + """ + intercept close event to prevent cancelling restoration early + this provides protection against upper corner x as well as + alt-f4 key presses + """ + # Check if close button is enabled to avoid using new variable + # Could add a boolean to __init__ for easier readability + if not self._btn_close.isEnabled(): + messagebox.critical( + self, + _("A critical process is currently running. Window " + "cannot be closed until restoration is finished.") + ) + event.ignore() + else: + event.accept() class RestoreThread(QThread): """ From 7793429a868124fd7c0f58db68cf4b29497c2827 Mon Sep 17 00:00:00 2001 From: Dominic Maluski Date: Mon, 1 Jun 2026 17:09:06 -0700 Subject: [PATCH 02/10] removed unneeded import time used for testing --- qt/restoredialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qt/restoredialog.py b/qt/restoredialog.py index 970a3a09d..4d9344db0 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -9,7 +9,6 @@ # General Public License v2 (GPLv2). See LICENSES directory or go to # . """Module offering RestoreDialog""" -import time from pathlib import Path from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import (QDialog, From 76440926a11747339560319ebe25def6e3114c31 Mon Sep 17 00:00:00 2001 From: Dominic Maluski Date: Mon, 1 Jun 2026 20:29:58 -0700 Subject: [PATCH 03/10] Added flake-8 exception for closeEvent() function name to properly override QT's closeEvent() function. --- qt/restoredialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qt/restoredialog.py b/qt/restoredialog.py index 4d9344db0..4a1fd6cc8 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -106,7 +106,7 @@ def exec(self): def _slot_thread_finished(self): self._btn_close.setEnabled(True) - def closeEvent(self, event): + def closeEvent(self, event): # noqa: N802 """ intercept close event to prevent cancelling restoration early this provides protection against upper corner x as well as @@ -124,6 +124,7 @@ def closeEvent(self, event): else: event.accept() + class RestoreThread(QThread): """ run restore in a separate Thread to prevent GUI freeze and speed up restore From 1071652ed3ca5d17d35f78bda3b8b25ceabb407f Mon Sep 17 00:00:00 2001 From: Dominic Maluski Date: Mon, 1 Jun 2026 20:42:18 -0700 Subject: [PATCH 04/10] Fixed spelling error in comment --- qt/restoredialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/restoredialog.py b/qt/restoredialog.py index 4a1fd6cc8..e4dc65702 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -108,7 +108,7 @@ def _slot_thread_finished(self): def closeEvent(self, event): # noqa: N802 """ - intercept close event to prevent cancelling restoration early + intercept close event to prevent canceling restoration early this provides protection against upper corner x as well as alt-f4 key presses """ From 893805e0c566e06d012d9c50824f076b82350edc Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 15:15:51 +0200 Subject: [PATCH 05/10] src string fix --- common/sshsetupvalidator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/sshsetupvalidator.py b/common/sshsetupvalidator.py index cbf8164ce..4f3824bf1 100644 --- a/common/sshsetupvalidator.py +++ b/common/sshsetupvalidator.py @@ -233,7 +233,7 @@ def _ensure_ssh_agent_running(self): raise SSHSetupError( f'ssh-agent failed: {err}', _( - "Unexpected response from '{process}'" + "Unexpected response from '{process}'." ).format(process='ssh-agent') + '\n\n' + _('Details:') + f'\n{err}' From ec5770af9bf87979514c17575db58a8d701359a2 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 15:27:57 +0200 Subject: [PATCH 06/10] fix udev error --- common/bitbase.py | 2 +- common/config.py | 7 ++++--- qt/restoredialog.py | 43 ++++++++++++++++++++++++------------------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index 59d937c54..700add28d 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -42,7 +42,7 @@ def _(val): # to create an extra AppContext or RuntimeContext class. context = { # '--config': None, - # '--quiet': None, + '--quiet': None, } # |-----------------| diff --git a/common/config.py b/common/config.py index cb50a6ba9..f65eec8a1 100644 --- a/common/config.py +++ b/common/config.py @@ -1873,11 +1873,12 @@ def _setup_event_based_automation(self): else: logger.error( - f"Udev scheduling doesn't work with mode {backup_mode}", + f"Udev scheduling doesn't work with mode '{backup_mode}'", self) + mode_for_human = self.SNAPSHOT_MODES[backup_mode][1] core_events.event_error.notify(_( - "Udev schedule doesn't work with mode {mode}") - .format(mode=backup_mode)) + "Udev schedule doesn't work with mode {mode}.") + .format(mode=mode_for_human)) return # Add rule diff --git a/qt/restoredialog.py b/qt/restoredialog.py index e4dc65702..33a6bc3e6 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -15,7 +15,7 @@ QDialogButtonBox, QPlainTextEdit, QVBoxLayout) -from PyQt6.QtCore import QMutex, QThread, QTimer, QUrl +from PyQt6.QtCore import QMutex, QThread, QTimer, QUrl, Qt from inhibitsuspend import InhibitSuspend import messagebox @@ -45,6 +45,9 @@ def __init__(self, parent, sid, what, where='', **kwargs): self.setWindowIcon(icon.RESTORE_DIALOG) self.setWindowTitle(_('Restore')) + # Remove the closing "X" in the window title bar + self.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, False) + self._main_layout = QVBoxLayout(self) self._txt_log_view = QPlainTextEdit(self) @@ -55,7 +58,9 @@ def __init__(self, parent, sid, what, where='', **kwargs): button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btn_show_log = button_box.addButton( - _('Show full Log'), QDialogButtonBox.ButtonRole.ActionRole) + _('Show full Log'), + QDialogButtonBox.ButtonRole.ActionRole + ) self._main_layout.addWidget(button_box) self._btn_close = button_box.button( QDialogButtonBox.StandardButton.Close) @@ -106,23 +111,23 @@ def exec(self): def _slot_thread_finished(self): self._btn_close.setEnabled(True) - def closeEvent(self, event): # noqa: N802 - """ - intercept close event to prevent canceling restoration early - this provides protection against upper corner x as well as - alt-f4 key presses - """ - # Check if close button is enabled to avoid using new variable - # Could add a boolean to __init__ for easier readability - if not self._btn_close.isEnabled(): - messagebox.critical( - self, - _("A critical process is currently running. Window " - "cannot be closed until restoration is finished.") - ) - event.ignore() - else: - event.accept() + # def closeEvent(self, event): # noqa: N802 + # """ + # intercept close event to prevent canceling restoration early + # this provides protection against upper corner x as well as + # alt-f4 key presses + # """ + # # Check if close button is enabled to avoid using new variable + # # Could add a boolean to __init__ for easier readability + # if not self._btn_close.isEnabled(): + # messagebox.critical( + # self, + # _("A critical process is currently running. Window " + # "cannot be closed until restoration is finished.") + # ) + # event.ignore() + # else: + # event.accept() class RestoreThread(QThread): From fc9196b07a885a436e2cc1c2ee663c8f7f596c50 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 16:52:09 +0200 Subject: [PATCH 07/10] x --- common/progress.py | 5 ++++- common/snapshots.py | 9 +++++---- qt/app.py | 40 +++++++++++++++++++++++++++++++++++----- qt/messagebox.py | 3 ++- qt/restoredialog.py | 37 +++++++++++++++++-------------------- 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/common/progress.py b/common/progress.py index 78b3063e3..f95fa497e 100644 --- a/common/progress.py +++ b/common/progress.py @@ -40,7 +40,10 @@ def save(self): self.filename.write_text(json.dumps(self._data), encoding='utf-8') def load(self): - self._data = json.loads(self.filename.read_text(encoding='utf-8')) + content = self.filename.read_text(encoding='utf-8') + # Ugly workaround. See #2260 + if content: + self._data = json.loads(content) def fileReadable(self): return os.access(self.filename, os.R_OK) diff --git a/common/snapshots.py b/common/snapshots.py index a8ba2bd09..51ff7fe3d 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -957,10 +957,11 @@ def backup(self, force=False, force_checksum_use=False): profile_name = self.config.profileName() user_name = logger.USER - logger.info(f'Backup started (profile: ' - f'{profile_name}({profile_id}), ' - f'user: {user_name}', - self) + logger.info( + 'Backup started ' + f'(profile: {profile_name}({profile_id}), ' + f'user: {user_name})' + ) if not self._can_backup(profile_id): diff --git a/qt/app.py b/qt/app.py index 6484c8881..49f47a733 100644 --- a/qt/app.py +++ b/qt/app.py @@ -1325,6 +1325,10 @@ def _update_progress_bar(self, message: str): pg.load() pg_data = pg.get_data() + # Ugly workaround. See #2260 + if not pg_data: + return + self.status_bar.set_progress_value(pg_data['percent']) message = ' | '.join(self.getProgressBarFormat(pg_data, message)) self.status_bar.set_status_message(message) @@ -2014,9 +2018,12 @@ def _restore_to(self, paths: list[str]): warnRoot='/' in paths, restoreTo=path_restore_to): return + print(f'{paths=}') # DEBUG rd = RestoreDialog(self, self.selected_backup_id(), + # what paths if len(paths) > 1 else paths[0], + # where path_restore_to, **opt) @@ -2027,6 +2034,9 @@ def _slot_restore_this(self): return paths = self.filesView.get_selected_paths() + # Workaround + if not paths: + paths = ['/'] # paths = [f for f, idx in self.multiFileSelected(fullPath = True)] confirm_dlg = ConfirmRestoreDialog( @@ -2060,15 +2070,21 @@ def _slot_restore_this_to(self): # paths = [f for f, _idx in self.multiFileSelected(fullPath=True)] paths = self.filesView.get_selected_paths() + # Workaround + if not paths: + paths = ['/'] + self._restore_to(paths) def _slot_restore_parent(self): if self.is_now_selected(): return + parent_path = self._get_parent_path_of_fileview_selection() + confirm_dlg = ConfirmRestoreDialog( parent=self, - paths=(self.path, ), + paths=(parent_path, ), to_path=None, backup_on_restore=self.config.backupOnRestore(), backup_suffix=self.snapshots.backupSuffix() @@ -2081,18 +2097,32 @@ def _slot_restore_parent(self): opt = confirm_dlg.get_values_as_dict() if opt['delete'] and not self._restore_confirm_delete( - warnRoot=self.path == '/'): + warnRoot=parent_path == '/'): return - rd = RestoreDialog(self, self.selected_backup_id(), self.path, **opt) + rd = RestoreDialog( + self, + self.selected_backup_id(), + parent_path, + **opt + ) rd.exec() def _slot_restore_parent_to(self): - """Restore parent folder (of current selected) to ...""" + """Restore parent directory (of current selected) to ...""" if self.is_now_selected(): return - self._restore_to([self.path]) + self._restore_to( + [self._get_parent_path_of_fileview_selection_or_root()] + ) + + def _get_parent_path_of_fileview_selection_or_root(self) -> str: + path = self.filesView.get_current_path() + path = str(pathlib.Path(path).parent) + print(f'{path=}') # DEBUG + + return path if path else '/' # |------------| # | Files View | diff --git a/qt/messagebox.py b/qt/messagebox.py index 77f738bc8..1c44ffc45 100644 --- a/qt/messagebox.py +++ b/qt/messagebox.py @@ -73,7 +73,8 @@ def info(text, title=None, widget_to_center_on=None): QMessageBox.information( widget_to_center_on, title if title else ngettext('Information', 'Information', 1), - text) + text + ) def warning(text: str, diff --git a/qt/restoredialog.py b/qt/restoredialog.py index 33a6bc3e6..aa42d9ddd 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -18,6 +18,7 @@ from PyQt6.QtCore import QMutex, QThread, QTimer, QUrl, Qt from inhibitsuspend import InhibitSuspend import messagebox +import logger class RestoreDialog(QDialog): @@ -25,6 +26,7 @@ class RestoreDialog(QDialog): # pylint: disable=too-many-instance-attributes def __init__(self, parent, sid, what, where='', **kwargs): + logger.debug(f'{what=} {where=}') super().__init__(parent) self.resize(600, 500) @@ -45,9 +47,6 @@ def __init__(self, parent, sid, what, where='', **kwargs): self.setWindowIcon(icon.RESTORE_DIALOG) self.setWindowTitle(_('Restore')) - # Remove the closing "X" in the window title bar - self.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, False) - self._main_layout = QVBoxLayout(self) self._txt_log_view = QPlainTextEdit(self) @@ -111,23 +110,21 @@ def exec(self): def _slot_thread_finished(self): self._btn_close.setEnabled(True) - # def closeEvent(self, event): # noqa: N802 - # """ - # intercept close event to prevent canceling restoration early - # this provides protection against upper corner x as well as - # alt-f4 key presses - # """ - # # Check if close button is enabled to avoid using new variable - # # Could add a boolean to __init__ for easier readability - # if not self._btn_close.isEnabled(): - # messagebox.critical( - # self, - # _("A critical process is currently running. Window " - # "cannot be closed until restoration is finished.") - # ) - # event.ignore() - # else: - # event.accept() + def closeEvent(self, event): # noqa: N802 + """Intercept close event to prevent canceling restoration. + + It protect against closing-X and Alt-F4. + """ + if self._btn_close.isEnabled(): + event.accept() + return + + messagebox.info(_( + 'The restore is still running. The window cannot be closed ' + 'until it is finished.' + )) + event.ignore() + return class RestoreThread(QThread): From cedae4a687ff53eab2380f8a5f875fb19457ff11 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 16:52:57 +0200 Subject: [PATCH 08/10] x --- qt/restoredialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/restoredialog.py b/qt/restoredialog.py index aa42d9ddd..1bd35b7b6 100644 --- a/qt/restoredialog.py +++ b/qt/restoredialog.py @@ -15,10 +15,10 @@ QDialogButtonBox, QPlainTextEdit, QVBoxLayout) -from PyQt6.QtCore import QMutex, QThread, QTimer, QUrl, Qt +from PyQt6.QtCore import QMutex, QThread, QTimer, QUrl from inhibitsuspend import InhibitSuspend -import messagebox import logger +import messagebox class RestoreDialog(QDialog): From 0ee6fe517a911247c075e8d8008d7c07bec9260f Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 16:55:32 +0200 Subject: [PATCH 09/10] [skip ci] --- qt/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qt/app.py b/qt/app.py index 49f47a733..86b216755 100644 --- a/qt/app.py +++ b/qt/app.py @@ -666,7 +666,7 @@ def _create_actions(self): # Release Candidate ? self.act_help_release_candidate = None - if version.IS_RELEASE_CANDIDATE or logger.DEBUG : + if version.IS_RELEASE_CANDIDATE or logger.DEBUG: # pylint: disable=undefined-variable action = QAction(icon.QUESTION, _('Release Candidate'), self) action.triggered.connect(self._slot_help_release_candidate) @@ -2018,7 +2018,6 @@ def _restore_to(self, paths: list[str]): warnRoot='/' in paths, restoreTo=path_restore_to): return - print(f'{paths=}') # DEBUG rd = RestoreDialog(self, self.selected_backup_id(), # what @@ -2120,7 +2119,6 @@ def _slot_restore_parent_to(self): def _get_parent_path_of_fileview_selection_or_root(self) -> str: path = self.filesView.get_current_path() path = str(pathlib.Path(path).parent) - print(f'{path=}') # DEBUG return path if path else '/' From d55367c65226dc2fdb85d2d8994b7c36f2ab95e3 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 2 Aug 2026 16:58:30 +0200 Subject: [PATCH 10/10] [skip ci] --- qt/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qt/app.py b/qt/app.py index 86b216755..53c810d52 100644 --- a/qt/app.py +++ b/qt/app.py @@ -2117,9 +2117,17 @@ def _slot_restore_parent_to(self): ) def _get_parent_path_of_fileview_selection_or_root(self) -> str: + """Dev note (buhtz, 2026-08): Ugly workaround because of removing + self.path . I never understood the purpose of that allmity + object variable. + + This workaround might cause unusual behavior. But I am on it. See + #2434. + """ path = self.filesView.get_current_path() path = str(pathlib.Path(path).parent) + # use root dir by default return path if path else '/' # |------------|