Compare commits

...

6 Commits

Author SHA1 Message Date
3a59bccddc add checkboxes to keyfileframe 2025-09-15 11:27:17 +02:00
9406d3f0e2 refactor(settings): Overhaul Reset and Keyfile UI/UX
This commit introduces a major refactoring of the settings and advanced settings panels to improve user experience and code structure.

Key Changes:

1.  **Reset Functionality Refactored:**
    -   The "Default Settings" logic has been moved from `actions.py` into `settings_frame.py`, where it belongs.
    -   Clicking "Reset" now opens a dedicated view with separate options for a "Default Reset" and a "Hard Reset", each with clear explanations.
    -   A global "Cancel" button was added for this new view, and the layout of the components has been centered and cleaned up.
    -   Error handling for the reset process has been improved to show feedback in the header.

2.  **Keyfile Creation UX Overhauled:**
    -   The "Automation Settings" (Keyfile) view in Advanced Settings is completely redesigned for clarity.
    -   It now explicitly displays the currently selected backup destination, so the user knows which container will be affected.
    -   A detailed description has been added to explain the purpose and prerequisites of creating a keyfile.
    -   The redundant "Apply" button is now hidden in this view.
    -   Feedback for keyfile creation (success or failure) is now shown as a temporary message in the header instead of a blocking dialog.
    -   Error messages are more informative, guiding the user on how to proceed (e.g., by creating an encrypted backup first).

3.  **String Externalization:**
    -   All new UI text for the keyfile and reset features has been added to `pbp_app_config.py` to support translation.
2025-09-15 00:46:47 +02:00
34234d2d14 refactor(ui): Improve user feedback and hard reset logic
This commit introduces several improvements to the user interface and application logic:

- **UI Feedback:**
  - Replaces the subtitle text change with a large, centered, temporary message for better visibility of feedback like "Settings saved" or "Hard reset successful".
  - Adds several new message strings to `pbp_app_config.py` for consistent UI text.

- **Hard Reset:**
  - Fixes a bug where encrypted drives were not unmounted during a hard reset.
  - The `_perform_hard_reset` function now correctly uses the existing `encryption_manager` instance to unmount all drives before deleting the configuration and restarting the application.
  - The application now provides clear feedback during and after the hard reset process.

- **Settings:**
  - Improves the "Advanced Settings" and "Settings" frames by adding success messages and preventing errors on empty selections.
  - The main application closing sequence (`on_closing`) is also made more robust, mirroring the new hard reset logic for unmounting drives.
2025-09-14 21:44:46 +02:00
6504758b7b feat(ui): Add warning for unmounted encrypted drives
Adds a warning message to the main UI.

The message "Accurate detection is only possible when the drive is mounted" is now displayed in addition to the standard "You can start a backup here" text, under the following conditions:
- The backup mode is active.
- The "Encrypt Backup" option is enabled for the current profile.
- The corresponding encrypted drive is not currently mounted.

This provides clearer feedback to the user on why size calculations might be unavailable or inaccurate for encrypted targets.
2025-09-14 15:44:02 +02:00
ff640ca9ef feat: Improve encrypted backups and resize logic
This commit introduces several major improvements to the handling of encrypted incremental backups.

1.  **Fix LUKS Container Resize:**
    The `encryption_helper.sh` script has been fixed to reliably resize LUKS containers. The `cryptsetup resize` command now correctly re-authenticates, resolving an issue where the script would fail because it was incorrectly trying to read a password from an empty stdin stream.

2.  **Refactor Backup Directory Structure:**
    The storage path for encrypted user backups has been flattened. Backups are now stored directly in the encrypted mount point (e.g., `/backup/pybackup/encrypted_videos/BACKUP_NAME`) instead of a deeply nested folder (`.../user/SOURCE_NAME/BACKUP_NAME`). This simplifies the directory structure as requested.

3.  **Correct Incremental Size Estimation:**
    The `estimate_incremental_size` function is now more robust.
    - The `rsync` command for user backups now correctly uses a trailing slash on the source path.
    - This ensures the `--link-dest` comparison works as intended against the new, flat directory structure, leading to an accurate calculation of the required incremental size.

4.  **Refine Container Resize Trigger and Logic:**
    The logic for automatically resizing encrypted containers in `encryption_manager.py` has been completely overhauled to prevent excessive growth:
    - A resize is now only triggered if the projected free space after a backup would fall below a 4 GB buffer.
    - The calculation for the new size now correctly adds the required space plus the 4 GB buffer, ensuring sufficient space without over-provisioning.
2025-09-14 14:23:12 +02:00
7d64544c37 rezize works part one 2025-09-14 12:56:09 +02:00
10 changed files with 434 additions and 214 deletions

View File

