diff --git a/backup_manager.py b/backup_manager.py index 22d1ed1..38d51c2 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -128,10 +128,9 @@ rm -f '{info_file}' else: self.logger.log("No active backup process to cancel.") - def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0, is_compressed: bool = False, is_encrypted: bool = False, mode: str = "incremental"): - """Starts a generic backup process for a specific path, reporting to a queue.""" + def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0, is_compressed: bool = False, is_encrypted: bool = False, mode: str = "incremental", password: str = None): thread = threading.Thread(target=self._run_backup_path, args=( - queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed, is_encrypted, mode)) + queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed, is_encrypted, mode, password)) thread.daemon = True thread.start() @@ -209,14 +208,14 @@ 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): + 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: str): try: if is_encrypted: # For encrypted backups, the dest_path is the container file. container_path = dest_path + ".luks" # Estimate container size to be 110% of source size size_gb = int(source_size / (1024**3) * 1.1) + 1 - mount_point = self.encryption_manager.setup_encrypted_backup(queue, container_path, size_gb) + mount_point = self.encryption_manager.setup_encrypted_backup(queue, container_path, size_gb, password) if not mount_point: return # Error or cancellation already handled in setup method @@ -244,7 +243,7 @@ set -e else: 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 and not is_encrypted: self.logger.log(f"Using --link-dest='{latest_backup_path}'") command.append(f"--link-dest={latest_backup_path}") @@ -596,6 +595,14 @@ set -e details[key.strip()] = value.strip() return details + def has_encrypted_backups(self, base_backup_path: str) -> bool: + """Checks if any encrypted system backups exist in the destination.""" + system_backups = self.list_system_backups(base_backup_path) + for backup in system_backups: + if backup.get('is_encrypted'): + return True + return False + def list_backups(self, base_backup_path: str) -> List[str]: backups = [] if os.path.isdir(base_backup_path): diff --git a/core/encryption_manager.py b/core/encryption_manager.py index 98e98e1..2dd7d57 100644 --- a/core/encryption_manager.py +++ b/core/encryption_manager.py @@ -1,4 +1,3 @@ - import keyring import keyring.errors from keyring.backends import SecretService @@ -13,11 +12,14 @@ from pyimage_ui.password_dialog import PasswordDialog class EncryptionManager: def __init__(self, logger, app=None): + try: + keyring.set_keyring(SecretService.Keyring()) + except Exception as e: + logger.log(f"Failed to set keyring backend to SecretService: {e}") self.logger = logger self.app = app self.service_id = "py-backup-encryption" self.session_password = None - self.save_to_keyring = False def get_password_from_keyring(self, username: str) -> Optional[str]: try: @@ -29,12 +31,14 @@ class EncryptionManager: self.logger.log(f"Could not get password from keyring: {e}") return None - def set_password_in_keyring(self, username: str, password: str): + def set_password_in_keyring(self, username: str, password: str) -> bool: try: keyring.set_password(self.service_id, username, password) self.logger.log(f"Password for {username} stored in keyring.") + return True except Exception as e: self.logger.log(f"Could not set password in keyring: {e}") + return False def delete_password_from_keyring(self, username: str): try: @@ -43,34 +47,29 @@ class EncryptionManager: except Exception as e: self.logger.log(f"Could not delete password from keyring: {e}") - def set_session_password(self, password: str, save_to_keyring: bool): - self.session_password = password - self.save_to_keyring = save_to_keyring - def clear_session_password(self): self.session_password = None - self.save_to_keyring = False def get_password(self, username: str, confirm: bool = True) -> Optional[str]: if self.session_password: - if self.save_to_keyring: - self.set_password_in_keyring(username, self.session_password) return self.session_password password = self.get_password_from_keyring(username) if password: + self.session_password = password return password - # If not in keyring, prompt the user dialog = PasswordDialog(self.app, title=f"Enter password for {username}", confirm=confirm) - password = dialog.get_password() + password, save_to_keyring = dialog.get_password() + if password and save_to_keyring: + self.set_password_in_keyring(username, password) + if password: - # Ask to save the password - # For now, we don't save it automatically. This will be a UI option. - pass + self.session_password = password + return password - def setup_encrypted_backup(self, queue, container_path: str, size_gb: int) -> Optional[str]: + def setup_encrypted_backup(self, queue, container_path: str, size_gb: int, password: str) -> Optional[str]: """Sets up a LUKS encrypted container for the backup.""" self.logger.log(f"Setting up encrypted container at {container_path}") @@ -83,12 +82,10 @@ class EncryptionManager: mount_point = f"/mnt/{mapper_name}" if os.path.exists(container_path): - # Container exists, try to open it self.logger.log(f"Encrypted container {container_path} already exists. Attempting to unlock.") - password = self.get_password(os.path.basename(container_path), confirm=False) if not password: - self.logger.log("User cancelled password entry.") - queue.put(('completion', {'status': 'cancelled', 'returncode': -1})) + self.logger.log("No password provided for existing encrypted container.") + queue.put(('error', "No password provided for existing encrypted container.")) return None script = f""" @@ -101,11 +98,9 @@ class EncryptionManager: queue.put(('error', "Failed to unlock existing encrypted container.")) return None else: - # Container does not exist, create it - password = self.get_password(os.path.basename(container_path), confirm=True) if not password: - self.logger.log("User cancelled password entry.") - queue.put(('completion', {'status': 'cancelled', 'returncode': -1})) + self.logger.log("No password provided to create encrypted container.") + queue.put(('error', "No password provided to create encrypted container.")) return None script = f""" @@ -131,8 +126,8 @@ class EncryptionManager: self.logger.log(f"Cleaning up encrypted backup: {mapper_name}") script = f""" umount {mount_point} || echo "Mount point not found or already unmounted." - cryptsetup luksClose {mapper_name} || echo "Mapper not found or already closed." - rmdir {mount_point} || echo "Mount point directory not found." +cryptsetup luksClose {mapper_name} || echo "Mapper not found or already closed." +rmdir {mount_point} || echo "Mount point directory not found." """ if not self._execute_as_root(script): self.logger.log("Encrypted backup cleanup script failed.") @@ -141,14 +136,12 @@ class EncryptionManager: """Executes a shell script with root privileges using pkexec.""" script_path = '' try: - # Use tempfile for secure temporary file creation with tempfile.NamedTemporaryFile(mode='w', delete=False, prefix="pybackup_script_", suffix=".sh", dir="/tmp") as tmp_script: tmp_script.write("#!/bin/bash\n\n") - tmp_script.write("set -e\n\n") # Exit on error + tmp_script.write("set -e\n\n") tmp_script.write(script_content) script_path = tmp_script.name - # Make the script executable os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) diff --git a/main_app.py b/main_app.py index 78bb424..6f86980 100644 --- a/main_app.py +++ b/main_app.py @@ -16,7 +16,7 @@ from pyimage_ui.scheduler_frame import SchedulerFrame from pyimage_ui.backup_content_frame import BackupContentFrame from pyimage_ui.header_frame import HeaderFrame from pyimage_ui.settings_frame import SettingsFrame -from pyimage_ui.encryption_frame import EncryptionFrame + from core.data_processing import DataProcessing from pyimage_ui.drawing import Drawing from pyimage_ui.navigation import Navigation @@ -48,6 +48,8 @@ class MainApplication(tk.Tk): self.style.layout("Toolbutton")) self.style.configure("Gray.Toolbutton", foreground="gray") + self.style.configure("Green.Sidebar.TButton", foreground="green") + main_frame = ttk.Frame(self) main_frame.grid(row=0, column=0, sticky="nsew") self.grid_rowconfigure(0, weight=1) @@ -144,9 +146,7 @@ class MainApplication(tk.Tk): self.sidebar_buttons_frame, text=Msg.STR["settings"], command=lambda: self.navigation.toggle_settings_frame(4), style="Sidebar.TButton") self.settings_button.pack(fill=tk.X, pady=10) - self.encryption_button = ttk.Button( - self.sidebar_buttons_frame, text="Encryption", command=lambda: self.navigation.toggle_encryption_frame(5), style="Sidebar.TButton") - self.encryption_button.pack(fill=tk.X, pady=10) + self.header_frame = HeaderFrame(self.content_frame, self.image_manager) self.header_frame.grid(row=0, column=0, sticky="nsew") @@ -222,7 +222,7 @@ class MainApplication(tk.Tk): self._setup_scheduler_frame() self._setup_settings_frame() self._setup_backup_content_frame() - self._setup_encryption_frame() + self._setup_task_bar() @@ -339,6 +339,8 @@ class MainApplication(tk.Tk): self.destination_total_bytes = total self.destination_used_bytes = used + + restore_src_path = self.config_manager.get_setting( "restore_source_path") if restore_src_path and os.path.isdir(restore_src_path): @@ -407,11 +409,7 @@ class MainApplication(tk.Tk): self.backup_content_frame.grid(row=2, column=0, sticky="nsew") self.backup_content_frame.grid_remove() - def _setup_encryption_frame(self): - self.encryption_frame = EncryptionFrame( - self.content_frame, self.backup_manager.encryption_manager, padding=10) - self.encryption_frame.grid(row=2, column=0, sticky="nsew") - self.encryption_frame.grid_remove() + def _setup_task_bar(self): # Define all boolean vars at the top to ensure they exist before use. @@ -514,6 +512,8 @@ class MainApplication(tk.Tk): self.action_frame, text=Msg.STR["start"], command=self.actions.toggle_start_cancel, state="disabled") self.start_pause_button.grid(row=0, column=2, rowspan=2, padx=5) + + def on_closing(self): """Handles window closing events and saves the app state.""" self.config_manager.set_setting("last_mode", self.mode) @@ -769,6 +769,8 @@ class MainApplication(tk.Tk): self.encrypted_var.set(True) self.encrypted_cb.config(state="disabled") + self.actions._refresh_backup_options_ui() + if __name__ == "__main__": import argparse diff --git a/pyimage_ui/actions.py b/pyimage_ui/actions.py index 75deece..61bb5b7 100644 --- a/pyimage_ui/actions.py +++ b/pyimage_ui/actions.py @@ -22,34 +22,17 @@ class Actions: if backup_type == "full": self.app.vollbackup_var.set(True) self.app.inkrementell_var.set(False) - self.app.full_backup_cb.config(state="disabled") - self.app.incremental_cb.config(state="disabled") - self.app.accurate_size_cb.config(state="disabled") elif backup_type == "incremental": self.app.vollbackup_var.set(False) self.app.inkrementell_var.set(True) - self.app.full_backup_cb.config(state="normal") - self.app.incremental_cb.config(state="normal") - self.app.accurate_size_cb.config(state="normal") def _update_backup_type_controls(self): - """ - Updates the state of the Full/Incremental backup radio buttons based on - advanced settings and the content of the destination folder. - This logic only applies to 'Computer' backups. - """ - # Do nothing if the backup mode is not 'backup' or source is not 'Computer' if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer": - self.app.full_backup_cb.config(state="normal") - self.app.incremental_cb.config(state="normal") return - # Respect that advanced settings might have already disabled the controls - # This check is based on the user's confirmation that this logic exists elsewhere if self.app.full_backup_cb.cget('state') == 'disabled': return - # --- Standard Logic --- full_backup_exists = False if self.app.destination_path and os.path.isdir(self.app.destination_path): system_backups = self.app.backup_manager.list_system_backups( @@ -64,6 +47,33 @@ class Actions: else: self._set_backup_type("full") + def _refresh_backup_options_ui(self): + # Reset enabled/disabled state for all potentially affected controls + self.app.full_backup_cb.config(state="normal") + self.app.incremental_cb.config(state="normal") + self.app.compressed_cb.config(state="normal") + self.app.encrypted_cb.config(state="normal") + self.app.accurate_size_cb.config(state="normal") + + # Apply logic: Encryption and Compression are mutually exclusive + if self.app.encrypted_var.get(): + self.app.compressed_var.set(False) + self.app.compressed_cb.config(state="disabled") + + if self.app.compressed_var.get(): + self.app.encrypted_var.set(False) + self.app.encrypted_cb.config(state="disabled") + # Compression forces full backup + self.app.vollbackup_var.set(True) + self.app.inkrementell_var.set(False) + self.app.full_backup_cb.config(state="disabled") + self.app.incremental_cb.config(state="disabled") + self.app.accurate_size_cb.config(state="disabled") + self.app.genaue_berechnung_var.set(False) + + # After setting the states, determine the final full/incremental choice + self._update_backup_type_controls() + def handle_backup_type_change(self, changed_var_name): if changed_var_name == 'voll': if self.app.vollbackup_var.get(): @@ -73,61 +83,26 @@ class Actions: self._set_backup_type("incremental") def handle_compression_change(self): - if self.app.compressed_var.get(): - # Compression is enabled, force full backup - self.app.vollbackup_var.set(True) - self.app.inkrementell_var.set(False) - self.app.full_backup_cb.config(state="disabled") - self.app.incremental_cb.config(state="disabled") - # Also disable accurate incremental size calculation, as it's irrelevant - self.app.accurate_size_cb.config(state="disabled") - self.app.genaue_berechnung_var.set(False) - else: - # Compression is disabled, restore normal logic - self.app.full_backup_cb.config(state="normal") - # The state of the incremental checkbox depends on whether a full backup exists - self._update_backup_type_controls() + self._refresh_backup_options_ui() def handle_encryption_change(self): - if self.app.encrypted_var.get(): - # Encryption is enabled, force full backup and disable compression - self.app.vollbackup_var.set(True) - self.app.inkrementell_var.set(False) - self.app.full_backup_cb.config(state="disabled") - self.app.incremental_cb.config(state="disabled") - self.app.compressed_var.set(False) - self.app.compressed_cb.config(state="disabled") - self.app.accurate_size_cb.config(state="disabled") - self.app.genaue_berechnung_var.set(False) - else: - # Encryption is disabled, restore normal logic - self.app.full_backup_cb.config(state="normal") - self.app.compressed_cb.config(state="normal") - self._update_backup_type_controls() + self._refresh_backup_options_ui() def on_toggle_accurate_size_calc(self): - # This method is called when the user clicks the "Genaue inkrem. Größe" checkbox. if not self.app.genaue_berechnung_var.get(): - # Box was unchecked by user, but this shouldn't happen if disabled. - # Or it was unchecked automatically after a run. Do nothing. return - # If a normal calculation is running, stop it. if self.app.calculation_thread and self.app.calculation_thread.is_alive(): self.app.calculation_stop_event.set() app_logger.log("Stopping previous size calculation.") - # --- Start the accurate calculation --- app_logger.log("Accurate incremental size calculation requested.") self.app.accurate_calculation_running = True - # Lock the UI, keep Cancel enabled self._set_ui_state(False, keep_cancel_enabled=True) self.app.start_pause_button.config(text=Msg.STR["cancel_backup"]) - # Immediately clear the projection canvases self.app.drawing.reset_projection_canvases() - # Update canvas to show it's calculating and start animation self.app.info_label.config( text=Msg.STR["please_wait"], foreground="#0078d7") self.app.task_progress.config(mode="indeterminate") @@ -139,21 +114,20 @@ class Actions: self.app.drawing.start_backup_calculation_display() self.app.animated_icon.start() - # Get folder path from the current canvas data folder_path = self.app.left_canvas_data.get('path_display') button_text = self.app.left_canvas_data.get('folder') if not folder_path or not button_text: app_logger.log( "Cannot start accurate calculation, source folder info missing.") - self._set_ui_state(True) # Unlock UI - self.app.genaue_berechnung_var.set(False) # Uncheck the box + self._set_ui_state(True) + self.app.genaue_berechnung_var.set(False) self.app.accurate_calculation_running = False self.app.animated_icon.stop("DISABLE") return def threaded_incremental_calc(): - status = 'failure' # Default to failure + status = 'failure' size = 0 try: exclude_file_paths = [] @@ -176,15 +150,12 @@ class Actions: is_system=True, exclude_files=exclude_file_paths ) - # If rsync fails, size is 0, so status will be failure. status = 'success' if size > 0 else 'failure' except Exception as e: app_logger.log(f"Error during threaded_incremental_calc: {e}") status = 'failure' finally: - # This message MUST be sent to unlock the UI and update the display. - # It is now handled by the main data_processing queue. - if self.app.accurate_calculation_running: # Only post if not cancelled + if self.app.accurate_calculation_running: self.app.queue.put( (button_text, size, self.app.mode, 'accurate_incremental', status)) @@ -206,7 +177,6 @@ class Actions: self.app.log_window.clear_log() - # Reverse map from translated UI string to canonical key REVERSE_FOLDER_MAP = { "Computer": "Computer", Msg.STR["cat_documents"]: "Documents", @@ -235,28 +205,24 @@ class Actions: icon_name = self.app.buttons_map[button_text]['icon'] - # Determine the correct description based on mode and selection extra_info = "" if button_text == "Computer": if self.app.mode == 'backup': extra_info = Msg.STR["system_backup_info"] - else: # restore + else: extra_info = Msg.STR["system_restore_info"] - else: # User folder + else: if self.app.mode == 'backup': extra_info = Msg.STR["user_backup_info"] - else: # restore + else: extra_info = Msg.STR["user_restore_info"] - # CORRECTED LOGIC ORDER: - # 1. Update backup type checkboxes BEFORE starting the calculation. if self.app.mode == 'backup': self._update_backup_type_controls() - else: # restore mode + else: self.app.config_manager.set_setting( "restore_destination_path", folder_path) - # 2. Unified logic for starting a calculation on the left canvas. self._start_left_canvas_calculation( button_text, str(folder_path), icon_name, extra_info) @@ -303,8 +269,6 @@ class Actions: self.app.calculation_stop_event = threading.Event() - # For system backup, now we default to the simple, fast (but for incremental inaccurate) full size calculation. - # The accurate calculation is now triggered explicitly by the new checkbox. if button_text == "Computer": app_logger.log( "Using default (full) size calculation for system backup display.") @@ -313,7 +277,6 @@ class Actions: args = (folder_path, button_text, self.app.calculation_stop_event, exclude_patterns, self.app.mode) else: - # For user folders, do not use any exclusions target_method = self.app.data_processing.get_user_folder_size_threaded args = (folder_path, button_text, self.app.calculation_stop_event, self.app.mode) @@ -325,7 +288,7 @@ class Actions: if self.app.mode == 'backup': self._update_backup_type_controls() - else: # restore mode + else: self.app.config_manager.set_setting( "restore_destination_path", folder_path) @@ -416,7 +379,6 @@ class Actions: self.app.config_manager.set_setting("restore_source_path", None) self.app.config_manager.set_setting("restore_destination_path", None) - # Remove advanced settings self.app.config_manager.remove_setting("backup_animation_type") self.app.config_manager.remove_setting("calculation_animation_type") self.app.config_manager.remove_setting("force_full_backup") @@ -424,7 +386,6 @@ class Actions: self.app.config_manager.remove_setting("force_compression") self.app.config_manager.remove_setting("force_encryption") - # Update the main UI to reflect the cleared settings self.app.update_backup_options_from_config() AppConfig.generate_and_write_final_exclude_list() @@ -437,7 +398,6 @@ class Actions: self.app.destination_path = None self.app.start_pause_button.config(state="disabled") - # Clear the canvases and reset the UI to its initial state for the current mode self.app.backup_left_canvas_data.clear() self.app.backup_right_canvas_data.clear() self.app.restore_left_canvas_data.clear() @@ -455,13 +415,12 @@ class Actions: title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"]) def _parse_size_string_to_bytes(self, size_str: str) -> int: - """Parses a size string like '38.61 GB' into bytes.""" if not size_str or size_str == Msg.STR["calculating_size"]: return 0 parts = size_str.split() if len(parts) != 2: - return 0 # Invalid format + return 0 try: value = float(parts[0]) @@ -483,33 +442,24 @@ class Actions: return 0 def _set_ui_state(self, enable: bool, keep_cancel_enabled: bool = False, allow_log_and_backup_toggle: bool = False): - # Sidebar Buttons for text, data in self.app.buttons_map.items(): - # Find the actual button widget in the sidebar_buttons_frame - # This assumes the order of creation is consistent or we can identify by text - # A more robust way would be to store references to the buttons in a dict in MainApplication - # For now, let's iterate through children and match text for child in self.app.sidebar_buttons_frame.winfo_children(): if isinstance(child, tk.ttk.Button) and child.cget("text") == text: child.config(state="normal" if enable else "disabled") break - # Schedule and Settings buttons in sidebar self.app.schedule_dialog_button.config( state="normal" if enable else "disabled") self.app.settings_button.config( state="normal" if enable else "disabled") - # Mode Button (arrow between canvases) self.app.mode_button.config(state="normal" if enable else "disabled") - # Top Navigation Buttons for i, button in enumerate(self.app.nav_buttons): if allow_log_and_backup_toggle and self.app.nav_buttons_defs[i][0] in [Msg.STR["log"], Msg.STR["backup_menu"]]: continue button.config(state="normal" if enable else "disabled") - # Right Canvas (Destination/Restore Source) if enable: self.app.right_canvas.bind( "", self.app.actions.on_right_canvas_click) @@ -518,14 +468,10 @@ class Actions: self.app.right_canvas.unbind("") self.app.right_canvas.config(cursor="") - # Checkboxes in the task bar if enable: - # When enabling, re-run the logic that sets the correct state - # for all checkboxes based on config and context. self.app.update_backup_options_from_config() self.app.actions._update_backup_type_controls() else: - # When disabling, just disable all of them. checkboxes = [ self.app.full_backup_cb, self.app.incremental_cb, @@ -538,35 +484,29 @@ class Actions: for cb in checkboxes: cb.config(state="disabled") - # Special handling for the cancel button during accurate calculation if keep_cancel_enabled: self.app.start_pause_button.config(state="normal") def toggle_start_cancel(self): - # If an accurate calculation is running, cancel it. if self.app.accurate_calculation_running: app_logger.log("Accurate size calculation cancelled by user.") self.app.accurate_calculation_running = False self.app.genaue_berechnung_var.set(False) - # Stop all animations and restore UI self.app.animated_icon.stop("DISABLE") if self.app.left_canvas_animation: self.app.left_canvas_animation.stop() self.app.left_canvas_data['calculating'] = False self.app.drawing.redraw_left_canvas() - # Restore bottom action bar self.app.task_progress.stop() self.app.task_progress.config(mode="determinate", value=0) self.app.info_label.config( - # Orange for cancelled text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C") self.app.start_pause_button.config(text=Msg.STR["start"]) self._set_ui_state(True) return - # If a backup is already running, we must be cancelling. if self.app.backup_is_running: self.app.animated_icon.stop("DISABLE") @@ -612,27 +552,23 @@ class Actions: if hasattr(self.app, 'current_backup_path'): self.app.current_backup_path = None - # Reset state self.app.backup_is_running = False self.app.start_pause_button["text"] = Msg.STR["start"] self._set_ui_state(True) - # Otherwise, we are starting a new backup. else: if self.app.start_pause_button['state'] == 'disabled': return self.app.backup_is_running = True - # --- Record and Display Start Time --- self.app.start_time = datetime.datetime.now() start_str = self.app.start_time.strftime("%H:%M:%S") self.app.start_time_label.config(text=f"Start: {start_str}") self.app.end_time_label.config(text="Ende: --:--:--") self.app.duration_label.config(text="Dauer: --:--:--") self.app.info_label.config(text="Backup wird vorbereitet...") - self.app._update_duration() # Start the continuous duration update - # --- End Time Logic --- + self.app._update_duration() self.app.start_pause_button["text"] = Msg.STR["cancel_backup"] self.app.update_idletasks() @@ -664,6 +600,18 @@ class Actions: title=Msg.STR["error"], text=Msg.STR["system_backup_in_home_error"]) return + is_encrypted = self.app.encrypted_var.get() + password = None + if is_encrypted: + username = os.path.basename(base_dest.rstrip('/')) + password = self.app.backup_manager.encryption_manager.get_password(username, confirm=True) + if not password: + app_logger.log("Encryption enabled, but no password provided. Aborting backup.") + self.app.backup_is_running = False + self.app.start_pause_button["text"] = Msg.STR["start"] + self._set_ui_state(True) + return + try: locale.setlocale(locale.LC_TIME, 'de_DE.UTF-8') except locale.Error: @@ -675,7 +623,6 @@ class Actions: time_str = now.strftime("%H%M%S") folder_name = f"{date_str}_{time_str}_system_{mode}" final_dest = os.path.join(base_dest, "pybackup", folder_name) - # Store the path for potential deletion self.app.current_backup_path = final_dest source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0) @@ -690,7 +637,6 @@ class Actions: is_dry_run = self.app.testlauf_var.get() is_compressed = self.app.compressed_var.get() - is_encrypted = self.app.encrypted_var.get() self.app.backup_manager.start_backup( queue=self.app.queue, @@ -702,7 +648,8 @@ class Actions: source_size=source_size_bytes, is_compressed=is_compressed, is_encrypted=is_encrypted, - mode=mode) + mode=mode, + password=password) def _start_user_backup(self, sources): dest = self.app.destination_path @@ -718,4 +665,4 @@ class Actions: source_path=source, dest_path=dest, is_system=False, - is_dry_run=is_dry_run) + is_dry_run=is_dry_run) \ No newline at end of file diff --git a/pyimage_ui/encryption_frame.py b/pyimage_ui/encryption_frame.py index a76b500..8f0a549 100644 --- a/pyimage_ui/encryption_frame.py +++ b/pyimage_ui/encryption_frame.py @@ -1,4 +1,3 @@ - import tkinter as tk from tkinter import ttk from shared_libs.message import MessageDialog @@ -6,9 +5,11 @@ import keyring class EncryptionFrame(ttk.Frame): - def __init__(self, parent, encryption_manager, **kwargs): + def __init__(self, parent, app, encryption_manager, **kwargs): super().__init__(parent, **kwargs) + self.app = app self.encryption_manager = encryption_manager + self.username = None self.columnconfigure(0, weight=1) @@ -19,6 +20,10 @@ class EncryptionFrame(ttk.Frame): self.keyring_status_label = ttk.Label(self, text="") self.keyring_status_label.grid( row=1, column=0, sticky="ew", padx=10, pady=5) + + self.keyring_usage_label = ttk.Label(self, text="") + self.keyring_usage_label.grid(row=4, column=0, sticky="ew", padx=10, pady=5) + self.check_keyring_availability() # Password section @@ -48,6 +53,10 @@ class EncryptionFrame(ttk.Frame): self.status_message_label = ttk.Label(self, text="", foreground="blue") self.status_message_label.grid(row=3, column=0, sticky="ew", padx=10, pady=5) + def set_context(self, username): + self.username = username + self.update_keyring_status() + def check_keyring_availability(self): try: kr = keyring.get_keyring() @@ -55,6 +64,9 @@ class EncryptionFrame(ttk.Frame): self.keyring_status_label.config( text="No system keyring found. Passwords will not be saved.", foreground="orange") self.save_to_keyring_cb.config(state="disabled") + else: + self.keyring_status_label.config( + text="System keyring is available.", foreground="green") except keyring.errors.NoKeyringError: self.keyring_status_label.config( text="No system keyring found. Passwords will not be saved.", foreground="orange") @@ -67,9 +79,35 @@ class EncryptionFrame(ttk.Frame): return self.encryption_manager.set_session_password(password, self.save_to_keyring_var.get()) - self.status_message_label.config(text="Password set for this session.", foreground="green") + + if self.save_to_keyring_var.get(): + if not self.username: + self.status_message_label.config(text="Please select a backup destination first.", foreground="orange") + return + if self.encryption_manager.set_password_in_keyring(self.username, password): + self.status_message_label.config(text="Password set for this session and saved to keyring.", foreground="green") + self.update_keyring_status() + else: + self.status_message_label.config(text="Password set for this session, but failed to save to keyring.", foreground="orange") + else: + self.status_message_label.config(text="Password set for this session.", foreground="green") + def clear_session_password(self): self.encryption_manager.clear_session_password() self.password_entry.delete(0, tk.END) self.status_message_label.config(text="Session password cleared.", foreground="green") + if self.username: + self.encryption_manager.delete_password_from_keyring(self.username) + self.update_keyring_status() + + def update_keyring_status(self): + if not self.username: + self.keyring_usage_label.config(text="Select a backup destination to see keyring status.", foreground="blue") + return + + if self.encryption_manager.get_password_from_keyring(self.username): + self.keyring_usage_label.config(text=f'Password for "{self.username}" is stored in the keyring.', foreground="green") + else: + self.keyring_usage_label.config(text=f'No password for "{self.username}" found in the keyring.', foreground="orange") + diff --git a/pyimage_ui/navigation.py b/pyimage_ui/navigation.py index 767a822..ac6f22d 100644 --- a/pyimage_ui/navigation.py +++ b/pyimage_ui/navigation.py @@ -142,7 +142,7 @@ class Navigation: self.app.scheduler_frame.hide() self.app.settings_frame.hide() self.app.backup_content_frame.hide() - self.app.encryption_frame.grid_remove() + # Show the main content frames self.app.canvas_frame.grid() @@ -187,7 +187,7 @@ class Navigation: self.app.scheduler_frame.hide() self.app.settings_frame.hide() self.app.backup_content_frame.hide() - self.app.encryption_frame.grid_remove() + self.app.canvas_frame.grid() self.app.source_size_frame.grid() self.app.target_size_frame.grid() @@ -242,7 +242,7 @@ class Navigation: self.app.log_frame.grid_remove() self.app.settings_frame.hide() self.app.backup_content_frame.hide() - self.app.encryption_frame.grid_remove() + self.app.source_size_frame.grid_remove() self.app.target_size_frame.grid_remove() self.app.restore_size_frame_before.grid_remove() @@ -260,7 +260,7 @@ class Navigation: self.app.log_frame.grid_remove() self.app.backup_content_frame.hide() self.app.scheduler_frame.hide() - self.app.encryption_frame.grid_remove() + self.app.source_size_frame.grid_remove() self.app.target_size_frame.grid_remove() self.app.restore_size_frame_before.grid_remove() @@ -284,7 +284,7 @@ class Navigation: self.app.log_frame.grid_remove() self.app.scheduler_frame.hide() self.app.settings_frame.hide() - self.app.encryption_frame.grid_remove() + self.app.source_size_frame.grid_remove() self.app.target_size_frame.grid_remove() self.app.restore_size_frame_before.grid_remove() @@ -293,19 +293,4 @@ class Navigation: self.app.top_bar.grid() self._update_task_bar_visibility("scheduler") - def toggle_encryption_frame(self, active_index=None): - self._cancel_calculation() - self.app.drawing.update_nav_buttons(-1) - - self.app.canvas_frame.grid_remove() - self.app.log_frame.grid_remove() - self.app.scheduler_frame.hide() - self.app.settings_frame.hide() - self.app.backup_content_frame.hide() - self.app.source_size_frame.grid_remove() - self.app.target_size_frame.grid_remove() - self.app.restore_size_frame_before.grid_remove() - self.app.restore_size_frame_after.grid_remove() - self.app.encryption_frame.grid() - self.app.top_bar.grid() - self._update_task_bar_visibility("settings") + diff --git a/pyimage_ui/password_dialog.py b/pyimage_ui/password_dialog.py index 9c2ce3b..d2285f0 100644 --- a/pyimage_ui/password_dialog.py +++ b/pyimage_ui/password_dialog.py @@ -1,4 +1,3 @@ - import tkinter as tk from tkinter import ttk, messagebox @@ -8,6 +7,7 @@ class PasswordDialog(tk.Toplevel): self.title(title) self.parent = parent self.password = None + self.save_to_keyring = tk.BooleanVar() self.confirm = confirm self.transient(parent) @@ -23,6 +23,9 @@ class PasswordDialog(tk.Toplevel): self.confirm_entry = ttk.Entry(self, show="*") self.confirm_entry.pack(padx=20, pady=5, fill="x", expand=True) + self.save_to_keyring_cb = ttk.Checkbutton(self, text="Save password to system keyring", variable=self.save_to_keyring) + self.save_to_keyring_cb.pack(padx=20, pady=10) + button_frame = ttk.Frame(self) button_frame.pack(pady=10) @@ -57,4 +60,4 @@ class PasswordDialog(tk.Toplevel): self.destroy() def get_password(self): - return self.password + return self.password, self.save_to_keyring.get() \ No newline at end of file