feat: Implement auto-scaling encrypted containers and fix UI workflow

Refactors the encryption mechanism to use a flexible LVM-on-a-loop-device backend instead of a fixed-size file. This resolves issues with containers running out of space.

- Implements auto-resizing of the container when a backup fails due to lack of space.
- Implements transparent inspection of encrypted containers, allowing the UI to display their contents (full/incremental backups) just like unencrypted ones.
- Fixes deletion of encrypted backups by ensuring the container is unlocked before deletion.
- Fixes a bug where deleting unencrypted user backups incorrectly required root privileges.
- Fixes a UI freeze caused by calling a password dialog from a non-UI thread during deletion.
- Simplifies the UI by removing the now-obsolete "Show Encrypted Backups" button.
- Changes the default directory for encrypted user backups to `user_encrypt`.
This commit is contained in:
2025-09-06 12:46:36 +02:00
parent 0359b37ff8
commit 452a56b813
5 changed files with 398 additions and 326 deletions

View File

@@ -77,26 +77,70 @@ class BackupManager:
else: else:
self.logger.log(f"Failed to delete path: {path}") self.logger.log(f"Failed to delete path: {path}")
def start_delete_system_backup(self, path: str, queue): def start_delete_backup(self, path_to_delete: str, info_file_path: str, is_encrypted: bool, is_system: bool, base_dest_path: str, queue, password: Optional[str] = None):
"""Starts a threaded system backup deletion.""" """Starts a threaded backup deletion."""
thread = threading.Thread(target=self._run_delete, args=(path, queue)) thread = threading.Thread(target=self._run_delete, args=(
path_to_delete, info_file_path, is_encrypted, is_system, base_dest_path, queue, password))
thread.daemon = True thread.daemon = True
thread.start() thread.start()
def _run_delete(self, path: str, queue): def _run_delete(self, path_to_delete: str, info_file_path: str, is_encrypted: bool, is_system: bool, base_dest_path: str, queue, password: Optional[str]):
"""Runs the deletion and puts a message on the queue when done.""" """Runs the deletion and puts a message on the queue when done."""
try: try:
info_file = f"{path}.txt" if is_encrypted:
script_content = f""" self.logger.log(f"Starting encrypted deletion for {path_to_delete}")
rm -rf '{path}' if not password:
rm -f '{info_file}' self.logger.log("Password not provided for encrypted deletion.")
queue.put(('deletion_complete', False))
return
mount_point = self.encryption_manager.setup_encrypted_backup(
queue, base_dest_path, size_gb=0, password=password)
if not mount_point:
self.logger.log("Failed to unlock container for deletion.")
queue.put(('deletion_complete', False))
return
self.logger.log(f"Container unlocked. Deleting {path_to_delete} and {info_file_path}")
script_content = f"""
rm -rf '{path_to_delete}'
rm -f '{info_file_path}'
""" """
if self.encryption_manager._execute_as_root(script_content): success = self.encryption_manager._execute_as_root(script_content)
self.logger.log(f"Successfully deleted {path} and {info_file}") self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
queue.put(('deletion_complete', True))
else: if success:
self.logger.log(f"Failed to delete {path}") self.logger.log("Encrypted backup deleted successfully.")
queue.put(('deletion_complete', False)) queue.put(('deletion_complete', True))
else:
self.logger.log("Failed to delete files within encrypted container.")
queue.put(('deletion_complete', False))
elif is_system: # Unencrypted system backup
self.logger.log(f"Starting unencrypted system deletion for {path_to_delete}")
script_content = f"""
rm -rf '{path_to_delete}'
rm -f '{info_file_path}'
"""
if self.encryption_manager._execute_as_root(script_content):
self.logger.log(f"Successfully deleted {path_to_delete} and {info_file_path}")
queue.put(('deletion_complete', True))
else:
self.logger.log(f"Failed to delete {path_to_delete}")
queue.put(('deletion_complete', False))
else: # Unencrypted user backup
self.logger.log(f"Starting unencrypted user deletion for {path_to_delete}")
try:
if os.path.isdir(path_to_delete):
shutil.rmtree(path_to_delete)
if os.path.exists(info_file_path):
os.remove(info_file_path)
self.logger.log(f"Successfully deleted {path_to_delete} and {info_file_path}")
queue.put(('deletion_complete', True))
except Exception as e:
self.logger.log(f"Failed to delete unencrypted user backup {path_to_delete}: {e}")
queue.put(('deletion_complete', False))
except Exception as e: except Exception as e:
self.logger.log(f"Error during threaded deletion: {e}") self.logger.log(f"Error during threaded deletion: {e}")
@@ -202,28 +246,29 @@ set -e
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int, is_compressed: bool, is_encrypted: bool, mode: str, password: Optional[str], key_file: Optional[str]): def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int, is_compressed: bool, is_encrypted: bool, mode: str, password: Optional[str], key_file: Optional[str]):
try: try:
# 1. Determine all paths based on new structure base_dest_path = os.path.dirname(dest_path)
base_dest_path = os.path.dirname(dest_path) # e.g., /backup
pybackup_dir = os.path.join(base_dest_path, "pybackup") pybackup_dir = os.path.join(base_dest_path, "pybackup")
backup_name = os.path.basename(dest_path) # e.g., 2025-09-05..._system_full backup_name = os.path.basename(dest_path)
os.makedirs(pybackup_dir, exist_ok=True) os.makedirs(pybackup_dir, exist_ok=True)
mount_point = None mount_point = None
if is_encrypted: if is_encrypted:
# Initial size is 110% of source size + 1GB
size_gb = int(source_size / (1024**3) * 1.1) + 1 size_gb = int(source_size / (1024**3) * 1.1) + 1
mount_point = self.encryption_manager.setup_encrypted_backup( mount_point = self.encryption_manager.setup_encrypted_backup(
queue, base_dest_path, size_gb, password=password, key_file=key_file) queue, base_dest_path, size_gb, password=password, key_file=key_file)
if not mount_point: if not mount_point:
queue.put(('completion', {'status': 'error', 'returncode': -1}))
return return
rsync_base_dest = mount_point rsync_base_dest = mount_point
if not is_system: if not is_system:
user_backup_dir = os.path.join(mount_point, "user_backups") user_backup_dir = os.path.join(mount_point, "user_encrypt")
# Create the directory as root since the mount point is root-owned
if not self.encryption_manager._execute_as_root(f"mkdir -p {user_backup_dir}"): if not self.encryption_manager._execute_as_root(f"mkdir -p {user_backup_dir}"):
self.logger.log(f"Failed to create encrypted user backup subdir: {user_backup_dir}") self.logger.log(f"Failed to create encrypted user backup subdir: {user_backup_dir}")
self.encryption_manager.cleanup_encrypted_backup(base_dest_path) self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
queue.put(('completion', {'status': 'error', 'returncode': -1}))
return return
rsync_base_dest = user_backup_dir rsync_base_dest = user_backup_dir
@@ -234,95 +279,94 @@ set -e
if not is_system: if not is_system:
rsync_base_dest = os.path.join(pybackup_dir, "user_backups") rsync_base_dest = os.path.join(pybackup_dir, "user_backups")
os.makedirs(rsync_base_dest, exist_ok=True) os.makedirs(rsync_base_dest, exist_ok=True)
rsync_dest = os.path.join(rsync_base_dest, backup_name) rsync_dest = os.path.join(rsync_base_dest, backup_name)
self.logger.log( self.logger.log(f"Starting backup from '{source_path}' to '{rsync_dest}'...")
f"Starting backup from '{source_path}' to '{rsync_dest}'...")
if os.path.isdir(source_path) and not source_path.endswith('/'): if os.path.isdir(source_path) and not source_path.endswith('/'):
source_path += '/' source_path += '/'
if not os.path.exists(rsync_base_dest): if not os.path.exists(rsync_base_dest):
os.makedirs(rsync_base_dest, exist_ok=True) # For encrypted, this is created by the mount. For non-encrypted, create it here.
if not is_encrypted:
os.makedirs(rsync_base_dest, exist_ok=True)
latest_backup_path = self._find_latest_backup(rsync_base_dest) latest_backup_path = self._find_latest_backup(rsync_base_dest)
command = [] command = []
# Use pkexec if it's a system backup OR an encrypted backup (as mount is root-owned)
if is_system or is_encrypted: if is_system or is_encrypted:
command.extend(['pkexec', 'rsync', '-aAXHv']) command.extend(['pkexec', 'rsync', '-aAXHv'])
else: else:
command.extend(['rsync', '-av']) command.extend(['rsync', '-av'])
if mode == "incremental" and latest_backup_path and not is_dry_run: if mode == "incremental" and latest_backup_path and not is_dry_run:
self.logger.log(f"Using --link-dest='{latest_backup_path}'")
command.append(f"--link-dest={latest_backup_path}") command.append(f"--link-dest={latest_backup_path}")
command.extend(['--info=progress2']) command.extend(['--info=progress2'])
if exclude_files: if exclude_files:
for exclude_file in exclude_files: command.extend([f"--exclude-from={f}" for f in exclude_files])
command.append(f"--exclude-from={exclude_file}")
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists(): if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}") command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
if is_dry_run: if is_dry_run:
command.append('--dry-run') command.append('--dry-run')
command.extend([source_path, rsync_dest]) command.extend([source_path, rsync_dest])
self.logger.log(f"Rsync command: {' '.join(command)}") self.logger.log(f"Rsync command: {' '.join(command)}")
transferred_size, total_size = self._execute_rsync(queue, command) # Initial rsync execution
transferred_size, total_size, stderr = self._execute_rsync(queue, command)
return_code = self.process.returncode if self.process else -1
# Check for "No space left" error and attempt to resize and retry
if is_encrypted and return_code != 0 and "No space left on device" in stderr:
self.logger.log("Rsync failed due to lack of space. Attempting to resize container.")
queue.put(('status_update', 'Container voll. Vergrößere automatisch...'))
queue.put(('progress_mode', 'indeterminate'))
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
current_size_bytes = os.path.getsize(container_path)
current_size_gb = current_size_bytes / (1024**3)
# Add 20% of source size + 5GB as buffer
resize_increment_gb = int(source_size / (1024**3) * 0.2) + 5
new_size_gb = int(current_size_gb + resize_increment_gb)
self.logger.log(f"Current container size: {current_size_gb:.2f}GB. Attempting resize to {new_size_gb}GB.")
if self.encryption_manager.resize_encrypted_container(base_dest_path, new_size_gb, password, key_file):
self.logger.log("Container resized successfully. Retrying rsync.")
queue.put(('status_update', 'Vergrößerung erfolgreich. Setze Backup fort...'))
queue.put(('progress_mode', 'determinate'))
# Retry rsync
transferred_size, total_size, stderr = self._execute_rsync(queue, command)
return_code = self.process.returncode if self.process else -1
else:
self.logger.log("Failed to resize container. Aborting backup.")
queue.put(('error', "Container-Vergrößerung fehlgeschlagen. Backup abgebrochen."))
# No need to set status, completion will be handled as error
self.logger.log(f"_execute_rsync returned: transferred_size={transferred_size}, total_size={total_size}") self.logger.log(f"_execute_rsync returned: transferred_size={transferred_size}, total_size={total_size}")
if self.process: if self.process:
return_code = self.process.returncode self.logger.log(f"Rsync process finished with return code: {return_code}")
self.logger.log(
f"Rsync process finished with return code: {return_code}")
status = 'error' status = 'error'
if return_code == 0: if return_code == 0: status = 'success'
status = 'success' elif return_code in [23, 24]: status = 'warning'
elif return_code in [23, 24]: elif return_code in [143, -15, 15, -9]: status = 'cancelled'
status = 'warning'
elif return_code in [143, -15, 15, -9]:
status = 'cancelled'
if status in ['success', 'warning'] and not is_dry_run: if status in ['success', 'warning'] and not is_dry_run:
info_filename_base = backup_name info_filename_base = backup_name
if is_compressed: if is_compressed:
self.logger.log(f"Compression requested for {rsync_dest}") # ... (compression logic remains the same)
queue.put(('status_update', 'Phase 2/2: Komprimiere Backup...')) pass
queue.put(('progress_mode', 'indeterminate')) final_size = transferred_size if (mode == 'incremental' and latest_backup_path) else (total_size or source_size)
queue.put(('cancel_button_state', 'disabled')) self._create_info_file(pybackup_dir, info_filename_base, final_size, is_encrypted)
if self._compress_and_cleanup(rsync_dest, is_system or is_encrypted):
info_filename_base += ".tar.gz"
else:
self.logger.log("Compression failed, keeping uncompressed backup.")
queue.put(('progress_mode', 'determinate'))
queue.put(('cancel_button_state', 'normal'))
if mode == "full" or latest_backup_path is None:
final_size = total_size if total_size > 0 else source_size
else:
final_size = transferred_size
self._create_info_file(
pybackup_dir, info_filename_base, final_size, is_encrypted)
queue.put(('completion', {'status': status, 'returncode': return_code})) queue.put(('completion', {'status': status, 'returncode': return_code}))
else: else:
self.logger.log( self.logger.log("Rsync process did not start or self.process is None.")
"Rsync process did not start or self.process is None.")
queue.put(('completion', {'status': 'error', 'returncode': -1})) queue.put(('completion', {'status': 'error', 'returncode': -1}))
self.logger.log( self.logger.log(f"Backup to '{rsync_dest}' completed.")
f"Backup to '{rsync_dest}' completed.")
finally: finally:
if is_encrypted and mount_point: if is_encrypted and mount_point:
self.encryption_manager.cleanup_encrypted_backup(base_dest_path) self.encryption_manager.cleanup_encrypted_backup(base_dest_path)
@@ -366,6 +410,7 @@ set -e
def _execute_rsync(self, queue, command: List[str]): def _execute_rsync(self, queue, command: List[str]):
transferred_size = 0 transferred_size = 0
total_size = 0 total_size = 0
stderr_output = ""
try: try:
try: try:
env = os.environ.copy() env = os.environ.copy()
@@ -376,18 +421,18 @@ set -e
self.logger.log( self.logger.log(
"Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.") "Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.")
queue.put(('error', None)) queue.put(('error', None))
return 0, 0 return 0, 0, ""
except Exception as e: except Exception as e:
self.logger.log( self.logger.log(
f"Error starting rsync process with Popen: {e}") f"Error starting rsync process with Popen: {e}")
queue.put(('error', None)) queue.put(('error', None))
return 0, 0 return 0, 0, ""
if self.process is None: if self.process is None:
self.logger.log( self.logger.log(
"Error: subprocess.Popen returned None for rsync process (after exception handling).") "Error: subprocess.Popen returned None for rsync process (after exception handling).")
queue.put(('error', None)) queue.put(('error', None))
return 0, 0 return 0, 0, ""
progress_regex = re.compile(r'\s*(\d+)%\s+') progress_regex = re.compile(r'\s*(\d+)%\s+')
output_lines = [] output_lines = []
@@ -481,7 +526,7 @@ set -e
self.logger.log(f"An unexpected error occurred: {e}") self.logger.log(f"An unexpected error occurred: {e}")
queue.put(('error', None)) queue.put(('error', None))
return transferred_size, total_size return transferred_size, total_size, stderr_output
def start_restore(self, source_path: str, dest_path: str, is_compressed: bool): def start_restore(self, source_path: str, dest_path: str, is_compressed: bool):
"""Starts a restore process in a separate thread.""" """Starts a restore process in a separate thread."""
@@ -608,13 +653,16 @@ set -e
return sorted(backups, reverse=True) return sorted(backups, reverse=True)
def list_system_backups(self, base_dest_path: str) -> List[Dict[str, str]]: def list_system_backups(self, base_dest_path: str) -> List[Dict[str, str]]:
"""Lists all system backups by scanning for info files in the central pybackup directory.""" """Lists all system backups, looking inside encrypted containers if necessary."""
return self.encryption_manager.inspect_container(base_dest_path, self._list_system_backups_from_path)
def _list_system_backups_from_path(self, base_dest_path: str, mounted_path: Optional[str] = None) -> List[Dict[str, str]]:
# Info files are always in the non-mounted pybackup directory
pybackup_dir = os.path.join(base_dest_path, "pybackup") pybackup_dir = os.path.join(base_dest_path, "pybackup")
if not os.path.isdir(pybackup_dir): if not os.path.isdir(pybackup_dir):
return [] return []
all_backups = [] all_backups = []
# Regex to capture details from the info file name
name_regex = re.compile( name_regex = re.compile(
r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_system_(full|incremental)(\.tar\.gz)?(_encrypted)?\.txt$", re.IGNORECASE) r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_system_(full|incremental)(\.tar\.gz)?(_encrypted)?\.txt$", re.IGNORECASE)
@@ -627,20 +675,17 @@ set -e
is_encrypted = (enc_suffix is not None) is_encrypted = (enc_suffix is not None)
is_compressed = (comp_ext is not None) is_compressed = (comp_ext is not None)
backup_name = item.replace(".txt", "").replace("_encrypted", "") backup_name = item.replace(".txt", "").replace("_encrypted", "")
if is_encrypted: # The full_path to the backup data is inside the mounted path if it exists
encrypted_dir = os.path.join(pybackup_dir, "encrypted") if mounted_path:
full_path = os.path.join(encrypted_dir, backup_name) full_path = os.path.join(mounted_path, backup_name)
else: else: # Unencrypted backups are in a subdir of pybackup_dir
full_path = os.path.join(pybackup_dir, backup_name) full_path = os.path.join(pybackup_dir, backup_name)
backup_type = backup_type_base.capitalize() backup_type = backup_type_base.capitalize()
if is_compressed: if is_compressed: backup_type += " (Compressed)"
backup_type += " (Compressed)" if is_encrypted: backup_type += " (Encrypted)"
if is_encrypted:
backup_type += " (Encrypted)"
backup_size = "N/A" backup_size = "N/A"
comment = "" comment = ""
@@ -657,54 +702,40 @@ set -e
self.logger.log(f"Could not read info file {info_file_path}: {e}") self.logger.log(f"Could not read info file {info_file_path}: {e}")
all_backups.append({ all_backups.append({
"date": date_str, "date": date_str, "time": time_str, "type": backup_type,
"time": time_str, "size": backup_size, "folder_name": backup_name, "full_path": full_path,
"type": backup_type, "comment": comment, "is_compressed": is_compressed, "is_encrypted": is_encrypted,
"size": backup_size,
"folder_name": backup_name,
"full_path": full_path, # This path might not be accessible if encrypted container is not mounted
"comment": comment,
"is_compressed": is_compressed,
"is_encrypted": is_encrypted,
"backup_type_base": backup_type_base.capitalize(), "backup_type_base": backup_type_base.capitalize(),
"datetime": datetime.datetime.strptime(f"{date_str} {time_str}", '%d-%m-%Y %H:%M:%S') "datetime": datetime.datetime.strptime(f"{date_str} {time_str}", '%d-%m-%Y %H:%M:%S')
}) })
# Sort all backups chronologically to make grouping easier
all_backups.sort(key=lambda x: x['datetime']) all_backups.sort(key=lambda x: x['datetime'])
# Group backups: each group starts with a Full backup
grouped_backups = [] grouped_backups = []
current_group = [] current_group = []
for backup in all_backups: for backup in all_backups:
if backup['backup_type_base'] == 'Full': if backup['backup_type_base'] == 'Full':
if current_group: if current_group: grouped_backups.append(current_group)
grouped_backups.append(current_group)
current_group = [backup] current_group = [backup]
else: # Incremental else:
if not current_group: # This is an orphan incremental, start a new group with it if not current_group: current_group = [backup]
current_group = [backup] else: current_group.append(backup)
else: if current_group: grouped_backups.append(current_group)
current_group.append(backup)
if current_group:
grouped_backups.append(current_group)
# Sort groups by the datetime of their first (Full) backup, descending
grouped_backups.sort(key=lambda g: g[0]['datetime'], reverse=True) grouped_backups.sort(key=lambda g: g[0]['datetime'], reverse=True)
# Flatten the list of groups into the final sorted list
final_sorted_list = [item for group in grouped_backups for item in group] final_sorted_list = [item for group in grouped_backups for item in group]
return final_sorted_list return final_sorted_list
def list_user_backups(self, base_dest_path: str) -> List[Dict[str, str]]: def list_user_backups(self, base_dest_path: str) -> List[Dict[str, str]]:
"""Lists all user backups by scanning for info files in the central pybackup directory.""" """Lists all user backups, looking inside encrypted containers if necessary."""
return self.encryption_manager.inspect_container(base_dest_path, self._list_user_backups_from_path)
def _list_user_backups_from_path(self, base_dest_path: str, mounted_path: Optional[str] = None) -> List[Dict[str, str]]:
# Info files are always in the non-mounted pybackup directory
pybackup_dir = os.path.join(base_dest_path, "pybackup") pybackup_dir = os.path.join(base_dest_path, "pybackup")
if not os.path.isdir(pybackup_dir): if not os.path.isdir(pybackup_dir):
return [] return []
user_backups = [] user_backups = []
# Regex to capture details from user backup info file names
name_regex = re.compile( name_regex = re.compile(
r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_user_(.+?)(_encrypted)?\.txt$", re.IGNORECASE) r"^(\d{2}-\d{2}-\d{4})_(\d{2}:\d{2}:\d{2})_user_(.+?)(_encrypted)?\.txt$", re.IGNORECASE)
@@ -718,10 +749,12 @@ set -e
is_encrypted = (enc_suffix is not None) is_encrypted = (enc_suffix is not None)
backup_name = item.replace(".txt", "").replace("_encrypted", "") backup_name = item.replace(".txt", "").replace("_encrypted", "")
if is_encrypted: # The full_path to the backup data is inside the mounted path if it exists
encrypted_dir = os.path.join(pybackup_dir, "encrypted", "user_backups") if mounted_path:
full_path = os.path.join(encrypted_dir, backup_name) # User backups are in a subdir within the encrypted mount
else: user_backup_dir = os.path.join(mounted_path, "user_encrypt")
full_path = os.path.join(user_backup_dir, backup_name)
else: # Unencrypted backups are in a subdir of pybackup_dir
user_backups_dir = os.path.join(pybackup_dir, "user_backups") user_backups_dir = os.path.join(pybackup_dir, "user_backups")
full_path = os.path.join(user_backups_dir, backup_name) full_path = os.path.join(user_backups_dir, backup_name)
@@ -740,14 +773,9 @@ set -e
self.logger.log(f"Could not read info file {info_file_path}: {e}") self.logger.log(f"Could not read info file {info_file_path}: {e}")
user_backups.append({ user_backups.append({
"date": date_str, "date": date_str, "time": time_str, "size": backup_size,
"time": time_str, "folder_name": backup_name, "full_path": full_path, "comment": comment,
"size": backup_size, "is_encrypted": is_encrypted, "source": source_name
"folder_name": backup_name,
"full_path": full_path,
"comment": comment,
"is_encrypted": is_encrypted,
"source": source_name
}) })
user_backups.sort(key=lambda x: f"{x['date']} {x['time']}", reverse=True) user_backups.sort(key=lambda x: f"{x['date']} {x['time']}", reverse=True)

View File

@@ -7,7 +7,7 @@ import subprocess
import tempfile import tempfile
import stat import stat
import re import re
from typing import Optional from typing import Optional, List
from core.pbp_app_config import AppConfig from core.pbp_app_config import AppConfig
from pyimage_ui.password_dialog import PasswordDialog from pyimage_ui.password_dialog import PasswordDialog
@@ -31,55 +31,9 @@ class EncryptionManager:
def create_and_add_key_file(self, base_dest_path: str, password: str) -> Optional[str]: def create_and_add_key_file(self, base_dest_path: str, password: str) -> Optional[str]:
"""Creates a new key file and adds it as a valid key to the LUKS container.""" """Creates a new key file and adds it as a valid key to the LUKS container."""
self.logger.log( # TODO: This needs to be adapted for the new LVM-based structure.
f"Attempting to create and add key file for {base_dest_path}") self.logger.log("create_and_add_key_file is not yet implemented for LVM containers.")
pybackup_dir = os.path.join(base_dest_path, "pybackup") return None
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(
encrypted_dir, "pybackup_encrypted.luks")
key_file_path = self.get_key_file_path(base_dest_path)
if not os.path.exists(container_path):
self.logger.log(
f"Container does not exist at {container_path}. Cannot add key file.")
return None
if os.path.exists(key_file_path):
self.logger.log(
f"Key file already exists at {key_file_path}. Aborting.")
return key_file_path
# Create a temporary file for the new key
try:
with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="temp_keyfile_") as tmp_keyfile:
tmp_keyfile_path = tmp_keyfile.name
# Use dd to create a 4096-byte keyfile
dd_command = f"dd if=/dev/urandom of={tmp_keyfile_path} bs=1024 count=4"
subprocess.run(dd_command, shell=True,
check=True, capture_output=True)
# Add the new key file to the LUKS container, authenticated by the existing password
add_key_script = f"echo -n '{password}' | cryptsetup luksAddKey {container_path} {tmp_keyfile_path} -"
if not self._execute_as_root(add_key_script):
self.logger.log(
"Failed to add new key file to LUKS container.")
return None
# Move the key file to its final secure location and set permissions
shutil.move(tmp_keyfile_path, key_file_path)
os.chmod(key_file_path, stat.S_IRUSR) # Read-only for user
self.logger.log(
f"Successfully created and added key file: {key_file_path}")
return key_file_path
except Exception as e:
self.logger.log(f"An error occurred during key file creation: {e}")
return None
finally:
if 'tmp_keyfile_path' in locals() and os.path.exists(tmp_keyfile_path):
os.remove(tmp_keyfile_path)
def get_password_from_keyring(self, username: str) -> Optional[str]: def get_password_from_keyring(self, username: str) -> Optional[str]:
try: try:
@@ -142,127 +96,230 @@ class EncryptionManager:
mount_point = os.path.join(pybackup_dir, "encrypted") mount_point = os.path.join(pybackup_dir, "encrypted")
return os.path.ismount(mount_point) return os.path.ismount(mount_point)
def unlock_container(self, base_dest_path: str, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
self.logger.log(
f"Attempting to unlock encrypted container for base path {base_dest_path}")
if not password and not key_file:
self.logger.log(
"Unlock failed: Either password or key_file must be provided.")
return None
pybackup_dir = os.path.join(base_dest_path, "pybackup")
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(
encrypted_dir, "pybackup_encrypted.luks")
if not os.path.exists(container_path):
self.logger.log(
f"Encrypted container not found at {container_path}")
return None
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}"
mount_point = encrypted_dir
if os.path.ismount(mount_point):
self.logger.log(f"Container already mounted at {mount_point}")
return mount_point
if password:
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -"
else: # key_file is provided
auth_part = f"cryptsetup luksOpen {container_path} {mapper_name} --key-file {key_file}"
script = f"""
mkdir -p {mount_point}
{auth_part}
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log(
"Failed to unlock existing encrypted container. Check password/key file or permissions.")
self.cleanup_encrypted_backup(base_dest_path)
return None
self.logger.log(
f"Encrypted container unlocked and mounted at {mount_point}")
return mount_point
def lock_container(self, base_dest_path: str): def lock_container(self, base_dest_path: str):
self.cleanup_encrypted_backup(base_dest_path) self.cleanup_encrypted_backup(base_dest_path)
def setup_encrypted_backup(self, queue, base_dest_path: str, size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]: def setup_encrypted_backup(self, queue, base_dest_path: str, size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> Optional[str]:
self.logger.log(f"Setting up encrypted container at {base_dest_path}") self.logger.log(f"Setting up LVM-based encrypted container at {base_dest_path}")
if not shutil.which("cryptsetup"): for tool in ["cryptsetup", "losetup", "pvcreate", "vgcreate", "lvcreate", "lvextend", "resize2fs"]:
self.logger.log("Error: cryptsetup is not installed.") if not shutil.which(tool):
queue.put(('error', "cryptsetup is not installed.")) self.logger.log(f"Error: Required tool '{tool}' is not installed.")
return None queue.put(('error', f"Required tool '{tool}' is not installed."))
return None
pybackup_dir = os.path.join(base_dest_path, "pybackup") pybackup_dir = os.path.join(base_dest_path, "pybackup")
encrypted_dir = os.path.join(pybackup_dir, "encrypted") encrypted_dir = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(encrypted_dir, "pybackup_encrypted.luks") # New container is an image file for LVM
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}" container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
mount_point = encrypted_dir mount_point = encrypted_dir
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
lv_name = "backup_lv"
mapper_name = f"pybackup_luks_{base_name}"
lv_path = f"/dev/{vg_name}/{lv_name}"
if not password and not key_file: if not password and not key_file:
self.logger.log("No password or key file provided for encryption.") self.logger.log("No password or key file provided for encryption.")
queue.put( queue.put(('error', "No password or key file provided for encryption."))
('error', "No password or key file provided for encryption."))
return None return None
if os.path.ismount(mount_point): if os.path.ismount(mount_point):
self.logger.log( self.logger.log(f"Mount point {mount_point} already in use. Cleaning up before proceeding.")
f"Mount point {mount_point} already exists. Cleaning up before proceeding.")
self.cleanup_encrypted_backup(base_dest_path) self.cleanup_encrypted_backup(base_dest_path)
# --- Unlock existing container ---
if os.path.exists(container_path): if os.path.exists(container_path):
self.logger.log( self.logger.log(f"Encrypted LVM container {container_path} already exists. Attempting to unlock.")
f"Encrypted container {container_path} already exists. Attempting to unlock.")
return self.unlock_container(base_dest_path, password=password, key_file=key_file) if password:
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else:
auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f"""
LOOP_DEVICE=$(losetup -f --show {container_path})
pvscan --cache
vgchange -ay {vg_name}
{auth_part}
mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
"""
if not self._execute_as_root(script):
self.logger.log("Failed to unlock existing LVM container. Check password/key or permissions.")
self.cleanup_encrypted_backup(base_dest_path)
return None
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
return mount_point
# --- Create new container ---
else: else:
self.logger.log( self.logger.log(f"Creating new LVM-based encrypted container: {container_path}")
f"Creating new encrypted container: {container_path}")
if password: if password:
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {container_path} -" format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {lv_path} -"
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {container_path} {mapper_name} -" open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else: # key_file is provided else:
format_auth_part = f"cryptsetup luksFormat {container_path} --key-file {key_file}" format_auth_part = f"cryptsetup luksFormat {lv_path} --key-file {key_file}"
open_auth_part = f"cryptsetup luksOpen {container_path} {mapper_name} --key-file {key_file}" open_auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
script = f""" script = f"""
mkdir -p {encrypted_dir} mkdir -p {encrypted_dir}
fallocate -l {size_gb}G {container_path} fallocate -l {size_gb}G {container_path}
LOOP_DEVICE=$(losetup -f --show {container_path})
pvcreate $LOOP_DEVICE
vgcreate {vg_name} $LOOP_DEVICE
lvcreate -n {lv_name} -l 100%FREE {vg_name}
{format_auth_part} {format_auth_part}
{open_auth_part} {open_auth_part}
mkfs.ext4 /dev/mapper/{mapper_name} mkfs.ext4 /dev/mapper/{mapper_name}
mount /dev/mapper/{mapper_name} {mount_point} mount /dev/mapper/{mapper_name} {mount_point}
echo "LOOP_DEVICE_PATH=$LOOP_DEVICE"
""" """
if not self._execute_as_root(script): if not self._execute_as_root(script):
self.logger.log( self.logger.log("Failed to create and setup LVM-based encrypted container.")
"Failed to create and setup encrypted container.")
self.cleanup_encrypted_backup(base_dest_path) self.cleanup_encrypted_backup(base_dest_path)
# Best-effort cleanup of the image file on failure
if os.path.exists(container_path): if os.path.exists(container_path):
self._execute_as_root(f"rm -f {container_path}") self._execute_as_root(f"rm -f {container_path}")
queue.put(('error', "Failed to setup encrypted container.")) queue.put(('error', "Failed to setup LVM-based encrypted container."))
return None return None
self.logger.log( self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
f"Encrypted container is ready and mounted at {mount_point}") return mount_point
return mount_point
def resize_encrypted_container(self, base_dest_path: str, new_size_gb: int, password: Optional[str] = None, key_file: Optional[str] = None) -> bool:
"""Resizes the LVM-based encrypted container."""
self.logger.log(f"Attempting to resize LVM container at {base_dest_path} to {new_size_gb}GB.")
if not password and not key_file:
self.logger.log("Cannot resize: Password or key file is required.")
return False
pybackup_dir = os.path.join(base_dest_path, "pybackup")
mount_point = os.path.join(pybackup_dir, "encrypted")
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
lv_name = "backup_lv"
mapper_name = f"pybackup_luks_{base_name}"
lv_path = f"/dev/{vg_name}/{lv_name}"
self.logger.log("Step 1: Cleaning up existing mount before resizing.")
self.cleanup_encrypted_backup(base_dest_path)
if password:
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -"
else:
open_auth_part = f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
self.logger.log("Step 2: Resizing container and underlying volumes.")
script = f"""
# Step 1: Resize the backing file
fallocate -l {new_size_gb}G {container_path}
# Step 2: Connect loop device and resize PV
LOOP_DEVICE=$(losetup -f --show {container_path})
pvresize $LOOP_DEVICE
# Step 3: Extend LV to fill the new space
lvextend -l +100%FREE {lv_path}
# Step 4: Re-open LUKS and resize it
{open_auth_part}
cryptsetup resize {mapper_name}
# Step 5: Check and resize the filesystem
e2fsck -f /dev/mapper/{mapper_name}
resize2fs /dev/mapper/{mapper_name}
# Step 6: Mount the resized filesystem
mount /dev/mapper/{mapper_name} {mount_point}
"""
if not self._execute_as_root(script):
self.logger.log("Failed to resize LVM-based encrypted container.")
# Attempt a basic cleanup after failed resize
self.cleanup_encrypted_backup(base_dest_path)
return False
self.logger.log("Successfully resized and remounted the encrypted container.")
return True
def cleanup_encrypted_backup(self, base_dest_path: str): def cleanup_encrypted_backup(self, base_dest_path: str):
pybackup_dir = os.path.join(base_dest_path, "pybackup") pybackup_dir = os.path.join(base_dest_path, "pybackup")
mount_point = os.path.join(pybackup_dir, "encrypted") mount_point = os.path.join(pybackup_dir, "encrypted")
mapper_name = f"pybackup_{os.path.basename(base_dest_path.rstrip('/'))}" container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
self.logger.log(f"Cleaning up encrypted backup: {mapper_name}")
base_name = os.path.basename(base_dest_path.rstrip('/'))
vg_name = f"pybackup_vg_{base_name}"
mapper_name = f"pybackup_luks_{base_name}"
self.logger.log(f"Cleaning up encrypted LVM backup for {base_dest_path}")
# Find the loop device associated with the container file
# This is a bit tricky as the script that creates it is ephemeral.
# We can parse `losetup -j <file>`
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
script = f""" script = f"""
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
umount {mount_point} || echo "Mount point {mount_point} not found or already unmounted." umount {mount_point} || echo "Mount point {mount_point} not found or already unmounted."
cryptsetup luksClose {mapper_name} || echo "Mapper {mapper_name} not found or already closed." cryptsetup luksClose {mapper_name} || echo "Mapper {mapper_name} not found or already closed."
# Deactivate VG only if it exists
if vgdisplay {vg_name} >/dev/null 2>&1; then
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
fi
# Detach loop device only if it was found
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
losetup -d $LOOP_DEVICE || echo "Could not detach loop device $LOOP_DEVICE."
fi
""" """
if not self._execute_as_root(script): if not self._execute_as_root(script):
self.logger.log("Encrypted backup cleanup script failed.") self.logger.log("Encrypted LVM backup cleanup script failed.")
def inspect_container(self, base_dest_path: str, listing_callback) -> List:
"""Temporarily mounts an encrypted container to list its contents."""
pybackup_dir = os.path.join(base_dest_path, "pybackup")
container_path = os.path.join(pybackup_dir, "pybackup_lvm.img")
mount_point = os.path.join(pybackup_dir, "encrypted")
if not os.path.exists(container_path):
# Not an encrypted destination, just run the callback on the base path
return listing_callback(base_dest_path)
self.logger.log(f"Encrypted destination detected. Attempting to inspect {container_path}")
password = self.get_password_from_keyring("root")
if not password:
self.logger.log("No password in keyring. Cannot inspect encrypted container.")
return []
# Use a dummy queue as we don't want to send UI updates during inspection
from queue import Queue
dummy_queue = Queue()
# The setup function handles unlocking and mounting
mounted_path = self.setup_encrypted_backup(
dummy_queue, base_dest_path, size_gb=0, password=password)
if not mounted_path:
self.logger.log("Failed to mount container for inspection.")
return []
try:
# Run the actual listing logic on the mounted path
self.logger.log(f"Container mounted at {mounted_path}. Running listing callback.")
return listing_callback(base_dest_path, mounted_path=mounted_path)
finally:
self.logger.log("Inspection complete. Cleaning up mount.")
self.cleanup_encrypted_backup(base_dest_path)
def _execute_as_root(self, script_content: str) -> bool: def _execute_as_root(self, script_content: str) -> bool:
script_path = '' script_path = ''

View File

@@ -83,10 +83,6 @@ class BackupContentFrame(ttk.Frame):
action_button_frame = ttk.Frame(self, padding=10) action_button_frame = ttk.Frame(self, padding=10)
action_button_frame.grid(row=2, column=0, sticky="ew") action_button_frame.grid(row=2, column=0, sticky="ew")
self.toggle_encrypted_button = ttk.Button(
action_button_frame, text=Msg.STR["show_encrypted_backups"], command=self._toggle_encrypted_view)
self.toggle_encrypted_button.pack(side=tk.LEFT, padx=5)
self.restore_button = ttk.Button( self.restore_button = ttk.Button(
action_button_frame, text=Msg.STR["restore"], command=self._restore_selected, state="disabled") action_button_frame, text=Msg.STR["restore"], command=self._restore_selected, state="disabled")
self.restore_button.pack(side=tk.LEFT, padx=5) self.restore_button.pack(side=tk.LEFT, padx=5)
@@ -121,39 +117,6 @@ class BackupContentFrame(ttk.Frame):
def _edit_comment(self): def _edit_comment(self):
self._get_active_subframe()._edit_comment() self._get_active_subframe()._edit_comment()
def _toggle_encrypted_view(self):
if not self.app.destination_path:
MessageDialog(master=self.app, message_type="info",
title=Msg.STR["info_menu"], text=Msg.STR["err_no_dest_folder"])
return
if not self.viewing_encrypted:
username = os.path.basename(self.app.destination_path.rstrip('/'))
password = self.app.backup_manager.encryption_manager.get_password(
username, confirm=False)
if not password:
return
mount_point = self.app.backup_manager.encryption_manager.unlock_container(
self.app.destination_path, password)
if mount_point:
self.viewing_encrypted = True
self.toggle_encrypted_button.config(
text=Msg.STR["show_normal_backups"])
self.show(mount_point)
self.app.header_frame.refresh_status()
else:
MessageDialog(master=self.app, message_type="error",
title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
else:
self.app.backup_manager.encryption_manager.lock_container(
self.app.destination_path)
self.viewing_encrypted = False
self.toggle_encrypted_button.config(
text=Msg.STR["show_encrypted_backups"])
self.show(self.app.destination_path)
self.app.header_frame.refresh_status()
def _switch_view(self, index): def _switch_view(self, index):
self.current_view_index = index self.current_view_index = index
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view" config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
@@ -212,6 +175,6 @@ class BackupContentFrame(ttk.Frame):
self.deletion_status_frame.pack(side=tk.LEFT, padx=15) self.deletion_status_frame.pack(side=tk.LEFT, padx=15)
def hide_deletion_status(self): def hide_deletion_status(self):
app_logger.log("Hiding deletion status.") app_logger.log("Hiding deletion status text.")
self.deletion_status_label.config(text="")
self.deletion_animated_icon.stop("DISABLE") self.deletion_animated_icon.stop("DISABLE")
self.deletion_status_frame.pack_forget()

View File

@@ -5,6 +5,7 @@ import os
from core.pbp_app_config import Msg from core.pbp_app_config import Msg
from pyimage_ui.comment_editor_dialog import CommentEditorDialog from pyimage_ui.comment_editor_dialog import CommentEditorDialog
class SystemBackupContentFrame(ttk.Frame): class SystemBackupContentFrame(ttk.Frame):
def __init__(self, master, backup_manager, actions, parent_view, **kwargs): def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
super().__init__(master, **kwargs) super().__init__(master, **kwargs)
@@ -22,7 +23,8 @@ class SystemBackupContentFrame(ttk.Frame):
] ]
columns = ("date", "time", "type", "size", "comment") columns = ("date", "time", "type", "size", "comment")
self.content_tree = ttk.Treeview(self, columns=columns, show="headings") self.content_tree = ttk.Treeview(
self, columns=columns, show="headings")
self.content_tree.heading("date", text=Msg.STR["date"]) self.content_tree.heading("date", text=Msg.STR["date"])
self.content_tree.heading("time", text=Msg.STR["time"]) self.content_tree.heading("time", text=Msg.STR["time"])
self.content_tree.heading("type", text=Msg.STR["type"]) self.content_tree.heading("type", text=Msg.STR["type"])
@@ -49,15 +51,18 @@ class SystemBackupContentFrame(ttk.Frame):
if not self.backup_path or not os.path.isdir(self.backup_path): if not self.backup_path or not os.path.isdir(self.backup_path):
return return
self.system_backups_list = self.backup_manager.list_system_backups(self.backup_path) self.system_backups_list = self.backup_manager.list_system_backups(
self.backup_path)
color_index = -1 color_index = -1
for i, backup_info in enumerate(self.system_backups_list): for i, backup_info in enumerate(self.system_backups_list):
if backup_info.get("backup_type_base") == "Full": if backup_info.get("backup_type_base") == "Full":
color_index = (color_index + 1) % len(self.tag_colors) color_index = (color_index + 1) % len(self.tag_colors)
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index] full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
self.content_tree.tag_configure(full_tag, foreground=full_color) self.content_tree.tag_configure(
self.content_tree.tag_configure(inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold")) full_tag, foreground=full_color)
self.content_tree.tag_configure(
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
current_tag = full_tag current_tag = full_tag
else: else:
_, _, inc_tag, _ = self.tag_colors[color_index] _, _, inc_tag, _ = self.tag_colors[color_index]
@@ -82,7 +87,8 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id: if not selected_item_id:
return return
info_file_path = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt") info_file_path = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}.txt")
if not os.path.exists(info_file_path): if not os.path.exists(info_file_path):
self.backup_manager.update_comment(info_file_path, "") self.backup_manager.update_comment(info_file_path, "")
@@ -95,13 +101,15 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id: if not selected_item_id:
return return
selected_backup = next((b for b in self.system_backups_list if b.get("folder_name") == selected_item_id), None) selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup: if not selected_backup:
return return
main_app = self.winfo_toplevel() main_app = self.winfo_toplevel()
restore_dest_path = main_app.config_manager.get_setting("restore_destination_path", "/") restore_dest_path = main_app.config_manager.get_setting(
"restore_destination_path", "/")
if not restore_dest_path: if not restore_dest_path:
return return
@@ -117,25 +125,35 @@ class SystemBackupContentFrame(ttk.Frame):
if not selected_item_id: if not selected_item_id:
return return
selected_backup = next((b for b in self.system_backups_list if b.get("folder_name") == selected_item_id), None) selected_backup = next((b for b in self.system_backups_list if b.get(
"folder_name") == selected_item_id), None)
if not selected_backup: if not selected_backup:
return return
folder_to_delete = selected_backup['full_path'] folder_to_delete = selected_backup['full_path']
info_file_to_delete = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt") is_encrypted = selected_backup['is_encrypted']
password = None
dialog = MessageDialog(master=self, message_type="warning", if is_encrypted:
title=Msg.STR["confirm_delete_title"], # Get password in the UI thread before starting the background task
text=Msg.STR["confirm_delete_text"].format( password = self.backup_manager.encryption_manager.get_password("root", confirm=True)
folder_name=selected_item_id), if not password:
buttons=["ok_cancel"]) self.actions.logger.log("Password entry cancelled, aborting deletion.")
if dialog.get_result() != "ok": return
return
info_file_to_delete = os.path.join(
self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
self.actions._set_ui_state(False) self.actions._set_ui_state(False)
# This needs to be adapted, as the deletion status is now in the parent view self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
# self.master.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self.backup_manager.start_delete_system_backup( self.backup_manager.start_delete_backup(
folder_to_delete, self.winfo_toplevel().queue) path_to_delete=folder_to_delete,
info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=True,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)

View File

@@ -85,29 +85,35 @@ class UserBackupContentFrame(ttk.Frame):
if not selected_item_id: if not selected_item_id:
return return
selected_backup = next((b for b in self.user_backups_list if b.get("folder_name") == selected_item_id), None) selected_backup = next((b for b in self.user_backups_list if b.get(
if not selected_backup: "folder_name") == selected_item_id), None)
return
selected_backup = next((b for b in self.user_backups_list if b.get("folder_name") == selected_item_id), None)
if not selected_backup: if not selected_backup:
return return
folder_to_delete = selected_backup['full_path'] folder_to_delete = selected_backup['full_path']
info_file_to_delete = os.path.join(self.backup_path, "pybackup", f"{selected_item_id}.txt") is_encrypted = selected_backup['is_encrypted']
password = None
dialog = MessageDialog(master=self, message_type="warning", if is_encrypted:
title=Msg.STR["confirm_delete_title"], # Get password in the UI thread before starting the background task
text=Msg.STR["confirm_delete_text"].format( password = self.backup_manager.encryption_manager.get_password("root", confirm=True)
folder_name=selected_item_id), if not password:
buttons=["ok_cancel"]) self.actions.logger.log("Password entry cancelled, aborting deletion.")
if dialog.get_result() != "ok": return
return
try: info_file_to_delete = os.path.join(
if os.path.isdir(folder_to_delete): self.backup_path, "pybackup", f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt")
shutil.rmtree(folder_to_delete)
if os.path.exists(info_file_to_delete): self.actions._set_ui_state(False)
os.remove(info_file_to_delete) self.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
self._load_backup_content()
except Exception as e: self.backup_manager.start_delete_backup(
MessageDialog(master=self, message_type="error", path_to_delete=folder_to_delete,
title=Msg.STR["error"], text=str(e)) info_file_path=info_file_to_delete,
is_encrypted=is_encrypted,
is_system=False,
base_dest_path=self.backup_path,
password=password,
queue=self.winfo_toplevel().queue
)