@@ -88,7 +88,11 @@ class BackupManager:
if is_system:
return os.path.join(base_data_dir, "system")
# For user backups, store them directly in the mount point if encrypted.
elif is_encrypted:
return base_data_dir
else:
# Keep old structure for unencrypted user backups
return os.path.join(base_data_dir, "user", source_name)
def check_for_full_backup(self, dest_path: str, source_name: str, is_encrypted: bool) -> bool:
@@ -201,7 +205,9 @@ class BackupManager:
full_system_cmd = f"mkdir -p '{rsync_dest}' && {rsync_cmd_str}"
command = ['pkexec', 'bash', '-c', full_system_cmd]
else:
rsync_command_parts.extend([source_path, rsync_dest])
# Add a trailing slash to the source path to ensure rsync copies the content, not the directory itself.
source_with_slash = source_path.rstrip('/') + '/'
rsync_command_parts.extend([source_with_slash, rsync_dest])
os.makedirs(rsync_dest, exist_ok=True)
command = rsync_command_parts
@@ -273,7 +279,7 @@ class BackupManager:
if is_system:
command.extend(['pkexec', 'rsync', '-aAXHvn', '--stats'])
else:
command.extend(['rsync', '-avn', '--stats'])
command.extend(['rsync', '-ain', '--dry-run', '--stats'])
command.append(f"--link-dest={latest_backup_path}")
@@ -283,6 +289,9 @@ class BackupManager:
try:
with tempfile.TemporaryDirectory() as dummy_dest:
# For user backups, add a trailing slash to copy contents, not the dir itself.
if not is_system:
source_path = source_path.rstrip('/') + '/'
command.extend([source_path, dummy_dest])
self.logger.log(

View File

@@ -141,11 +141,23 @@ do_resize() {
exit 1
fi
log "Resizing LUKS volume."
cryptsetup resize "$mapper_name"
log "Checking and resizing filesystem."
log "Checking filesystem before resize."
e2fsck -fy "/dev/mapper/$mapper_name"
log "Resizing LUKS volume and filesystem."
# Re-authenticate for resize, as some cryptsetup versions require it.
if [ -n "$LUKSPASS" ]; then
log "Resizing with password."
echo -n "$LUKSPASS" | cryptsetup resize "$mapper_name" -
elif [ -n "$key_file_arg" ]; then
log "Resizing with keyfile."
cryptsetup resize "$mapper_name" --key-file "$key_file_arg"
else
# This should not happen if luksOpen succeeded.
# Fallback to the previous non-interactive attempt.
log "No password or keyfile for resize, trying non-interactively."
cryptsetup resize "$mapper_name" < /dev/null
fi
resize2fs "/dev/mapper/$mapper_name"
# 4. Mount it again

View File

@@ -27,6 +27,7 @@ class EncryptionManager:
self.lock_file = AppConfig.LOCK_FILE_PATH
def _write_lock_file(self, data):
with open(self.lock_file, 'w') as f:
json.dump(data, f)
@@ -93,33 +94,62 @@ class EncryptionManager:
return os.path.join(pybackup_dir, container_filename)
def get_key_file_path(self, base_dest_path: str, profile_name: str) -> str:
pybackup_dir = os.path.join(base_dest_path, "pybackup")
# base_dest_path is ignored for security. Keyfiles are stored centrally.
secure_key_dir = "/usr/local/etc/luks"
key_filename = f"luks_{profile_name}.keyfile"
return os.path.join(pybackup_dir, key_filename)
return os.path.join(secure_key_dir, key_filename)
def create_and_add_key_file(self, base_dest_path: str, profile_name: str, password: str) -> Optional[str]:
# Get the new secure path for the keyfile
key_file_path = self.get_key_file_path(base_dest_path, profile_name)
secure_key_dir = os.path.dirname(key_file_path)
# Container path remains the same, on the destination drive
container_path = self.get_container_path(base_dest_path, profile_name)
# This script will be executed as root. It creates the secure directory,
# generates the keyfile, sets its permissions, and adds it to the LUKS container.
# Using shlex.quote to be safe with paths
script = f"""
mkdir -p {shlex.quote(secure_key_dir)} && \
dd if=/dev/urandom of={shlex.quote(key_file_path)} bs=1 count=4096 && \
chmod 0400 {shlex.quote(key_file_path)} && \
cryptsetup luksAddKey {shlex.quote(container_path)} {shlex.quote(key_file_path)}
"""
if self._execute_as_root(script, password):
self.logger.log(
f"Successfully created keyfile at {key_file_path} and added to LUKS container.")
return key_file_path
else:
self.logger.log(
"Failed to create and add key file. The script might have failed midway. Attempting cleanup.")
# Attempt to clean up the keyfile if the script failed, as we can't be sure at which stage it failed.
cleanup_script = f"rm -f {shlex.quote(key_file_path)}"
self._execute_as_root(cleanup_script)
return None
def delete_key_file(self, base_dest_path: str, profile_name: str, password: str) -> bool:
key_file_path = self.get_key_file_path(base_dest_path, profile_name)
container_path = self.get_container_path(base_dest_path, profile_name)
try:
with open(key_file_path, 'wb') as f:
f.write(os.urandom(4096))
os.chmod(key_file_path, 0o400)
self.logger.log(f"Generated new key file at {key_file_path}")
script = f'cryptsetup luksAddKey "{container_path}" "{key_file_path}"'
if self._execute_as_root(script, password):
self.logger.log(
"Successfully added key file to LUKS container.")
return key_file_path
else:
self.logger.log("Failed to add key file to LUKS container.")
os.remove(key_file_path)
return None
except Exception as e:
self.logger.log(f"Error creating key file: {e}")
if os.path.exists(key_file_path):
os.remove(key_file_path)
return None
if not os.path.exists(key_file_path):
self.logger.log(f"Keyfile for profile {profile_name} does not exist. Nothing to delete.")
return True # Considered a success as the desired state is "deleted"
# This script removes the key from the LUKS container and then deletes the keyfile.
# It requires a valid password for the container to authorize the key removal.
script = f"""
cryptsetup luksRemoveKey {shlex.quote(container_path)} {shlex.quote(key_file_path)} && \
rm -f {shlex.quote(key_file_path)}
"""
if self._execute_as_root(script, password):
self.logger.log(f"Successfully removed keyfile for profile {profile_name}.")
return True
else:
self.logger.log(f"Failed to remove keyfile for profile {profile_name}.")
return False
def _get_password_and_keyfile(self, base_dest_path: str, profile_name: str, username: str) -> Tuple[Optional[str], Optional[str]]:
"""Gets the password (from cache, keyring, or user) or the keyfile path."""
@@ -177,9 +207,11 @@ class EncryptionManager:
free_space = shutil.disk_usage(mount_point).free
required_space = int(source_size * 1.10)
if required_space > free_space:
BUFFER = 4 * 1024 * 1024 * 1024 # 4 GB
# Trigger resize if projected free space is less than the buffer
if free_space - required_space < BUFFER:
self.logger.log(
f"Resize needed for {profile_name}. Free: {free_space}, Required: {required_space}")
f"Resize needed for {profile_name}. Free: {free_space}, Required: {required_space}, Buffer: {BUFFER}")
queue.put(
('status_update', f"Container für {profile_name} zu klein. Vergrößere..."))
@@ -192,8 +224,11 @@ class EncryptionManager:
return None
current_total = shutil.disk_usage(mount_point).total
needed_additional = required_space - free_space - \
(4 * 1024 * 1024 * 1024) # (4 * 1024 * 1024 * 1024) = 4 GB
# How much space we need to add to meet the future demand and have the buffer left over.
# This is the deficit (required_space - free_space) plus the desired buffer.
needed_additional = (required_space - free_space) + BUFFER
new_total_size = current_total + needed_additional
new_total_size = math.ceil(new_total_size / 4096) * 4096

View File

@@ -229,6 +229,7 @@ class Msg:
"ready_for_first_backup": _("Everything is ready for your first backup."),
"backup_mode": _("Backup Mode"),
"backup_mode_info": _("Backup Mode: You can start a backup here."),
"encrypted_unmounted_warning": _("Accurate detection is only possible when the drive is mounted."),
"restore_mode_info": _("Restore Mode: You can start a restore here."),
"advanced_settings_title": _("Advanced Settings"),
"animation_settings_title": _("Animation Settings"),
@@ -256,7 +257,7 @@ class Msg:
"log": _("Log"),
"full_backup": _("Full backup"),
"incremental": _("Incremental"),
"incremental_backup": _("Incremental backup"), # New
"incremental_backup": _("Incremental backup"),
"test_run": _("Test run"),
"start": _("Start"),
"cancel_backup": _("Cancel"),
@@ -279,8 +280,8 @@ class Msg:
"system_excludes": _("System Excludes"),
"manual_excludes": _("Manual Excludes"),
"manual_excludes_info": _("Here, manually add files or folders to be excluded from the backup. Each entry should be on a new line."),
"sudoers_info_text": _(f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
f"with the following content (replace 'user' with the correct username):\n"
"sudoers_info_text": _(f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
f"with the following content (replace 'user' with the correct username):\n"
f"user ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py"),
# Menus
@@ -352,9 +353,9 @@ class Msg:
"header_subtitle": _("Simple GUI for rsync"),
"encrypted_backup_content": _("Encrypted Backups"),
"compressed": _("Compressed"),
"compression": _("Compression"), # New
"compression": _("Compression"),
"encrypted": _("Encrypted"),
"encryption": _("Encryption"), # New
"encryption": _("Encryption"),
"bypass_security": _("Bypass security"),
"refresh_log": _("Refresh log"),
"comment": _("Kommentar"),
@@ -369,15 +370,38 @@ class Msg:
"sync_mode_trash_bin": _("Sync Mode: Archive. Files deleted from the source will be moved to a trash folder in the backup."),
"sync_mode_no_delete": _("Sync Mode: Additive. Files deleted from the source will be kept in the backup."),
"encryption_note_system_backup": _("Note: For system backups, encryption only applies to files directly within the /home directory. Folders are not automatically encrypted unless explicitly included in the backup."),
"keyfile_settings": _("Keyfile Settings"), # New
"backup_defaults_title": _("Backup Defaults"), # New
"automation_settings_title": _("Automation Settings"), # New
"create_add_key_file": _("Create/Add Key File"), # New
"key_file_not_created": _("Key file not created."), # New
"backup_options": _("Backup Options"), # New
"hard_reset": _("Hard reset"),
# Advanced Settings - Keyfile
"keyfile_settings": _("Keyfile Settings"),
"backup_defaults_title": _("Backup Defaults"),
"automation_settings_title": _("Automation Settings"),
"create_add_key_file": _("Create/Add Key File"),
"key_file_not_created": _("Key file not created."),
"keyfile_automation_info": _("A keyfile provides an alternative way to unlock an encrypted backup without entering a password, which is useful for automation.\n\n**Prerequisite:** You must first create at least one encrypted backup for a profile. This process will then add a keyfile to that existing encrypted container."),
"err_no_encrypted_container": _("No encrypted container found at the destination. Please create an encrypted backup for this profile first."),
"enter_existing_password_title": _("Enter Existing Password"),
"keyfile_creation_success": _("Keyfile created successfully!"),
"keyfile_creation_failed": _("Failed to create keyfile. See log for details."),
"keyfile_status_unknown": _("Keyfile status unknown (no destination set)."),
"keyfile_exists": _("Keyfile exists: {key_file_path}"),
"keyfile_not_found_for_dest": _("Keyfile has not been created for this destination."),
"select_profile_first": _("Please select at least one profile from the list."),
"current_destination": _("Current Target Destination"),
"no_destination_selected": _("None. Please select a destination in the main backup view."),
"sudoers_info_text": _(f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
f"with the following content (replace 'user' with the correct username):\n"
f"user ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py"),
# General Settings
"backup_options": _("Backup Options"),
"reset": _("Reset"),
"hard_reset_success": _("Hard reset successful.\nRestarting..."),
"default_reset_success": _("Default reset successful"),
"default_reset_failed": _("Default reset failed\nPlease click Delete now on Full_delete_config_settings..."),
"default_reset_info": _("Click to restore all settings to their original defaults. Your existing backup schedules and any manually added paths in the exclusion list will be preserved."),
"hard_reset_warning": _("This will reset the application to its initial state, as if it were opened for the first time. This can be useful if you are experiencing problems with the app. Clicking 'Delete now' will delete the '.config/py_backup' folder in your home directory without an additional dialog. The application will then automatically restart."),
"delete_now": _("Delete now"),
"default_config_settings": _("Default config settings"),
"full_delete_config_settings": _("Full delete config settings"),
"password_required": _("Password Required"),
"enter_password_prompt": _("Please enter the password for the encrypted backup:"),
@@ -386,5 +410,7 @@ class Msg:
"password_empty_error": _("Password cannot be empty."),
"passwords_do_not_match_error": _("Passwords do not match."),
"ok": _("OK"),
"settings_saved": _("Settings saved successfully!"),
"delete_success": _("Deletion successful!"),
"unlock_backup": _("Unlock Backup"),
}

View File

@@ -54,16 +54,20 @@ class MainApplication(tk.Tk):
self.style.configure("Green.Sidebar.TButton", foreground="green")
# Custom button styles for BackupContentFrame
self.style.configure("Success.TButton", foreground="#2E7D32") # Darker Green
self.style.configure("Danger.TButton", foreground="#C62828") # Darker Red
self.style.configure(
"Success.TButton", foreground="#2E7D32") # Darker Green
self.style.configure(
"Danger.TButton", foreground="#C62828") # Darker Red
# Custom LabelFrame style for Mount frame
self.style.configure("Mount.TLabelFrame", bordercolor="#0078D7")
self.style.configure("Mount.TLabelFrame.Label", foreground="#0078D7")
# Custom button styles for BackupContentFrame
self.style.configure("Success.TButton", foreground="#2E7D32") # Darker Green
self.style.configure("Danger.TButton", foreground="#C62828") # Darker Red
self.style.configure(
"Success.TButton", foreground="#2E7D32") # Darker Green
self.style.configure(
"Danger.TButton", foreground="#C62828") # Darker Red
self.style.configure("Switch2.TCheckbutton",
background="#2b3e4f", foreground="white")
@@ -435,7 +439,7 @@ class MainApplication(tk.Tk):
restore_dest_folder)
self._process_queue()
self._update_sync_mode_display() # Call after loading state
self.update_backup_options_from_config() # Apply defaults on startup
self.update_backup_options_from_config() # Apply defaults on startup
def _setup_log_window(self):
self.log_frame = ttk.Frame(self.content_frame)
@@ -452,8 +456,8 @@ class MainApplication(tk.Tk):
self.scheduler_frame.grid_remove()
def _setup_settings_frame(self):
self.settings_frame = SettingsFrame(
self.content_frame, self.navigation, self.actions, self.backup_manager.encryption_manager, self.image_manager, self.config_manager, padding=(0, 10))
self.settings_frame = SettingsFrame(self.content_frame, self.navigation, self.actions,
self.backup_manager.encryption_manager, self.image_manager, self.config_manager, padding=(0, 10))
self.settings_frame.grid(row=2, column=0, sticky="nsew")
self.settings_frame.grid_remove()
@@ -552,13 +556,10 @@ class MainApplication(tk.Tk):
self.start_cancel_button.grid(row=0, column=2, rowspan=2, padx=5)
def on_closing(self):
self.config_manager.set_setting(
"refresh_log", self.refresh_log_var.get())
# Attempt to unmount all encrypted drives
# First, always attempt to unmount all encrypted drives
unmount_success = self.backup_manager.encryption_manager.unmount_all()
# Check if any drives are still mounted after the attempt
# If unmounting fails, show an error and prevent the app from closing
if not unmount_success and self.backup_manager.encryption_manager.mounted_destinations:
app_logger.log(
"WARNING: Not all encrypted drives could be unmounted. Preventing application closure.")
@@ -566,6 +567,8 @@ class MainApplication(tk.Tk):
message_type="error", title=Msg.STR["unmount_failed_title"], text=Msg.STR["unmount_failed_message"]).show()
return # Prevent application from closing
self.config_manager.set_setting(
"refresh_log", self.refresh_log_var.get())
self.config_manager.set_setting("last_mode", self.mode)
if self.backup_left_canvas_data.get('path_display'):
@@ -592,6 +595,7 @@ class MainApplication(tk.Tk):
else:
self.config_manager.set_setting("restore_source_path", None)
# Finally, clean up UI resources and destroy the window
if self.left_canvas_animation:
self.left_canvas_animation.stop()
self.left_canvas_animation.destroy()

View File

@@ -77,7 +77,7 @@ class Actions:
self.app.compressed_cb.config(state="normal")
self.app.encrypted_cb.config(state="normal")
self.app.test_run_cb.config(state="normal")
# Apply mutual exclusion rules for Option A
if self.app.compressed_var.get():
self.app.incremental_var.set(False)
@@ -85,7 +85,7 @@ class Actions:
self.app.incremental_cb.config(state="disabled")
self.app.encrypted_var.set(False)
self.app.encrypted_cb.config(state="disabled")
if self.app.incremental_var.get() or self.app.encrypted_var.get():
self.app.compressed_var.set(False)
self.app.compressed_cb.config(state="disabled")
@@ -397,47 +397,6 @@ class Actions:
MessageDialog(message_type="error",
title=Msg.STR["error"], text=Msg.STR["path_not_found"].format(path=path)).show()
def reset_to_default_settings(self):
self.app.config_manager.set_setting("backup_destination_path", None)
self.app.config_manager.set_setting("restore_source_path", None)
self.app.config_manager.set_setting("restore_destination_path", None)
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")
self.app.config_manager.remove_setting("force_incremental_backup")
self.app.config_manager.remove_setting("force_compression")
self.app.config_manager.remove_setting("force_encryption")
self.app.update_backup_options_from_config()
AppConfig.generate_and_write_final_exclude_list()
app_logger.log("Settings have been reset to default values.")
settings_frame = self.app.settings_frame
if settings_frame:
settings_frame.load_and_display_excludes()
settings_frame._load_hidden_files()
self.app.destination_path = None
self.app.start_cancel_button.config(state="disabled")
self.app.backup_left_canvas_data.clear()
self.app.backup_right_canvas_data.clear()
self.app.restore_left_canvas_data.clear()
self.app.restore_right_canvas_data.clear()
current_source = self.app.left_canvas_data.get('folder')
if current_source:
self.on_sidebar_button_click(current_source)
self.app.backup_content_frame.system_backups_frame._load_backup_content()
self.app.backup_content_frame.user_backups_frame._load_backup_content()
with message_box_animation(self.app.animated_icon):
MessageDialog(message_type="info",
title=Msg.STR["settings_reset_title"], text=Msg.STR["settings_reset_text"])
def _parse_size_string_to_bytes(self, size_str: str) -> int:
if not size_str or size_str == Msg.STR["calculating_size"]:
return 0
@@ -524,7 +483,8 @@ class Actions:
self.app.task_progress.stop()
self.app.task_progress.config(mode="determinate", value=0)
self.app._update_info_label(Msg.STR["incremental_calc_cancelled"], color="#E8740C")
self.app._update_info_label(
Msg.STR["incremental_calc_cancelled"], color="#E8740C")
self.app.start_cancel_button.config(text=Msg.STR["start"])
self._set_ui_state(True)
return
@@ -541,12 +501,14 @@ class Actions:
delete_path)
app_logger.log(
Msg.STR["backup_cancelled_and_deleted_msg"])
self.app._update_info_label(Msg.STR["backup_cancelled_and_deleted_msg"])
self.app._update_info_label(
Msg.STR["backup_cancelled_and_deleted_msg"])
else:
self.app.backup_manager.cancel_backup()
app_logger.log(
"Backup cancelled, but directory could not be deleted (path unknown).")
self.app._update_info_label("Backup cancelled, but directory could not be deleted (path unknown).")
self.app._update_info_label(
"Backup cancelled, but directory could not be deleted (path unknown).")
else:
self.app.backup_manager.cancel_backup()
if delete_path:
@@ -557,14 +519,17 @@ class Actions:
shutil.rmtree(delete_path)
app_logger.log(
Msg.STR["backup_cancelled_and_deleted_msg"])
self.app._update_info_label(Msg.STR["backup_cancelled_and_deleted_msg"])
self.app._update_info_label(
Msg.STR["backup_cancelled_and_deleted_msg"])
except Exception as e:
app_logger.log(f"Error deleting backup directory: {e}")
self.app._update_info_label(f"Error deleting backup directory: {e}")
self.app._update_info_label(
f"Error deleting backup directory: {e}")
else:
app_logger.log(
"Backup cancelled, but no path found to delete.")
self.app._update_info_label("Backup cancelled, but no path found to delete.")
self.app._update_info_label(
"Backup cancelled, but no path found to delete.")
if hasattr(self.app, 'current_backup_path'):
self.app.current_backup_path = None

View File

@@ -1,6 +1,7 @@
import tkinter as tk
from tkinter import ttk
import os
import glob
from pathlib import Path
from core.pbp_app_config import AppConfig, Msg
@@ -17,6 +18,7 @@ class AdvancedSettingsFrame(ttk.Frame):
self.config_manager = config_manager
self.app_instance = app_instance
self.current_view_index = 0
self.keyfile_profile_widgets = {}
nav_frame = ttk.Frame(self)
nav_frame.pack(fill=tk.X, padx=10, pady=(0, 5))
@@ -166,28 +168,21 @@ class AdvancedSettingsFrame(ttk.Frame):
self.backup_defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
encryption_note.pack(anchor=tk.W, pady=5)
# --- Keyfile Settings Frame ---
self.keyfile_settings_frame = ttk.LabelFrame(
view_container, text=Msg.STR["automation_settings_title"], padding=10)
keyfile_info_label = ttk.Label(
self.keyfile_settings_frame, text=Msg.STR["keyfile_automation_info"], justify="left", wraplength=750)
keyfile_info_label.pack(fill=tk.X, pady=(5, 15))
key_file_button = ttk.Button(
self.keyfile_settings_frame, text=Msg.STR["create_add_key_file"], command=self._create_key_file)
key_file_button.grid(row=0, column=0, padx=5, pady=5)
scan_button = ttk.Button(self.keyfile_settings_frame, text="Scan Destination for Encrypted Profiles", command=self._populate_keyfile_profiles_view)
scan_button.pack(pady=5)
self.key_file_status_var = tk.StringVar(
value=Msg.STR["key_file_not_created"])
key_file_status_label = ttk.Label(
self.keyfile_settings_frame, textvariable=self.key_file_status_var, foreground="gray")
key_file_status_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
sudoers_info_text = Msg.STR["sudoers_info_text"]
sudoers_info_label = ttk.Label(
self.keyfile_settings_frame, text=sudoers_info_text, justify="left")
sudoers_info_label.grid(
row=1, column=0, columnspan=2, sticky="w", padx=5, pady=5)
self.keyfile_settings_frame.columnconfigure(1, weight=1)
self.keyfile_profiles_frame = ttk.Frame(self.keyfile_settings_frame)
self.keyfile_profiles_frame.pack(fill=tk.BOTH, expand=True, pady=10)
# --- Bottom Buttons (for most views) ---
self.bottom_button_frame = ttk.Frame(self)
self.bottom_button_frame.pack(pady=10)
@@ -206,7 +201,6 @@ class AdvancedSettingsFrame(ttk.Frame):
self._load_animation_settings()
self._load_backup_defaults()
self._load_manual_excludes()
self._update_key_file_status()
self._switch_view(self.current_view_index)
@@ -243,61 +237,140 @@ class AdvancedSettingsFrame(ttk.Frame):
self.config_manager.set_setting("use_trash_bin", use_trash)
self.config_manager.set_setting("no_trash_bin", no_trash)
def _create_key_file(self):
if not self.app_instance.destination_path:
MessageDialog(message_type="error", title="Error",
text="Please select a backup destination first.")
def _populate_keyfile_profiles_view(self):
# Clear existing widgets
for widget in self.keyfile_profiles_frame.winfo_children():
widget.destroy()
self.keyfile_profile_widgets.clear()
destination = self.app_instance.destination_path
if not destination:
MessageDialog(message_type="error", title=Msg.STR["error"],
text=Msg.STR["select_destination_first"]).show()
return
pybackup_dir = os.path.join(
self.app_instance.destination_path, "pybackup")
container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks")
if not os.path.exists(container_path):
MessageDialog(message_type="error", title="Error",
text="No encrypted container found at the destination.")
pybackup_dir = os.path.join(destination, "pybackup")
if not os.path.isdir(pybackup_dir):
ttk.Label(self.keyfile_profiles_frame, text="No 'pybackup' directory found at destination.").pack()
return
search_pattern = os.path.join(pybackup_dir, "pybackup_encrypted_*.luks")
found_containers = glob.glob(search_pattern)
if not found_containers:
ttk.Label(self.keyfile_profiles_frame, text="No encrypted profiles found at destination.").pack()
return
profiles = []
for container in found_containers:
filename = os.path.basename(container)
profile_name = filename.replace("pybackup_encrypted_", "").replace(".luks", "")
if profile_name:
profiles.append(profile_name)
# Master "All" checkbox
all_var = tk.BooleanVar()
all_check = ttk.Checkbutton(self.keyfile_profiles_frame, text="All Profiles", variable=all_var, style="Switch.TCheckbutton",
command=lambda v=all_var: self._on_master_keyfile_toggle(v, profiles))
all_check.pack(anchor="w", padx=5, pady=5)
ttk.Separator(self.keyfile_profiles_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5)
# Individual profile checkboxes
for profile_name in sorted(profiles):
profile_frame = ttk.Frame(self.keyfile_profiles_frame)
profile_frame.pack(fill=tk.X, padx=5, pady=2)
var = tk.BooleanVar()
check = ttk.Checkbutton(profile_frame, text=profile_name, variable=var, style="Switch.TCheckbutton")
check.pack(side=tk.LEFT)
# Use a lambda with default arguments to capture the current values
check.config(command=lambda p=profile_name, v=var: self._on_keyfile_checkbox_toggle(p, v))
status_label = ttk.Label(profile_frame, text="", foreground="gray")
status_label.pack(side=tk.LEFT, padx=10)
self.keyfile_profile_widgets[profile_name] = {"var": var, "status_label": status_label}
self._update_single_keyfile_status(profile_name)
def _on_master_keyfile_toggle(self, var, profiles):
if not var.get():
return # Do nothing on uncheck for safety
password_dialog = PasswordDialog(
self, title="Enter Existing Password", confirm=False, translations=Msg.STR)
self, title=Msg.STR["enter_existing_password_title"], confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password()
if not password:
var.set(False) # Uncheck the master switch
return
for profile_name in profiles:
self._create_key_file_for_profile(profile_name, password)
def _on_keyfile_checkbox_toggle(self, profile_name, var):
if var.get(): # If checked, create key
self._create_key_file_for_profile(profile_name)
else: # If unchecked, delete key
self._delete_key_file_for_profile(profile_name)
def _create_key_file_for_profile(self, profile_name, password=None):
header = self.app_instance.header_frame
destination = self.app_instance.destination_path
if not password:
password_dialog = PasswordDialog(
self, title=f"{Msg.STR['enter_existing_password_title']} for {profile_name}", confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password()
if not password:
self._update_single_keyfile_status(profile_name) # Revert checkbox state
return
key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(
self.app_instance.destination_path, password)
destination, profile_name, password)
if key_file_path:
MessageDialog(message_type="info", title="Success",
text=f"Key file created and added successfully!\nPath: {key_file_path}")
header.show_temporary_message(Msg.STR["keyfile_creation_success"])
else:
MessageDialog(message_type="error", title="Error",
text="Failed to create or add key file. See log for details.")
header.show_temporary_message(Msg.STR["keyfile_creation_failed"])
self._update_single_keyfile_status(profile_name)
self._update_key_file_status()
def _delete_key_file_for_profile(self, profile_name):
header = self.app_instance.header_frame
destination = self.app_instance.destination_path
def _update_key_file_status(self):
if not self.app_instance.destination_path:
self.key_file_status_var.set(
"Key file status unknown (no destination set).")
password_dialog = PasswordDialog(
self, title=f"Password to delete key for {profile_name}", confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password()
if not password:
self._update_single_keyfile_status(profile_name) # Revert checkbox state
return
# Determine the profile_name based on the current source folder
source_name = self.app_instance.left_canvas_data.get('folder')
if not source_name:
# Fallback or handle case where no source is selected, perhaps default to "system" or log an error
# For now, let's assume "system" if no specific folder is selected for keyfile status
profile_name = "system"
else:
profile_name = "system" if source_name == "Computer" else source_name
success = self.app_instance.backup_manager.encryption_manager.delete_key_file(
destination, profile_name, password)
key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(
self.app_instance.destination_path, profile_name)
if os.path.exists(key_file_path):
self.key_file_status_var.set(f"Key file exists: {key_file_path}")
if success:
header.show_temporary_message("Keyfile successfully deleted.")
else:
self.key_file_status_var.set(
"Key file has not been created for this destination.")
header.show_temporary_message("Failed to delete keyfile.")
self._update_single_keyfile_status(profile_name)
def _update_single_keyfile_status(self, profile_name):
widgets = self.keyfile_profile_widgets.get(profile_name)
if not widgets:
return
destination = self.app_instance.destination_path
key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(
destination, profile_name)
if os.path.exists(key_file_path):
widgets["var"].set(True)
widgets["status_label"].config(text="Keyfile exists")
else:
widgets["var"].set(False)
widgets["status_label"].config(text="")
def _switch_view(self, index):
self.current_view_index = index
@@ -309,8 +382,7 @@ class AdvancedSettingsFrame(ttk.Frame):
self.animation_settings_frame.pack_forget()
self.backup_defaults_frame.pack_forget()
# Show/hide the main action buttons based on the view
if index == 1: # Manual Excludes view
if index in [1, 2]:
self.bottom_button_frame.pack_forget()
else:
self.bottom_button_frame.pack(pady=10)
@@ -326,7 +398,7 @@ class AdvancedSettingsFrame(ttk.Frame):
elif index == 2:
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text="")
self._update_key_file_status()
self._populate_keyfile_profiles_view()
elif index == 3:
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text="")
@@ -357,8 +429,11 @@ class AdvancedSettingsFrame(ttk.Frame):
def _delete_manual_exclude(self):
selected_indices = self.manual_excludes_listbox.curselection()
if not selected_indices:
return
for i in reversed(selected_indices):
self.manual_excludes_listbox.delete(i)
self.app_instance.header_frame.show_temporary_message(Msg.STR["delete_success"])
self._apply_changes()
def _reset_animation_settings(self):
@@ -550,13 +625,7 @@ class AdvancedSettingsFrame(ttk.Frame):
f.write(f"{item}\n")
# Show temporary success message in header
self.app_instance.header_frame.show_temporary_message("Settings saved successfully!")
if self.app_instance:
current_source = self.app_instance.left_canvas_data.get('folder')
if current_source:
self.app_instance.actions.on_sidebar_button_click(
current_source)
self.app_instance.header_frame.show_temporary_message(Msg.STR["settings_saved"])
def _cancel_changes(self):
self.pack_forget()

View File

@@ -290,7 +290,21 @@ class Drawing:
elif self.app.is_first_backup:
info_messages.append(Msg.STR["ready_for_first_backup"])
elif self.app.mode == "backup":
# Base message is always shown after calculation in backup mode
info_messages.append(Msg.STR["backup_mode_info"])
# Additionally, show a warning if encryption is on but the drive is not mounted
if self.app.encrypted_var.get():
dest_path = self.app.destination_path
profile_name = self.app.left_canvas_data.get('folder')
is_mounted = False
if dest_path and profile_name:
is_mounted = self.app.backup_manager.encryption_manager.is_mounted(
dest_path, profile_name)
if not is_mounted:
info_messages.append(Msg.STR["encrypted_unmounted_warning"])
else:
info_messages.append(Msg.STR["restore_mode_info"])

View File

@@ -72,18 +72,32 @@ class HeaderFrame(tk.Frame):
self.refresh_status()
def show_temporary_message(self, message: str, duration_ms: int = 4000):
"""Displays a temporary message in the subtitle area."""
# Cancel any previous after job to prevent conflicts
if self._temp_message_after_id:
self.after_cancel(self._temp_message_after_id)
def show_temporary_message(self, message: str, duration_ms: int = 3000):
"""Displays a large, centered, temporary message over the header."""
# If a message is already shown, cancel its removal and destroy it
if hasattr(self, '_temp_message_label') and self._temp_message_label.winfo_exists():
if self._temp_message_after_id:
self.after_cancel(self._temp_message_after_id)
self._temp_message_label.destroy()
self.subtitle_label.config(text=message, fg="#2ECC71") # Green color for success
# Create a new label for the message
self._temp_message_label = tk.Label(
self, # Place it directly in the HeaderFrame
text=message,
font=("Helvetica", 16, "bold"),
fg="#2ECC71", # Green color for success
bg="#455A64" # Same background as header
)
# Place it in the center of the header frame
self._temp_message_label.place(relx=0.5, rely=0.5, anchor="center")
# Schedule its destruction
self._temp_message_after_id = self.after(duration_ms, self._restore_subtitle)
def _restore_subtitle(self):
"""Restores the original subtitle text and color."""
self.subtitle_label.config(text=self.original_subtitle_text, fg="#bdc3c7")
"""Destroys the temporary message label."""
if hasattr(self, '_temp_message_label') and self._temp_message_label.winfo_exists():
self._temp_message_label.destroy()
self._temp_message_after_id = None
def refresh_status(self):

View File

@@ -9,6 +9,7 @@ from core.pbp_app_config import AppConfig, Msg
from pyimage_ui.advanced_settings_frame import AdvancedSettingsFrame
from shared_libs.custom_file_dialog import CustomFileDialog
from shared_libs.message import MessageDialog, PasswordDialog
from shared_libs.logger import app_logger
class SettingsFrame(ttk.Frame):
@@ -45,19 +46,12 @@ class SettingsFrame(ttk.Frame):
self.button_frame, text=Msg.STR["add_to_exclude_list"], command=self._add_to_exclude_list, style="Gray.Toolbutton")
add_to_exclude_button.pack(side=tk.LEFT, padx=5)
ttk.Separator(self.button_frame, orient=tk.VERTICAL).pack(
side=tk.LEFT, ipady=15, padx=5)
reset_button = ttk.Button(
self.button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings, style="Gray.Toolbutton")
reset_button.pack(side=tk.LEFT)
ttk.Separator(self.button_frame, orient=tk.VERTICAL).pack(
side=tk.LEFT, ipady=15, padx=5)
# Right-aligned buttons
hard_reset_button = ttk.Button(
self.button_frame, text=Msg.STR["hard_reset"], command=self._toggle_hard_reset_view, style="Gray.Toolbutton")
self.button_frame, text=Msg.STR["reset"], command=self._toggle_hard_reset_view, style="Gray.Toolbutton")
hard_reset_button.pack(side=tk.LEFT, padx=5)
ttk.Separator(self.button_frame, orient=tk.VERTICAL).pack(
@@ -79,10 +73,6 @@ class SettingsFrame(ttk.Frame):
self.bottom_button_frame, text=Msg.STR["apply"], command=self._apply_changes)
apply_button.pack(side=tk.LEFT, padx=5)
cancel_button = ttk.Button(self.bottom_button_frame, text=Msg.STR["cancel"],
command=lambda: self.navigation.toggle_mode("backup", 0))
cancel_button.pack(side=tk.LEFT, padx=5)
# --- Treeview for file/folder exclusion ---
self.tree_frame = ttk.LabelFrame(
self.trees_container, text=Msg.STR["user_defined_folder_settings"], padding=10)
@@ -121,50 +111,122 @@ class SettingsFrame(ttk.Frame):
self.hidden_tree.bind("<Button-1>", self._toggle_include_status_hidden)
self.hidden_tree_frame.pack_forget() # Initially hidden
# --- Default Reset Frame (initially hidden) ---
self.default_reset_frame = ttk.LabelFrame(
self, text=Msg.STR["default_config_settings"], padding=10)
default_reset_label = ttk.Label(
self.default_reset_frame, text=Msg.STR["default_reset_info"], wraplength=400, justify=tk.CENTER)
default_reset_label.pack(pady=10, expand=True)
# Frame to center the button
default_reset_button_frame = ttk.Frame(self.default_reset_frame)
default_reset_button_frame.pack(pady=5)
reset_button = ttk.Button(
default_reset_button_frame, text=Msg.STR["default_settings"], command=self._reset_to_default_settings)
reset_button.pack() # Centered by default in its own frame
# --- Hard Reset Frame (initially hidden) ---
self.hard_reset_frame = ttk.LabelFrame(
self, text=Msg.STR["full_delete_config_settings"], padding=10)
hard_reset_label = ttk.Label(
self.hard_reset_frame, text=Msg.STR["hard_reset_warning"], wraplength=400, justify=tk.LEFT)
hard_reset_label.pack(pady=10)
self.hard_reset_frame, text=Msg.STR["hard_reset_warning"], wraplength=400, justify=tk.CENTER)
hard_reset_label.pack(pady=10, expand=True)
hard_reset_button_frame = ttk.Frame(self.hard_reset_frame)
hard_reset_button_frame.pack(pady=10)
hard_reset_button_frame.pack(pady=5)
delete_now_button = ttk.Button(
self.delete_now_button = ttk.Button(
hard_reset_button_frame, text=Msg.STR["delete_now"], command=self._perform_hard_reset)
delete_now_button.pack(side=tk.LEFT, padx=5)
self.delete_now_button.pack() # Centered by default
cancel_hard_reset_button = ttk.Button(
hard_reset_button_frame, text=Msg.STR["cancel"], command=self._toggle_hard_reset_view)
cancel_hard_reset_button.pack(side=tk.LEFT, padx=5)
# --- Bottom Cancel Button for Reset View (initially hidden) ---
self.reset_view_cancel_frame = ttk.Frame(self)
self.reset_view_cancel_button = ttk.Button(
self.reset_view_cancel_frame, text=Msg.STR["cancel"], command=self._toggle_hard_reset_view)
self.reset_view_cancel_button.pack(pady=10)
self.hidden_files_visible = False
self.hard_reset_visible = False
# To hold the instance of AdvancedSettingsFrame
self.advanced_settings_frame_instance = None
def _perform_hard_reset(self):
if self.encryption_manager.mounted_destinations:
dialog = PasswordDialog(
self, title=Msg.STR["unlock_backup"], confirm=False, translations=Msg.STR)
password, _ = dialog.get_password()
if not password:
return
success, message = self.encryption_manager.unmount_all_encrypted_drives(
password)
if not success:
MessageDialog(message_type="error", text=message).show()
return
def _reset_to_default_settings(self):
app_instance = self.master.master.master
header = app_instance.header_frame
try:
shutil.rmtree(AppConfig.APP_DIR)
# Restart the application
os.execl(sys.executable, sys.executable, *sys.argv)
self.config_manager.set_setting("backup_destination_path", None)
self.config_manager.set_setting("restore_source_path", None)
self.config_manager.set_setting("restore_destination_path", None)
self.config_manager.remove_setting("backup_animation_type")
self.config_manager.remove_setting("calculation_animation_type")
self.config_manager.remove_setting("force_full_backup")
self.config_manager.remove_setting("force_incremental_backup")
self.config_manager.remove_setting("force_compression")
self.config_manager.remove_setting("force_encryption")
app_instance.update_backup_options_from_config()
AppConfig.generate_and_write_final_exclude_list()
app_logger.log("Settings have been reset to default values.")
self.load_and_display_excludes()
if self.hidden_files_visible:
self._load_hidden_files()
app_instance.destination_path = None
app_instance.start_cancel_button.config(state="disabled")
app_instance.backup_left_canvas_data.clear()
app_instance.backup_right_canvas_data.clear()
app_instance.restore_left_canvas_data.clear()
app_instance.restore_right_canvas_data.clear()
current_source = app_instance.left_canvas_data.get('folder')
if current_source:
self.actions.on_sidebar_button_click(current_source)
app_instance.backup_content_frame.system_backups_frame._load_backup_content()
app_instance.backup_content_frame.user_backups_frame._load_backup_content()
header.show_temporary_message(Msg.STR["default_reset_success"])
except Exception as e:
MessageDialog(message_type="error", text=str(e)).show()
app_logger.log(f"Error resetting settings: {e}")
header.show_temporary_message(Msg.STR["default_reset_failed"])
def _perform_hard_reset(self):
try:
# First, always attempt to unmount all encrypted drives
unmount_success = self.encryption_manager.unmount_all()
# If unmounting fails, show an error and prevent the app from closing
if not unmount_success and self.encryption_manager.mounted_destinations:
app_logger.log(
"WARNING: Not all encrypted drives could be unmounted. Preventing application closure.")
MessageDialog(
message_type="error", title=Msg.STR["unmount_failed_title"], text=Msg.STR["unmount_failed_message"])
return # Prevent application from closing
# Try to delete the config directory without elevated rights
if AppConfig.APP_DIR.exists():
shutil.rmtree(AppConfig.APP_DIR)
app_instance = self.master.master.master
header = app_instance.header_frame
header.show_temporary_message(Msg.STR["hard_reset_success"])
AppConfig.ensure_directories()
self.after(2000, lambda: os.execl(
sys.executable, sys.executable, *sys.argv))
except Exception as e:
MessageDialog(message_type="error",
text=f"Hard reset failed: {e}").show()
def _toggle_hard_reset_view(self):
self.hard_reset_visible = not self.hard_reset_visible
@@ -172,10 +234,16 @@ class SettingsFrame(ttk.Frame):
self.trees_container.pack_forget()
self.button_frame.pack_forget()
self.bottom_button_frame.pack_forget()
self.default_reset_frame.pack(
fill=tk.X, expand=True, padx=10, pady=5)
self.hard_reset_frame.pack(
fill=tk.BOTH, expand=True, padx=10, pady=5)
fill=tk.X, expand=True, padx=10, pady=5)
self.reset_view_cancel_frame.pack(fill=tk.X, side=tk.BOTTOM)
else:
self.default_reset_frame.pack_forget()
self.hard_reset_frame.pack_forget()
self.reset_view_cancel_frame.pack_forget()
self.button_frame.pack(fill=tk.X, padx=10)
self.trees_container.pack(
fill=tk.BOTH, expand=True, padx=10, pady=5)
@@ -338,6 +406,10 @@ class SettingsFrame(ttk.Frame):
if self.hidden_files_visible:
self._load_hidden_files()
# Show success message and stay on the settings page
self.master.master.master.header_frame.show_temporary_message(
Msg.STR["settings_saved"])
def _open_advanced_settings(self):
# Hide main settings UI elements
self.trees_container.pack_forget() # Hide the container for treeviews