Compare commits
24 Commits
4a700194c3
...
4a96fd1547
Author | SHA1 | Date | |
---|---|---|---|
4a96fd1547 | |||
eff7569d71 | |||
d6ead1694c | |||
3b57df2ffa | |||
d7dd4215c0 | |||
22144859d8 | |||
8a70fe2320 | |||
9fd032e9b4 | |||
444650f9f0 | |||
b6a0bb82f1 | |||
fd6bb6cc1b | |||
e932dff8a6 | |||
94a44881e6 | |||
94afeb5d45 | |||
474930e6d0 | |||
a646b9d13a | |||
f11f30ba74 | |||
798134dd20 | |||
95a55a7d4c | |||
a8cbfcb380 | |||
4aa38ab33d | |||
8c6256e94c | |||
dbaa623b17 | |||
73e6e42485 |
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,8 @@
|
||||
# pyimage/core/data_processing.py
|
||||
import os
|
||||
import fnmatch
|
||||
import shutil
|
||||
import re
|
||||
import subprocess
|
||||
from queue import Empty
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from core.pbp_app_config import AppConfig
|
||||
from shared_libs.logger import app_logger
|
||||
|
||||
|
||||
@@ -66,7 +63,6 @@ class DataProcessing:
|
||||
if exclude_patterns is None:
|
||||
exclude_patterns = []
|
||||
|
||||
# Compile exclude patterns into a single regex for performance
|
||||
if exclude_patterns:
|
||||
exclude_regex = re.compile(
|
||||
'|'.join(fnmatch.translate(p) for p in exclude_patterns))
|
||||
@@ -75,7 +71,7 @@ class DataProcessing:
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(path, topdown=True):
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
return
|
||||
|
||||
if exclude_regex:
|
||||
dirnames[:] = [d for d in dirnames if not exclude_regex.match(
|
||||
@@ -83,7 +79,7 @@ class DataProcessing:
|
||||
|
||||
for f in filenames:
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
return
|
||||
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not exclude_regex or not exclude_regex.match(fp):
|
||||
@@ -101,11 +97,11 @@ class DataProcessing:
|
||||
total_size = 0
|
||||
for dirpath, dirnames, filenames in os.walk(path, topdown=True):
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
return
|
||||
|
||||
for f in filenames:
|
||||
if stop_event.is_set():
|
||||
return # Stop the calculation
|
||||
return
|
||||
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not os.path.islink(fp):
|
||||
@@ -116,91 +112,3 @@ class DataProcessing:
|
||||
|
||||
if not stop_event.is_set():
|
||||
self.app.queue.put((button_text, total_size, mode))
|
||||
|
||||
def get_incremental_backup_size(self, source_path: str, dest_path: str, is_system: bool, exclude_files: list = None) -> int:
|
||||
"""
|
||||
Calculates the approximate size of an incremental backup using rsync's dry-run feature.
|
||||
This is much faster than walking the entire file tree.
|
||||
"""
|
||||
app_logger.log(f"Calculating incremental backup size for source: {source_path}")
|
||||
|
||||
parent_dest = os.path.dirname(dest_path)
|
||||
if not os.path.exists(parent_dest):
|
||||
# If the parent destination doesn't exist, there are no previous backups to link to.
|
||||
# In this case, the incremental size is the full size of the source.
|
||||
# We can use the existing full-size calculation method.
|
||||
# This is a simplified approach for the estimation.
|
||||
# A more accurate one would run rsync without --link-dest.
|
||||
app_logger.log("Destination parent does not exist, cannot calculate incremental size. Returning 0.")
|
||||
return 0
|
||||
|
||||
# Find the latest backup to link against
|
||||
try:
|
||||
backups = sorted([d for d in os.listdir(parent_dest) if os.path.isdir(os.path.join(parent_dest, d))], reverse=True)
|
||||
if not backups:
|
||||
app_logger.log("No previous backups found. Incremental size is full size.")
|
||||
return 0 # Or trigger a full size calculation
|
||||
latest_backup_path = os.path.join(parent_dest, backups[0])
|
||||
except FileNotFoundError:
|
||||
app_logger.log("Could not list backups, assuming no prior backups exist.")
|
||||
return 0
|
||||
|
||||
|
||||
command = []
|
||||
if is_system:
|
||||
command.extend(['pkexec', 'rsync', '-aAXHvn', '--stats'])
|
||||
else:
|
||||
command.extend(['rsync', '-avn', '--stats'])
|
||||
|
||||
command.append(f"--link-dest={latest_backup_path}")
|
||||
|
||||
if exclude_files:
|
||||
for exclude_file in exclude_files:
|
||||
command.append(f"--exclude-from={exclude_file}")
|
||||
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
command.append(f"--exclude-from={AppConfig.MANUAL_EXCLUDE_LIST_PATH}")
|
||||
|
||||
# The destination for a dry run can be a dummy path, but it must exist.
|
||||
# Let's use a temporary directory.
|
||||
dummy_dest = os.path.join(parent_dest, "dry_run_dest")
|
||||
os.makedirs(dummy_dest, exist_ok=True)
|
||||
|
||||
command.extend([source_path, dummy_dest])
|
||||
|
||||
app_logger.log(f"Executing rsync dry-run command: {' '.join(command)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
|
||||
# Clean up the dummy directory
|
||||
shutil.rmtree(dummy_dest)
|
||||
|
||||
if result.returncode != 0:
|
||||
app_logger.log(f"Rsync dry-run failed with code {result.returncode}: {result.stderr}")
|
||||
return 0
|
||||
|
||||
output = result.stdout + "\n" + result.stderr
|
||||
# The regex now accepts dots as thousands separators (e.g., 1.234.567).
|
||||
match = re.search(r"Total transferred file size: ([\d,.]+) bytes", output)
|
||||
if match:
|
||||
# Remove both dots and commas before converting to an integer.
|
||||
size_str = match.group(1).replace(',', '').replace('.', '')
|
||||
size_bytes = int(size_str)
|
||||
app_logger.log(f"Estimated incremental backup size: {size_bytes} bytes")
|
||||
return size_bytes
|
||||
else:
|
||||
app_logger.log("Could not find 'Total transferred file size' in rsync output.")
|
||||
# Log the output just in case something changes in the future
|
||||
app_logger.log(f"Full rsync output for debugging:\n{output}")
|
||||
return 0
|
||||
|
||||
except FileNotFoundError:
|
||||
app_logger.log("Error: 'rsync' or 'pkexec' command not found.")
|
||||
return 0
|
||||
except Exception as e:
|
||||
app_logger.log(f"An unexpected error occurred during incremental size calculation: {e}")
|
||||
return 0
|
||||
|
||||
# The queue processing logic has been moved to main_app.py
|
||||
# to fix a race condition and ensure all queue messages are handled correctly.
|
||||
|
@@ -1,16 +1,16 @@
|
||||
|
||||
import keyring
|
||||
import keyring.errors
|
||||
from keyring.backends import SecretService
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import stat
|
||||
import re
|
||||
from typing import Optional, List
|
||||
import math
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from core.pbp_app_config import AppConfig
|
||||
from pyimage_ui.password_dialog import PasswordDialog
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.message import PasswordDialog
|
||||
import json
|
||||
|
||||
|
||||
class EncryptionManager:
|
||||
@@ -22,32 +22,44 @@ class EncryptionManager:
|
||||
self.logger = logger
|
||||
self.app = app
|
||||
self.service_id = "py-backup-encryption"
|
||||
self.session_password = None
|
||||
self.mounted_destinations = set()
|
||||
self.auth_method = None
|
||||
self.is_mounting = False
|
||||
self.password_cache = {}
|
||||
self.lock_file = AppConfig.LOCK_FILE_PATH
|
||||
|
||||
def get_key_file_path(self, base_dest_path: str) -> str:
|
||||
"""Generates the standard path for the key file for a given destination."""
|
||||
key_filename = f"keyfile_{os.path.basename(base_dest_path.rstrip('/'))}.key"
|
||||
return os.path.join(AppConfig.CONFIG_DIR, key_filename)
|
||||
def _write_lock_file(self, data):
|
||||
with open(self.lock_file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
def _read_lock_file(self):
|
||||
if not self.lock_file.exists():
|
||||
return []
|
||||
with open(self.lock_file, 'r') as f:
|
||||
try:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def add_to_lock_file(self, base_path, mapper_name):
|
||||
locks = self._read_lock_file()
|
||||
if not any(lock['base_path'] == base_path for lock in locks):
|
||||
locks.append({"base_path": base_path, "mapper_name": mapper_name})
|
||||
self._write_lock_file(locks)
|
||||
|
||||
def remove_from_lock_file(self, base_path):
|
||||
locks = self._read_lock_file()
|
||||
updated_locks = [
|
||||
lock for lock in locks if lock['base_path'] != base_path]
|
||||
self._write_lock_file(updated_locks)
|
||||
|
||||
def get_password_from_keyring(self, username: str) -> Optional[str]:
|
||||
try:
|
||||
return keyring.get_password(self.service_id, username)
|
||||
except keyring.errors.InitError as e:
|
||||
self.logger.log(f"Could not initialize keyring: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not get password from keyring: {e}")
|
||||
return None
|
||||
|
||||
def is_key_in_keyring(self, username: str) -> bool:
|
||||
try:
|
||||
return self.get_password_from_keyring(username) is not None
|
||||
except Exception as e:
|
||||
self.logger.log(f"Could not check password in keyring: {e}")
|
||||
return False
|
||||
return self.get_password_from_keyring(username) is not None
|
||||
|
||||
def set_password_in_keyring(self, username: str, password: str) -> bool:
|
||||
try:
|
||||
@@ -59,279 +71,315 @@ class EncryptionManager:
|
||||
return False
|
||||
|
||||
def get_password(self, username: str, confirm: bool = True) -> Optional[str]:
|
||||
if self.session_password:
|
||||
return self.session_password
|
||||
|
||||
if username in self.password_cache:
|
||||
return self.password_cache[username]
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.session_password = password
|
||||
self.password_cache[username] = password
|
||||
return password
|
||||
|
||||
dialog = PasswordDialog(
|
||||
self.app, title=f"Enter password for {username}", confirm=confirm)
|
||||
self.app, title=f"Enter password for {username}", confirm=confirm, translations=Msg.STR)
|
||||
password, save_to_keyring = dialog.get_password()
|
||||
if password and save_to_keyring:
|
||||
self.set_password_in_keyring(username, password)
|
||||
|
||||
if password:
|
||||
self.session_password = password
|
||||
|
||||
self.password_cache[username] = password
|
||||
if save_to_keyring:
|
||||
self.set_password_in_keyring(username, password)
|
||||
return password
|
||||
|
||||
def get_container_path(self, base_dest_path: str) -> str:
|
||||
"""Returns the path for the LUKS container file itself."""
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "pybackup_encrypted.luks")
|
||||
|
||||
def get_key_file_path(self, base_dest_path: str) -> str:
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "luks.keyfile")
|
||||
|
||||
def create_and_add_key_file(self, base_dest_path: str, password: str) -> Optional[str]:
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
container_path = self.get_container_path(base_dest_path)
|
||||
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
|
||||
|
||||
def _get_password_or_key_cmd(self, base_dest_path: str, username: str) -> Tuple[str, Optional[str]]:
|
||||
# 1. Check cache and keyring (without triggering dialog)
|
||||
password = self.password_cache.get(
|
||||
username) or self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.logger.log(
|
||||
"Using password from cache or keyring for LUKS operation.")
|
||||
self.password_cache[username] = password # ensure it's cached
|
||||
return "-", password
|
||||
|
||||
# 2. Check for key file
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.logger.log(
|
||||
f"Using key file for LUKS operation: {key_file_path}")
|
||||
return f'--key-file "{key_file_path}"'
|
||||
|
||||
# 3. If nothing found, prompt for password
|
||||
self.logger.log(
|
||||
"No password in keyring and no keyfile found. Prompting user.")
|
||||
# This will now definitely open the dialog
|
||||
password = self.get_password(username, confirm=False)
|
||||
if not password:
|
||||
return "", None
|
||||
return "-", password
|
||||
|
||||
def is_encrypted(self, base_dest_path: str) -> bool:
|
||||
if os.path.basename(base_dest_path) == "pybackup":
|
||||
pybackup_dir = base_dest_path
|
||||
else:
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
return os.path.exists(container_path)
|
||||
return os.path.exists(self.get_container_path(base_dest_path))
|
||||
|
||||
def get_mount_point(self, base_dest_path: str) -> str:
|
||||
"""Constructs the unique, static mount point path for a given destination."""
|
||||
return os.path.join(base_dest_path, "pybackup", "encrypted")
|
||||
|
||||
def is_mounted(self, base_dest_path: str) -> bool:
|
||||
if os.path.basename(base_dest_path) == "pybackup":
|
||||
pybackup_dir = base_dest_path
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
return os.path.ismount(mount_point) or base_dest_path in self.mounted_destinations
|
||||
|
||||
def mount_for_deletion(self, base_dest_path: str, is_system: bool, password: str) -> Optional[str]:
|
||||
self.logger.log("Mounting container for deletion operation.")
|
||||
if self._open_and_mount(base_dest_path, is_system, password):
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
self.logger.log("Failed to mount container for deletion.")
|
||||
return None
|
||||
|
||||
def prepare_encrypted_destination(self, base_dest_path: str, is_system: bool, source_size: int, queue) -> Optional[str]:
|
||||
container_path = self.get_container_path(base_dest_path)
|
||||
if os.path.exists(container_path):
|
||||
return self._handle_existing_container(base_dest_path, is_system, source_size, queue)
|
||||
else:
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
return os.path.ismount(mount_point)
|
||||
return self._handle_new_container(base_dest_path, is_system, source_size, queue)
|
||||
|
||||
def mount(self, base_dest_path: str, queue=None, size_gb: int = 0) -> Optional[str]:
|
||||
if self.is_mounting:
|
||||
self.logger.log("Mount process already in progress. Aborting new request.")
|
||||
return None
|
||||
def _handle_existing_container(self, base_dest_path: str, is_system: bool, source_size: int, queue) -> Optional[str]:
|
||||
self.logger.log("Handling existing container.")
|
||||
|
||||
self.is_mounting = True
|
||||
try:
|
||||
if self.is_mounted(base_dest_path):
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
return os.path.join(pybackup_dir, "encrypted")
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
|
||||
# Use a dummy queue if none is provided
|
||||
if queue is None:
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
# 1. Try keyring
|
||||
password = self.get_password_from_keyring(username)
|
||||
if password:
|
||||
self.logger.log("Found password in keyring. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "keyring"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
else:
|
||||
# If mounting with keyring key fails, stop here and report error.
|
||||
self.logger.log("Mounting with keyring password failed. Aborting mount attempt.")
|
||||
return None
|
||||
|
||||
# 2. Try key file
|
||||
key_file_path = self.get_key_file_path(base_dest_path)
|
||||
if os.path.exists(key_file_path):
|
||||
self.logger.log(f"Found key file at {key_file_path}. Attempting to mount.")
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, key_file=key_file_path)
|
||||
if mount_point:
|
||||
self.auth_method = "keyfile"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
# 3. Prompt for password
|
||||
self.logger.log("No password in keyring or key file found. Prompting user.")
|
||||
password = self.get_password(username, confirm=False)
|
||||
if not password:
|
||||
self.logger.log("No password provided, cannot mount container.")
|
||||
self.auth_method = None
|
||||
if not self.is_mounted(base_dest_path):
|
||||
if not self._open_and_mount(base_dest_path, is_system):
|
||||
self.logger.log("Failed to mount container for size check.")
|
||||
return None
|
||||
|
||||
mount_point = self._setup_encrypted_backup(queue, base_dest_path, size_gb, password=password)
|
||||
if mount_point:
|
||||
self.auth_method = "password"
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
finally:
|
||||
self.is_mounting = False
|
||||
free_space = shutil.disk_usage(mount_point).free
|
||||
required_space = int(source_size * 1.15)
|
||||
|
||||
if required_space > free_space:
|
||||
self.logger.log(
|
||||
f"Resize needed. Free: {free_space}, Required: {required_space}")
|
||||
queue.put(('status_update', "Container zu klein. Vergrößere..."))
|
||||
|
||||
current_total = shutil.disk_usage(mount_point).total
|
||||
needed_additional = required_space - free_space
|
||||
new_total_size = current_total + needed_additional
|
||||
|
||||
container_path = self.get_container_path(base_dest_path)
|
||||
mapper_name = f"pybackup_luks_{username}"
|
||||
chown_cmd = self._get_chown_command(mount_point, is_system)
|
||||
|
||||
key_or_pass_arg, password = self._get_password_or_key_cmd(
|
||||
base_dest_path, username)
|
||||
if not key_or_pass_arg:
|
||||
return None
|
||||
luks_open_cmd = f'echo -n "$LUKSPASS" | cryptsetup luksOpen "{container_path}" {mapper_name} {key_or_pass_arg}' if password else f'cryptsetup luksOpen "{container_path}" {mapper_name} {key_or_pass_arg}'
|
||||
|
||||
resize_script = f"""
|
||||
# Unmount cleanly first
|
||||
umount -l \"{mount_point}\" || true
|
||||
cryptsetup luksClose {mapper_name} || true
|
||||
|
||||
# Resize container file
|
||||
truncate -s {int(new_total_size)} \"{container_path}\"
|
||||
|
||||
# Re-open, check, and resize filesystem
|
||||
{luks_open_cmd}
|
||||
e2fsck -fy \"/dev/mapper/{mapper_name}\"
|
||||
resize2fs \"/dev/mapper/{mapper_name}\"
|
||||
|
||||
# Now mount it
|
||||
mount \"/dev/mapper/{mapper_name}\" \"{mount_point}\"
|
||||
{chown_cmd}
|
||||
"""
|
||||
|
||||
if not self._execute_as_root(resize_script, password):
|
||||
self.logger.log("Failed to execute resize and remount script.")
|
||||
return None
|
||||
|
||||
if not self.is_mounted(base_dest_path):
|
||||
self.logger.log(
|
||||
"CRITICAL: Mount failed after resize script, but script reported success. Aborting.")
|
||||
return None
|
||||
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
def _handle_new_container(self, base_dest_path: str, is_system: bool, source_size: int, queue) -> Optional[str]:
|
||||
self.logger.log("Handling new container creation.")
|
||||
size_gb = math.ceil((source_size * 1.2) / (1024**3)) + 5
|
||||
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
password = self.get_password(username, confirm=True)
|
||||
if not password:
|
||||
return None
|
||||
|
||||
container_path = self.get_container_path(base_dest_path)
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
mapper_name = f"pybackup_luks_{username}"
|
||||
chown_cmd = self._get_chown_command(mount_point, is_system)
|
||||
|
||||
script = f"""
|
||||
mkdir -p \"{os.path.dirname(container_path)}\"\n mkdir -p \"{mount_point}\"\n truncate -s {int(size_gb)}G \"{container_path}\"\n echo -n \"$LUKSPASS\" | cryptsetup luksFormat \"{container_path}\" -
|
||||
echo -n \"$LUKSPASS\" | cryptsetup luksOpen \"{container_path}\" {mapper_name} -
|
||||
mkfs.ext4 \"/dev/mapper/{mapper_name}\"
|
||||
mount \"/dev/mapper/{mapper_name}\" \"{mount_point}\"\n {chown_cmd}\n """
|
||||
if not self._execute_as_root(script, password):
|
||||
return None
|
||||
|
||||
self.mounted_destinations.add(base_dest_path)
|
||||
return mount_point
|
||||
|
||||
def _open_and_mount(self, base_dest_path: str, is_system: bool, password_override: Optional[str] = None) -> bool:
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
|
||||
key_or_pass_arg, password = "", None
|
||||
if password_override:
|
||||
password = password_override
|
||||
key_or_pass_arg = "-"
|
||||
else:
|
||||
key_or_pass_arg, password = self._get_password_or_key_cmd(
|
||||
base_dest_path, username)
|
||||
|
||||
if not key_or_pass_arg:
|
||||
return False
|
||||
|
||||
container_path = self.get_container_path(base_dest_path)
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
mapper_name = f"pybackup_luks_{username}"
|
||||
chown_cmd = self._get_chown_command(mount_point, is_system)
|
||||
|
||||
luks_open_cmd = f'echo -n \"$LUKSPASS\" | cryptsetup luksOpen \"{container_path}\" {mapper_name} {key_or_pass_arg}' if password else f'cryptsetup luksOpen \"{container_path}\" {mapper_name} {key_or_pass_arg}'
|
||||
|
||||
script = f"""
|
||||
umount -l \"{mount_point}\" || true
|
||||
cryptsetup luksClose {mapper_name} || true
|
||||
mkdir -p \"{mount_point}\"
|
||||
{luks_open_cmd}
|
||||
mount \"/dev/mapper/{mapper_name}\" \"{mount_point}\"
|
||||
{chown_cmd}
|
||||
"""
|
||||
if self._execute_as_root(script, password):
|
||||
self.add_to_lock_file(base_dest_path, mapper_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def unmount_and_reset_owner(self, base_dest_path: str, force_unmap=False):
|
||||
username = os.path.basename(base_dest_path.rstrip('/'))
|
||||
mapper_name = f"pybackup_luks_{username}"
|
||||
if not os.path.exists(f"/dev/mapper/{mapper_name}") and not self.is_mounted(base_dest_path):
|
||||
if not force_unmap:
|
||||
return
|
||||
|
||||
self.logger.log(f"Unmounting and resetting owner for {base_dest_path}")
|
||||
mount_point = self.get_mount_point(base_dest_path)
|
||||
|
||||
script = f"""
|
||||
chown root:root \"{mount_point}\" || true
|
||||
umount -l \"{mount_point}\"
|
||||
cryptsetup luksClose {mapper_name}
|
||||
"""
|
||||
password = self.password_cache.get(username)
|
||||
self._execute_as_root(script, password)
|
||||
self.remove_from_lock_file(base_dest_path)
|
||||
|
||||
def unmount(self, base_dest_path: str):
|
||||
if base_dest_path in self.mounted_destinations:
|
||||
self._unmount_encrypted_backup(base_dest_path)
|
||||
self.mounted_destinations.remove(base_dest_path)
|
||||
if username in self.password_cache:
|
||||
del self.password_cache[username]
|
||||
|
||||
def unmount_all(self):
|
||||
self.logger.log(f"Unmounting all mounted backup destinations: {self.mounted_destinations}")
|
||||
# Create a copy for safe iteration
|
||||
self.logger.log(f"Unmounting all: {self.mounted_destinations}")
|
||||
for path in list(self.mounted_destinations):
|
||||
self.unmount(path)
|
||||
self.unmount_and_reset_owner(path, force_unmap=True)
|
||||
|
||||
def _unmount_encrypted_backup(self, base_dest_path: str):
|
||||
""" Gently unmounts the container without destroying LVM structures. """
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
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"Unmounting encrypted LVM backup for {base_dest_path}")
|
||||
find_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
script = f"""
|
||||
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing..."
|
||||
fi
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
vgchange -an {vg_name} || echo "Could not deactivate volume group {vg_name}."
|
||||
fi
|
||||
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):
|
||||
self.logger.log("Encrypted LVM backup unmount script failed.")
|
||||
def unmount_all_encrypted_drives(self, password: str) -> Tuple[bool, str]:
|
||||
for path in list(self.mounted_destinations):
|
||||
self.unmount_and_reset_owner(path, force_unmap=True)
|
||||
return True, "Successfully unmounted all drives."
|
||||
|
||||
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 LVM-based encrypted container at {base_dest_path}")
|
||||
def _get_chown_command(self, mount_point: str, is_system: bool) -> str:
|
||||
if not is_system:
|
||||
try:
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
return f"chown {uid}:{gid} \"{mount_point}\""
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Could not get current user UID/GID for chown: {e}")
|
||||
return ""
|
||||
|
||||
for tool in ["cryptsetup", "losetup", "pvcreate", "vgcreate", "lvcreate", "lvextend", "resize2fs"]:
|
||||
if not shutil.which(tool):
|
||||
self.logger.log(f"Error: Required tool '{tool}' is not installed.")
|
||||
queue.put(('error', f"Required tool '{tool}' is not installed."))
|
||||
return None
|
||||
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
encrypted_dir = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
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:
|
||||
self.logger.log("No password or key file provided for encryption.")
|
||||
queue.put(('error', "No password or key file provided for encryption."))
|
||||
return None
|
||||
|
||||
if os.path.ismount(mount_point):
|
||||
self.logger.log(f"Mount point {mount_point} already in use. Assuming it's correctly mounted.")
|
||||
return mount_point
|
||||
|
||||
if os.path.exists(container_path):
|
||||
auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
|
||||
script = f"""
|
||||
mkdir -p {user_encrypt_dir}
|
||||
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.")
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
return None
|
||||
self.logger.log(f"Encrypted LVM container unlocked and mounted at {mount_point}")
|
||||
return mount_point
|
||||
else:
|
||||
format_auth_part = f"echo -n '{password}' | cryptsetup luksFormat {lv_path} -" if password else f"cryptsetup luksFormat {lv_path} --key-file {key_file}"
|
||||
open_auth_part = f"echo -n '{password}' | cryptsetup luksOpen {lv_path} {mapper_name} -" if password else f"cryptsetup luksOpen {lv_path} {mapper_name} --key-file {key_file}"
|
||||
script = f"""
|
||||
mkdir -p {encrypted_dir}
|
||||
mkdir -p {user_encrypt_dir}
|
||||
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}
|
||||
{open_auth_part}
|
||||
mkfs.ext4 /dev/mapper/{mapper_name}
|
||||
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 create and setup LVM-based encrypted container.")
|
||||
self._destroy_encrypted_structures(base_dest_path)
|
||||
if os.path.exists(container_path):
|
||||
self._execute_as_root(f"rm -f {container_path}")
|
||||
queue.put(('error', "Failed to setup LVM-based encrypted container."))
|
||||
return None
|
||||
self.logger.log(f"Encrypted LVM container is ready and mounted at {mount_point}")
|
||||
return mount_point
|
||||
|
||||
def _destroy_encrypted_structures(self, base_dest_path: str):
|
||||
pybackup_dir = os.path.join(base_dest_path, "pybackup")
|
||||
user_encrypt_dir = os.path.join(pybackup_dir, "user_encrypt")
|
||||
mount_point = os.path.join(pybackup_dir, "encrypted")
|
||||
container_path = os.path.join(user_encrypt_dir, "pybackup_lvm.img")
|
||||
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_loop_device_cmd = f"losetup -j {container_path} | cut -d: -f1"
|
||||
lv_name = "backup_lv"
|
||||
lv_path = f"/dev/{vg_name}/{lv_name}"
|
||||
|
||||
script = f"""
|
||||
set -x # Log executed commands
|
||||
LOOP_DEVICE=$({find_loop_device_cmd} | head -n 1)
|
||||
if mountpoint -q {mount_point}; then
|
||||
umount {mount_point} || echo "Umount failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -e /dev/mapper/{mapper_name} ]; then
|
||||
cryptsetup luksClose {mapper_name} || echo "luksClose failed, continuing cleanup..."
|
||||
fi
|
||||
# Deactivate and remove all LVM structures associated with the VG
|
||||
if vgdisplay {vg_name} >/dev/null 2>&1; then
|
||||
lvchange -an {lv_path} >/dev/null 2>&1 || echo "lvchange failed, continuing..."
|
||||
vgchange -an {vg_name} || echo "vgchange -an failed, continuing cleanup..."
|
||||
lvremove -f {vg_name} || echo "lvremove failed, continuing cleanup..."
|
||||
vgremove -f {vg_name} || echo "vgremove failed, continuing cleanup..."
|
||||
fi
|
||||
if [ -n "$LOOP_DEVICE" ] && losetup $LOOP_DEVICE >/dev/null 2>&1; then
|
||||
pvremove -f $LOOP_DEVICE || echo "pvremove failed, continuing cleanup..."
|
||||
losetup -d $LOOP_DEVICE || echo "losetup -d failed, continuing cleanup..."
|
||||
fi
|
||||
"""
|
||||
if not self._execute_as_root(script):
|
||||
self.logger.log("Encrypted LVM backup cleanup script failed.")
|
||||
|
||||
def _execute_as_root(self, script_content: str) -> bool:
|
||||
script_path = ''
|
||||
def _execute_as_root(self, script_content: str, password_for_stdin: Optional[str] = None) -> bool:
|
||||
try:
|
||||
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")
|
||||
tmp_script.write(script_content)
|
||||
script_path = tmp_script.name
|
||||
if password_for_stdin:
|
||||
escaped_password = password_for_stdin.replace("'", "'\\\''")
|
||||
script_content = f"LUKSPASS='{escaped_password}'\n{script_content}"
|
||||
|
||||
os.chmod(script_path, stat.S_IRWXU | stat.S_IRGRP |
|
||||
stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
project_root = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), '..'))
|
||||
runner_script_path = os.path.join(
|
||||
project_root, 'core', 'privileged_script_runner.sh')
|
||||
|
||||
command = ['pkexec', script_path]
|
||||
if not os.path.exists(runner_script_path):
|
||||
self.logger.log(
|
||||
f"CRITICAL: Privileged script runner not found at {runner_script_path}")
|
||||
return False
|
||||
|
||||
command = ['pkexec', runner_script_path]
|
||||
|
||||
log_lines = []
|
||||
for line in script_content.split('\n'):
|
||||
if "LUKSPASS=" in line:
|
||||
log_lines.append("LUKSPASS='[REDACTED]'")
|
||||
else:
|
||||
log_lines.append(line)
|
||||
sanitized_script_content = "\n".join(log_lines)
|
||||
|
||||
sanitized_script_content = re.sub(
|
||||
r"echo -n \'.*?\'", "echo -n \'[REDACTED]\'", script_content)
|
||||
self.logger.log(
|
||||
f"Executing privileged command via script: {script_path}")
|
||||
f"Executing privileged command via runner: {runner_script_path}")
|
||||
self.logger.log(
|
||||
f"Script content:\n---\n{sanitized_script_content}\n---")
|
||||
f"Script content to be piped:\n---\n{sanitized_script_content}\n---")
|
||||
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=False)
|
||||
command, input=script_content, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.log(
|
||||
f"Privileged script executed successfully. Output:\n{result.stdout}")
|
||||
log_output = f"Privileged script executed successfully."
|
||||
if result.stdout:
|
||||
log_output += f"\nStdout:\n{result.stdout}"
|
||||
if result.stderr:
|
||||
log_output += f"\nStderr:\n{result.stderr}"
|
||||
self.logger.log(log_output)
|
||||
return True
|
||||
else:
|
||||
self.logger.log(
|
||||
@@ -341,6 +389,3 @@ class EncryptionManager:
|
||||
self.logger.log(
|
||||
f"Failed to set up or execute privileged command: {e}")
|
||||
return False
|
||||
finally:
|
||||
if script_path and os.path.exists(script_path):
|
||||
os.remove(script_path)
|
@@ -10,13 +10,13 @@ class AppConfig:
|
||||
|
||||
# --- Core Paths ---
|
||||
BASE_DIR: Path = Path.home()
|
||||
CONFIG_DIR: Path = BASE_DIR / ".config/lx_pyimage"
|
||||
SETTINGS_FILE: Path = CONFIG_DIR / "settings.json"
|
||||
GENERATED_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / "rsync-generated-excludes.conf"
|
||||
USER_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
|
||||
"rsync-user-excludes.conf" # Single file
|
||||
MANUAL_EXCLUDE_LIST_PATH: Path = CONFIG_DIR / \
|
||||
"rsync-manual-excludes.conf" # Single file
|
||||
APP_DIR: Path = BASE_DIR / ".config/py_backup"
|
||||
SETTINGS_FILE: Path = APP_DIR / "pbp_settings.json"
|
||||
GENERATED_EXCLUDE_LIST_PATH: Path = APP_DIR / "rsync-generated-excludes.conf"
|
||||
USER_EXCLUDE_LIST_PATH: Path = APP_DIR / "user_excludes.txt"
|
||||
MANUAL_EXCLUDE_LIST_PATH: Path = APP_DIR / "manual_excludes.txt"
|
||||
LOG_FILE_PATH: Path = APP_DIR / "py-backup.log"
|
||||
LOCK_FILE_PATH: Path = APP_DIR / "pybackup.lock"
|
||||
APP_ICONS_DIR: Path = Path(__file__).parent / "lx-icons"
|
||||
|
||||
# --- Application Info ---
|
||||
@@ -125,8 +125,8 @@ class AppConfig:
|
||||
@classmethod
|
||||
def ensure_directories(cls) -> None:
|
||||
"""Ensures that all required application directories exist."""
|
||||
if not cls.CONFIG_DIR.exists():
|
||||
cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not cls.APP_DIR.exists():
|
||||
cls.APP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# In the future, we can create a default settings file here
|
||||
|
||||
# Generate/update the final exclude list on every start
|
||||
@@ -227,13 +227,14 @@ class Msg:
|
||||
"warning_not_enough_space": _("WARNING: Not enough space for the backup.\nPlease free up space or choose another location."),
|
||||
"warning_space_over_90_percent": _("WARNING: The storage space will be over 90% full. Backup at your own risk!"),
|
||||
"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."),
|
||||
"restore_mode_info": _("Restore Mode: You can start a restore here."),
|
||||
"advanced_settings_title": _("Advanced Settings"),
|
||||
"animation_settings_title": _("Animation Settings"),
|
||||
"backup_animation_label": _("Backup/Restore Animation:"),
|
||||
"calc_animation_label": _("Size Calculation Animation:"),
|
||||
"advanced_settings_warning": _("WARNING: Changing these settings is recommended for experienced users only. Incorrect configurations can lead to an unreliable backup.\n\nThe backup destination is always excluded for security reasons and cannot be changed here."),
|
||||
"advanced_settings_warning": _("WARNING: Changing these settings is recommended for experienced users only. Incorrect configurations can lead to an unreliable backup.\nThe backup destination is always excluded for security reasons and cannot be changed here."),
|
||||
"exclude_system_folders": _("Exclude system folders"),
|
||||
"in_backup": _("In Backup"),
|
||||
"name": _("Name"),
|
||||
@@ -255,6 +256,7 @@ class Msg:
|
||||
"log": _("Log"),
|
||||
"full_backup": _("Full backup"),
|
||||
"incremental": _("Incremental"),
|
||||
"incremental_backup": _("Incremental backup"), # New
|
||||
"test_run": _("Test run"),
|
||||
"start": _("Start"),
|
||||
"cancel_backup": _("Cancel"),
|
||||
@@ -264,12 +266,12 @@ class Msg:
|
||||
"backup_finished_with_warnings": _("Backup finished with warnings. See log for details."),
|
||||
"backup_failed": _("Backup failed. See log for details."),
|
||||
"backup_cancelled_by_user": _("Backup was cancelled by the user."),
|
||||
"accurate_size_cb_label": _("Accurate inkrem. size"),
|
||||
"accurate_size_info_label": _("(Calculation may take longer)"),
|
||||
"accurate_size_success": _("Accurate size calculated successfully."),
|
||||
"accurate_size_failed": _("Failed to calculate size. See log for details."),
|
||||
"incremental_size_cb_label": _("Inkrem. size"),
|
||||
"incremental_size_info_label": _("(Calculation may take longer)"),
|
||||
"incremental_size_success": _("Incremental size calculated successfully."),
|
||||
"incremental_size_failed": _("Failed to calculate size. See log for details."),
|
||||
"please_wait": _("Please wait, calculating..."),
|
||||
"accurate_calc_cancelled": _("Calculate size cancelled."),
|
||||
"incremental_calc_cancelled": _("Calculate size cancelled."),
|
||||
"add_to_exclude_list": _("Add to exclude list"),
|
||||
"exclude_dialog_text": _("Do you want to add a folder or a file?"),
|
||||
"add_folder_button": _("Folder"),
|
||||
@@ -277,6 +279,9 @@ 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"
|
||||
f"user ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py"),
|
||||
|
||||
# Menus
|
||||
"file_menu": _("File"),
|
||||
@@ -345,22 +350,39 @@ class Msg:
|
||||
"header_subtitle": _("Simple GUI for rsync"),
|
||||
"encrypted_backup_content": _("Encrypted Backups"),
|
||||
"compressed": _("Compressed"),
|
||||
"compression": _("Compression"), # New
|
||||
"encrypted": _("Encrypted"),
|
||||
"encryption": _("Encryption"), # New
|
||||
"bypass_security": _("Bypass security"),
|
||||
"refresh_log": _("Refresh log"),
|
||||
"comment": _("Kommentar"),
|
||||
"force_full_backup": _("Always force full backup"),
|
||||
"force_incremental_backup": _("Always force incremental backup (except first)"),
|
||||
"force_compression": _("Always compress backup"),
|
||||
"force_encryption": _("Always encrypt backup"),
|
||||
"use_trash_bin": _("Papierkorb verwenden"),
|
||||
"no_trash_bin": _("Kein Papierkorb (reine Synchronisierung)"),
|
||||
"sync_mode_pure_sync": _("Synchronisierungsmodus: Reine Synchronisierung (Dateien werden gelöscht)"),
|
||||
"sync_mode_trash_bin": _("Synchronisierungsmodus: Papierkorb verwenden (Gelöschte Dateien werden verschoben)"),
|
||||
"sync_mode_no_delete": _("Synchronisierungsmodus: Keine Löschung (Dateien bleiben im Ziel)"),
|
||||
"use_trash_bin": _("Archive outdated files in a trash bin"),
|
||||
"no_trash_bin": _("Permanently delete outdated files (true sync)"),
|
||||
"trash_bin_explanation": _("This setting only applies to User Backups, not System Backups. It controls how files that are deleted from your source (e.g., your Documents folder) are handled in the destination."),
|
||||
"sync_mode_pure_sync": _("Sync Mode: Mirror. Files deleted from the source will also be permanently deleted from the backup."),
|
||||
"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
|
||||
"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"),
|
||||
"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"),
|
||||
"full_delete_config_settings": _("Full delete config settings"),
|
||||
"password_required": _("Password Required"),
|
||||
"enter_password_prompt": _("Please enter the password for the encrypted backup:"),
|
||||
"confirm_password_prompt": _("Confirm password:"),
|
||||
"save_to_keyring": _("Save password to system keyring"),
|
||||
"password_empty_error": _("Password cannot be empty."),
|
||||
"passwords_do_not_match_error": _("Passwords do not match."),
|
||||
"ok": _("OK"),
|
||||
"unlock_backup": _("Unlock Backup"),
|
||||
}
|
||||
|
5
core/privileged_script_runner.sh
Executable file
5
core/privileged_script_runner.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# This script executes commands passed to its standard input.
|
||||
# The 'set -e' command ensures that the script will exit immediately if any command fails.
|
||||
set -e
|
||||
/bin/bash
|
214
main_app.py
214
main_app.py
@@ -5,6 +5,7 @@ import os
|
||||
import datetime
|
||||
from queue import Queue, Empty
|
||||
import shutil
|
||||
import signal
|
||||
|
||||
from shared_libs.log_window import LogWindow
|
||||
from shared_libs.logger import app_logger
|
||||
@@ -51,6 +52,13 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.style.configure("Green.Sidebar.TButton", foreground="green")
|
||||
|
||||
self.style.configure("Switch2.TCheckbutton",
|
||||
background="#2b3e4f", foreground="white")
|
||||
self.style.map("Switch2.TCheckbutton",
|
||||
background=[("active", "#2b3e4f"), ("selected",
|
||||
"#2b3e4f"), ("disabled", "#2b3e4f")],
|
||||
foreground=[("active", "white"), ("selected", "white"), ("disabled", "#737373")])
|
||||
|
||||
main_frame = ttk.Frame(self)
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
@@ -73,8 +81,14 @@ class MainApplication(tk.Tk):
|
||||
self.content_frame.grid_rowconfigure(6, weight=0)
|
||||
self.content_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._setup_log_window()
|
||||
|
||||
self.backup_manager = BackupManager(app_logger, self)
|
||||
self.queue = Queue()
|
||||
|
||||
self._check_for_stale_mounts()
|
||||
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||
signal.signal(signal.SIGINT, self._handle_signal)
|
||||
self.image_manager = IconManager()
|
||||
|
||||
self.config_manager = ConfigManager(AppConfig.SETTINGS_FILE)
|
||||
@@ -83,14 +97,25 @@ class MainApplication(tk.Tk):
|
||||
self.navigation = Navigation(self)
|
||||
self.actions = Actions(self)
|
||||
|
||||
self.full_backup_var = tk.BooleanVar()
|
||||
self.incremental_var = tk.BooleanVar()
|
||||
self.genaue_berechnung_var = tk.BooleanVar()
|
||||
self.test_run_var = tk.BooleanVar()
|
||||
self.compressed_var = tk.BooleanVar()
|
||||
self.encrypted_var = tk.BooleanVar()
|
||||
self.bypass_security_var = tk.BooleanVar()
|
||||
self.refresh_log_var = tk.BooleanVar(value=True)
|
||||
|
||||
self.mode = "backup" # Default mode
|
||||
self.backup_is_running = False
|
||||
self.start_time = None
|
||||
self.last_backup_was_system = True
|
||||
self.next_backup_content_view = 'system'
|
||||
|
||||
self.calculation_thread = None
|
||||
self.calculation_stop_event = None
|
||||
self.source_larger_than_partition = False
|
||||
self.accurate_calculation_running = False
|
||||
self.incremental_calculation_running = False
|
||||
self.is_first_backup = False
|
||||
|
||||
self.left_canvas_animation = None
|
||||
@@ -147,6 +172,16 @@ 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.test_run_cb = ttk.Checkbutton(self.sidebar_buttons_frame, text=Msg.STR["test_run"],
|
||||
variable=self.test_run_var, style="Switch2.TCheckbutton")
|
||||
self.test_run_cb.pack(fill=tk.X, pady=(100, 10))
|
||||
self.bypass_security_cb = ttk.Checkbutton(self.sidebar_buttons_frame, text=Msg.STR["bypass_security"],
|
||||
variable=self.bypass_security_var, style="Switch2.TCheckbutton")
|
||||
self.bypass_security_cb.pack(fill=tk.X, pady=10)
|
||||
self.refresh_log_cb = ttk.Checkbutton(self.sidebar_buttons_frame, text=Msg.STR["refresh_log"],
|
||||
variable=self.refresh_log_var, style="Switch2.TCheckbutton")
|
||||
self.refresh_log_cb.pack(fill=tk.X, pady=10)
|
||||
|
||||
self.header_frame = HeaderFrame(
|
||||
self.content_frame, self.image_manager, self.backup_manager.encryption_manager, self)
|
||||
self.header_frame.grid(row=0, column=0, sticky="nsew")
|
||||
@@ -218,7 +253,6 @@ class MainApplication(tk.Tk):
|
||||
self.right_canvas.bind(
|
||||
"<Button-1>", self.actions.on_right_canvas_click)
|
||||
|
||||
self._setup_log_window()
|
||||
self._setup_scheduler_frame()
|
||||
self._setup_settings_frame()
|
||||
self._setup_backup_content_frame()
|
||||
@@ -298,12 +332,13 @@ class MainApplication(tk.Tk):
|
||||
self.restore_size_frame_after.grid_remove()
|
||||
|
||||
self._load_state_and_initialize()
|
||||
self.update_backup_options_from_config()
|
||||
self.protocol("WM_DELETE_WINDOW", self.on_closing)
|
||||
|
||||
def _load_state_and_initialize(self):
|
||||
# self.log_window.clear_log()
|
||||
last_mode = self.config_manager.get_setting("last_mode", "backup")
|
||||
refresh_log = self.config_manager.get_setting("refresh_log", True)
|
||||
self.refresh_log_var.set(refresh_log)
|
||||
|
||||
backup_source_path = self.config_manager.get_setting(
|
||||
"backup_source_path")
|
||||
@@ -336,23 +371,17 @@ class MainApplication(tk.Tk):
|
||||
self.destination_total_bytes = total
|
||||
self.destination_used_bytes = used
|
||||
|
||||
# If the destination is already mounted from a previous session,
|
||||
# adopt it into the current session's state so it can be cleaned up properly.
|
||||
if self.backup_manager.encryption_manager.is_mounted(backup_dest_path):
|
||||
app_logger.log(
|
||||
f"Adopting pre-existing mount for {backup_dest_path} into session.")
|
||||
self.backup_manager.encryption_manager.mounted_destinations.add(
|
||||
backup_dest_path)
|
||||
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
container_path = os.path.join(
|
||||
backup_dest_path, "pybackup_encrypted.luks")
|
||||
if os.path.exists(container_path):
|
||||
username = os.path.basename(backup_dest_path.rstrip('/'))
|
||||
password = self.backup_manager.encryption_manager.get_password_from_keyring(
|
||||
username)
|
||||
if password:
|
||||
self.backup_manager.encryption_manager.unlock_container(
|
||||
backup_dest_path, password)
|
||||
app_logger.log(
|
||||
"Automatically unlocked encrypted container.")
|
||||
if hasattr(self, 'header_frame'):
|
||||
self.header_frame.refresh_status()
|
||||
|
||||
restore_src_path = self.config_manager.get_setting(
|
||||
"restore_source_path")
|
||||
if restore_src_path and os.path.isdir(restore_src_path):
|
||||
@@ -407,7 +436,7 @@ class MainApplication(tk.Tk):
|
||||
|
||||
def _setup_settings_frame(self):
|
||||
self.settings_frame = SettingsFrame(
|
||||
self.content_frame, self.navigation, self.actions, padding=10)
|
||||
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()
|
||||
|
||||
@@ -418,19 +447,11 @@ class MainApplication(tk.Tk):
|
||||
self.backup_content_frame.grid_remove()
|
||||
|
||||
def _setup_task_bar(self):
|
||||
self.vollbackup_var = tk.BooleanVar()
|
||||
self.inkrementell_var = tk.BooleanVar()
|
||||
self.genaue_berechnung_var = tk.BooleanVar()
|
||||
self.testlauf_var = tk.BooleanVar()
|
||||
self.compressed_var = tk.BooleanVar()
|
||||
self.encrypted_var = tk.BooleanVar()
|
||||
self.bypass_security_var = tk.BooleanVar()
|
||||
|
||||
self.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.info_checkbox_frame.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
self.info_label = ttk.Label(
|
||||
self.info_checkbox_frame, text=Msg.STR["info_text_placeholder"])
|
||||
self.info_label = ttk.Label(self.info_checkbox_frame)
|
||||
self._update_info_label(Msg.STR["backup_mode"]) # Set initial text
|
||||
self.info_label.pack(anchor=tk.W, fill=tk.X, pady=5)
|
||||
|
||||
self.sync_mode_label = ttk.Label(
|
||||
@@ -452,39 +473,33 @@ class MainApplication(tk.Tk):
|
||||
self.time_info_frame, text="Ende: --:--:--")
|
||||
self.end_time_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
accurate_size_frame = ttk.Frame(self.time_info_frame)
|
||||
accurate_size_frame.pack(side=tk.LEFT, padx=20)
|
||||
incremental_size_frame = ttk.Frame(self.time_info_frame)
|
||||
incremental_size_frame.pack(side=tk.LEFT, padx=20)
|
||||
|
||||
self.accurate_size_cb = ttk.Checkbutton(accurate_size_frame, text=Msg.STR["accurate_size_cb_label"],
|
||||
variable=self.genaue_berechnung_var, command=self.actions.on_toggle_accurate_size_calc)
|
||||
self.accurate_size_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.incremental_size_btn = ttk.Button(incremental_size_frame, text=Msg.STR["incremental_size_cb_label"],
|
||||
command=self.actions.on_incremental_size_calc)
|
||||
self.incremental_size_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
accurate_size_info_label = ttk.Label(
|
||||
accurate_size_frame, text=Msg.STR["accurate_size_info_label"], foreground="gray")
|
||||
accurate_size_info_label.pack(side=tk.LEFT)
|
||||
incremental_size_info_label = ttk.Label(
|
||||
incremental_size_frame, text=Msg.STR["incremental_size_info_label"], foreground="gray")
|
||||
incremental_size_info_label.pack(side=tk.LEFT)
|
||||
|
||||
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
checkbox_frame.pack(fill=tk.X, pady=5)
|
||||
|
||||
self.full_backup_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["full_backup"],
|
||||
variable=self.vollbackup_var, command=lambda: self.actions.handle_backup_type_change('voll'))
|
||||
variable=self.full_backup_var, command=lambda: self.actions.handle_backup_type_change('full'), style="Switch.TCheckbutton")
|
||||
self.full_backup_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.incremental_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["incremental"],
|
||||
variable=self.inkrementell_var, command=lambda: self.actions.handle_backup_type_change('inkrementell'))
|
||||
variable=self.incremental_var, command=lambda: self.actions.handle_backup_type_change('incremental'), style="Switch.TCheckbutton")
|
||||
self.incremental_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.compressed_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["compressed"],
|
||||
variable=self.compressed_var, command=self.actions.handle_compression_change)
|
||||
variable=self.compressed_var, command=self.actions.handle_compression_change, style="Switch.TCheckbutton")
|
||||
self.compressed_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.encrypted_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["encrypted"],
|
||||
variable=self.encrypted_var, command=self.actions.handle_encryption_change)
|
||||
variable=self.encrypted_var, command=self.actions.handle_encryption_change, style="Switch.TCheckbutton")
|
||||
self.encrypted_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.test_run_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["test_run"],
|
||||
variable=self.testlauf_var)
|
||||
self.test_run_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.bypass_security_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["bypass_security"],
|
||||
variable=self.bypass_security_var)
|
||||
self.bypass_security_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.action_frame = ttk.Frame(self.content_frame, padding=10)
|
||||
self.action_frame.grid(row=6, column=0, sticky="ew")
|
||||
@@ -515,11 +530,13 @@ class MainApplication(tk.Tk):
|
||||
progress_container, orient="horizontal", length=100, mode="determinate")
|
||||
self.task_progress.grid(row=1, column=0, sticky="ew", pady=(5, 0))
|
||||
|
||||
self.start_pause_button = ttk.Button(
|
||||
self.start_cancel_button = ttk.Button(
|
||||
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)
|
||||
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())
|
||||
self.backup_manager.encryption_manager.unmount_all()
|
||||
|
||||
self.config_manager.set_setting("last_mode", self.mode)
|
||||
@@ -567,6 +584,10 @@ class MainApplication(tk.Tk):
|
||||
except tk.TclError:
|
||||
pass # App is already destroyed
|
||||
|
||||
def _update_info_label(self, text, color="black"):
|
||||
self.info_label.config(
|
||||
text=text, foreground=color, font=("Helvetica", 14))
|
||||
|
||||
def _process_queue(self):
|
||||
try:
|
||||
for _ in range(100):
|
||||
@@ -580,10 +601,9 @@ class MainApplication(tk.Tk):
|
||||
button_text, folder_size, mode_when_started = message
|
||||
|
||||
if mode_when_started != self.mode:
|
||||
if calc_type == 'accurate_incremental':
|
||||
if calc_type == 'incremental_incremental':
|
||||
self.actions._set_ui_state(True)
|
||||
self.genaue_berechnung_var.set(False)
|
||||
self.accurate_calculation_running = False
|
||||
self.incremental_calculation_running = False
|
||||
self.animated_icon.stop("DISABLE")
|
||||
else:
|
||||
current_folder_name = self.left_canvas_data.get(
|
||||
@@ -623,7 +643,7 @@ class MainApplication(tk.Tk):
|
||||
|
||||
self.drawing.update_target_projection()
|
||||
|
||||
if calc_type == 'accurate_incremental':
|
||||
if calc_type == 'incremental_incremental':
|
||||
self.source_size_bytes = folder_size
|
||||
self.drawing.update_target_projection()
|
||||
self.animated_icon.stop("DISABLE")
|
||||
@@ -631,17 +651,16 @@ class MainApplication(tk.Tk):
|
||||
self.task_progress.config(
|
||||
mode="determinate", value=0)
|
||||
self.actions._set_ui_state(True)
|
||||
self.genaue_berechnung_var.set(False)
|
||||
self.accurate_calculation_running = False
|
||||
self.start_pause_button.config(
|
||||
self.incremental_calculation_running = False
|
||||
self.start_cancel_button.config(
|
||||
text=Msg.STR["start"])
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_success"], foreground="#0078d7")
|
||||
self._update_info_label(
|
||||
Msg.STR["incremental_size_success"], color="#0078d7")
|
||||
self.current_file_label.config(text="")
|
||||
else:
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_failed"], foreground="#D32F2F")
|
||||
self._update_info_label(
|
||||
Msg.STR["incremental_size_failed"], color="#D32F2F")
|
||||
self.current_file_label.config(text="")
|
||||
|
||||
elif isinstance(message, tuple) and len(message) == 2:
|
||||
@@ -649,14 +668,14 @@ class MainApplication(tk.Tk):
|
||||
|
||||
if message_type == 'progress':
|
||||
self.task_progress["value"] = value
|
||||
self.info_label.config(text=f"Fortschritt: {value}%")
|
||||
self._update_info_label(f"Fortschritt: {value}%")
|
||||
elif message_type == 'file_update':
|
||||
max_len = 120
|
||||
if len(value) > max_len:
|
||||
value = "..." + value[-max_len:]
|
||||
self.current_file_label.config(text=value)
|
||||
elif message_type == 'status_update':
|
||||
self.info_label.config(text=value)
|
||||
self._update_info_label(value)
|
||||
elif message_type == 'progress_mode':
|
||||
self.task_progress.config(mode=value)
|
||||
if value == 'indeterminate':
|
||||
@@ -664,15 +683,20 @@ class MainApplication(tk.Tk):
|
||||
else:
|
||||
self.task_progress.stop()
|
||||
elif message_type == 'cancel_button_state':
|
||||
self.start_pause_button.config(state=value)
|
||||
self.start_cancel_button.config(state=value)
|
||||
elif message_type == 'current_path':
|
||||
self.current_backup_path = value
|
||||
app_logger.log(f"Set current backup path to: {value}")
|
||||
elif message_type == 'deletion_complete':
|
||||
self.actions._set_ui_state(True)
|
||||
self.backup_content_frame.hide_deletion_status()
|
||||
self.backup_content_frame.system_backups_frame._load_backup_content()
|
||||
self.backup_content_frame.user_backups_frame._load_backup_content()
|
||||
if self.destination_path:
|
||||
active_tab_index = self.backup_content_frame.current_view_index
|
||||
self.backup_content_frame.show(
|
||||
self.destination_path, initial_tab_index=active_tab_index)
|
||||
elif message_type == 'error':
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.start_cancel_button["text"] = "Start"
|
||||
self.backup_is_running = False
|
||||
elif message_type == 'completion':
|
||||
status_info = value
|
||||
@@ -682,20 +706,25 @@ class MainApplication(tk.Tk):
|
||||
elif status_info is None:
|
||||
status = 'success'
|
||||
|
||||
if status in ['success', 'warning']:
|
||||
if self.last_backup_was_system:
|
||||
self.next_backup_content_view = 'system'
|
||||
else:
|
||||
self.next_backup_content_view = 'user'
|
||||
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_successfully"])
|
||||
self._update_info_label(
|
||||
Msg.STR["backup_finished_successfully"])
|
||||
elif status == 'warning':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_with_warnings"])
|
||||
self._update_info_label(
|
||||
Msg.STR["backup_finished_with_warnings"])
|
||||
elif status == 'error':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_failed"])
|
||||
self._update_info_label(Msg.STR["backup_failed"])
|
||||
elif status == 'cancelled':
|
||||
pass
|
||||
self._update_info_label(Msg.STR["backup_mode"])
|
||||
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.start_cancel_button["text"] = "Start"
|
||||
self.task_progress["value"] = 0
|
||||
self.current_file_label.config(text="")
|
||||
|
||||
@@ -731,6 +760,37 @@ class MainApplication(tk.Tk):
|
||||
def quit(self):
|
||||
self.on_closing()
|
||||
|
||||
def _check_for_stale_mounts(self):
|
||||
app_logger.log("Checking for stale mounts from previous sessions...")
|
||||
try:
|
||||
locks = self.backup_manager.encryption_manager._read_lock_file()
|
||||
if not locks:
|
||||
app_logger.log(
|
||||
"No lock file found or lock file is empty. Clean state.")
|
||||
return
|
||||
|
||||
stale_mounts_found = False
|
||||
for lock in locks:
|
||||
mapper_path = f"/dev/mapper/{lock['mapper_name']}"
|
||||
if os.path.exists(mapper_path):
|
||||
stale_mounts_found = True
|
||||
app_logger.log(
|
||||
f"Found stale mount: {lock['mapper_name']} for path {lock['base_path']}. Attempting to close.")
|
||||
self.backup_manager.encryption_manager.unmount_and_reset_owner(
|
||||
lock['base_path'], force_unmap=True)
|
||||
|
||||
if not stale_mounts_found:
|
||||
app_logger.log("No stale mounts detected.")
|
||||
if locks:
|
||||
self.backup_manager.encryption_manager._write_lock_file([])
|
||||
|
||||
except Exception as e:
|
||||
app_logger.log(f"Error during stale mount check: {e}")
|
||||
|
||||
def _handle_signal(self, signum, frame):
|
||||
app_logger.log(f"Received signal {signum}. Cleaning up and exiting.")
|
||||
self.on_closing()
|
||||
|
||||
def update_backup_options_from_config(self):
|
||||
force_full = self.config_manager.get_setting(
|
||||
"force_full_backup", False)
|
||||
@@ -738,13 +798,13 @@ class MainApplication(tk.Tk):
|
||||
"force_incremental_backup", False)
|
||||
|
||||
if force_full:
|
||||
self.vollbackup_var.set(True)
|
||||
self.inkrementell_var.set(False)
|
||||
self.full_backup_var.set(True)
|
||||
self.incremental_var.set(False)
|
||||
self.full_backup_cb.config(state="disabled")
|
||||
self.incremental_cb.config(state="disabled")
|
||||
elif force_incremental:
|
||||
self.vollbackup_var.set(False)
|
||||
self.inkrementell_var.set(True)
|
||||
self.full_backup_var.set(False)
|
||||
self.incremental_var.set(True)
|
||||
self.full_backup_cb.config(state="disabled")
|
||||
self.incremental_cb.config(state="disabled")
|
||||
|
||||
|
@@ -10,23 +10,35 @@ from shared_libs.logger import app_logger
|
||||
from core.pbp_app_config import AppConfig
|
||||
|
||||
# A simple logger for the CLI that just prints to the console
|
||||
|
||||
|
||||
class CliLogger:
|
||||
def log(self, message):
|
||||
print(f"[CLI] {message}")
|
||||
|
||||
def init_logger(self, log_method):
|
||||
pass # Not needed for CLI
|
||||
pass # Not needed for CLI
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Py-Backup Command-Line Interface.")
|
||||
parser.add_argument("--backup-type", choices=['user', 'system'], required=True, help="Type of backup to perform.")
|
||||
parser.add_argument("--destination", required=True, help="Destination directory for the backup.")
|
||||
parser.add_argument("--source", help="Source directory for user backup. Required for --backup-type user.")
|
||||
parser.add_argument("--mode", choices=['full', 'incremental'], default='incremental', help="Mode for system backup.")
|
||||
parser.add_argument("--encrypted", action='store_true', help="Flag to indicate the backup should be encrypted.")
|
||||
parser.add_argument("--key-file", help="Path to the key file for unlocking an encrypted container.")
|
||||
parser.add_argument("--password", help="Password for the encrypted container (use with caution). If --key-file is not provided, this will be used.")
|
||||
parser.add_argument("--compressed", action='store_true', help="Flag to indicate the backup should be compressed.")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Py-Backup Command-Line Interface.")
|
||||
parser.add_argument(
|
||||
"--backup-type", choices=['user', 'system'], required=True, help="Type of backup to perform.")
|
||||
parser.add_argument("--destination", required=True,
|
||||
help="Destination directory for the backup.")
|
||||
parser.add_argument(
|
||||
"--source", help="Source directory for user backup. Required for --backup-type user.")
|
||||
parser.add_argument("--mode", choices=['full', 'incremental'],
|
||||
default='incremental', help="Mode for system backup.")
|
||||
parser.add_argument("--encrypted", action='store_true',
|
||||
help="Flag to indicate the backup should be encrypted.")
|
||||
parser.add_argument(
|
||||
"--key-file", help="Path to the key file for unlocking an encrypted container.")
|
||||
parser.add_argument(
|
||||
"--password", help="Password for the encrypted container (use with caution). If --key-file is not provided, this will be used.")
|
||||
parser.add_argument("--compressed", action='store_true',
|
||||
help="Flag to indicate the backup should be compressed.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -34,17 +46,19 @@ def main():
|
||||
parser.error("--source is required for --backup-type 'user'.")
|
||||
|
||||
if args.encrypted and not (args.key_file or args.password):
|
||||
parser.error("For encrypted backups, either --key-file or --password must be provided.")
|
||||
parser.error(
|
||||
"For encrypted backups, either --key-file or --password must be provided.")
|
||||
|
||||
cli_logger = CliLogger()
|
||||
backup_manager = BackupManager(cli_logger)
|
||||
queue = Queue() # Dummy queue for now, might be used for progress later
|
||||
queue = Queue() # Dummy queue for now, might be used for progress later
|
||||
|
||||
source_path = "/" # Default for system backup
|
||||
source_path = "/" # Default for system backup
|
||||
if args.backup_type == 'user':
|
||||
source_path = args.source
|
||||
if not os.path.isdir(source_path):
|
||||
cli_logger.log(f"Error: Source path '{source_path}' does not exist or is not a directory.")
|
||||
cli_logger.log(
|
||||
f"Error: Source path '{source_path}' does not exist or is not a directory.")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine password or key_file to pass
|
||||
@@ -54,7 +68,8 @@ def main():
|
||||
if args.key_file:
|
||||
auth_key_file = args.key_file
|
||||
if not os.path.exists(auth_key_file):
|
||||
cli_logger.log(f"Error: Key file '{auth_key_file}' does not exist.")
|
||||
cli_logger.log(
|
||||
f"Error: Key file '{auth_key_file}' does not exist.")
|
||||
sys.exit(1)
|
||||
elif args.password:
|
||||
auth_password = args.password
|
||||
@@ -78,8 +93,8 @@ def main():
|
||||
dest_path=args.destination,
|
||||
is_system=(args.backup_type == 'system'),
|
||||
is_dry_run=False,
|
||||
exclude_files=None, # Excludes are handled by AppConfig.MANUAL_EXCLUDE_LIST_PATH
|
||||
source_size=0, # Not accurately calculable in CLI without scanning, set to 0
|
||||
exclude_files=None, # Excludes are handled by AppConfig.MANUAL_EXCLUDE_LIST_PATH
|
||||
source_size=0, # Not accurately calculable in CLI without scanning, set to 0
|
||||
is_compressed=args.compressed,
|
||||
is_encrypted=args.encrypted,
|
||||
mode=args.mode,
|
||||
@@ -92,5 +107,6 @@ def main():
|
||||
|
||||
cli_logger.log("CLI backup process finished.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
@@ -20,48 +20,47 @@ class Actions:
|
||||
|
||||
def _set_backup_type(self, backup_type: str):
|
||||
if backup_type == "full":
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
self.app.full_backup_var.set(True)
|
||||
self.app.incremental_var.set(False)
|
||||
elif backup_type == "incremental":
|
||||
self.app.vollbackup_var.set(False)
|
||||
self.app.inkrementell_var.set(True)
|
||||
self.app.full_backup_var.set(False)
|
||||
self.app.incremental_var.set(True)
|
||||
|
||||
def _update_backup_type_controls(self):
|
||||
# Only applies to system backups in backup mode
|
||||
if self.app.mode != 'backup' or self.app.left_canvas_data.get('folder') != "Computer":
|
||||
self._set_backup_type("full") # Default for user backups
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
return
|
||||
else:
|
||||
# Re-enable if we switch back to system backup
|
||||
source_name = self.app.left_canvas_data.get('folder')
|
||||
is_system_backup = (source_name == "Computer")
|
||||
|
||||
if not is_system_backup:
|
||||
self.app.full_backup_cb.config(state='normal')
|
||||
self.app.incremental_cb.config(state='normal')
|
||||
else: # System backup
|
||||
self.app.full_backup_cb.config(state='normal')
|
||||
self.app.incremental_cb.config(state='normal')
|
||||
|
||||
# If controls are forced by advanced settings, do nothing
|
||||
if self.app.full_backup_cb.cget('state') == 'disabled' and self.app.incremental_cb.cget('state') == 'disabled':
|
||||
if self.app.config_manager.get_setting("force_full_backup", False):
|
||||
self._set_backup_type("full")
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
return
|
||||
if self.app.config_manager.get_setting("force_incremental_backup", False):
|
||||
self._set_backup_type("incremental")
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
return
|
||||
|
||||
full_backup_exists = False
|
||||
if self.app.destination_path and os.path.isdir(self.app.destination_path):
|
||||
pybackup_dir = os.path.join(self.app.destination_path, "pybackup")
|
||||
if not os.path.isdir(pybackup_dir):
|
||||
self._set_backup_type("full")
|
||||
return
|
||||
if not self.app.destination_path or not os.path.isdir(self.app.destination_path):
|
||||
self._set_backup_type("full")
|
||||
return
|
||||
|
||||
is_encrypted_backup = self.app.encrypted_var.get()
|
||||
is_encrypted = self.app.encrypted_var.get()
|
||||
|
||||
system_backups = self.app.backup_manager.list_system_backups(
|
||||
self.app.destination_path, mount_if_needed=False)
|
||||
profile_name = "system" if is_system_backup else source_name
|
||||
|
||||
if system_backups is None: # Encrypted, but not inspected
|
||||
full_backup_exists = True # Assume one exists to be safe
|
||||
else:
|
||||
for backup in system_backups:
|
||||
# Match the encryption status and check if it's a full backup
|
||||
if backup.get('is_encrypted') == is_encrypted_backup and backup.get('backup_type_base') == 'Full':
|
||||
full_backup_exists = True
|
||||
break
|
||||
full_backup_exists = self.app.backup_manager.check_for_full_backup(
|
||||
dest_path=self.app.destination_path,
|
||||
source_name=profile_name,
|
||||
is_encrypted=is_encrypted
|
||||
)
|
||||
|
||||
if full_backup_exists:
|
||||
self._set_backup_type("incremental")
|
||||
@@ -69,39 +68,42 @@ class Actions:
|
||||
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")
|
||||
# Reset enabled/disabled state first, but respect forced states from config
|
||||
if self.app.full_backup_cb.cget('state') != 'disabled':
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
if self.app.incremental_cb.cget('state') != 'disabled':
|
||||
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.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)
|
||||
self.app.full_backup_var.set(True)
|
||||
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")
|
||||
|
||||
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()
|
||||
# Set incremental_size_btn state
|
||||
if self.app.mode == "backup" and self.app.incremental_var.get():
|
||||
self.app.incremental_size_btn.config(state="normal")
|
||||
else:
|
||||
self.app.incremental_size_btn.config(state="disabled")
|
||||
|
||||
def handle_backup_type_change(self, changed_var_name):
|
||||
if changed_var_name == 'voll':
|
||||
if self.app.vollbackup_var.get():
|
||||
if changed_var_name == 'full':
|
||||
if self.app.full_backup_var.get():
|
||||
self._set_backup_type("full")
|
||||
elif changed_var_name == 'inkrementell':
|
||||
if self.app.inkrementell_var.get():
|
||||
elif changed_var_name == 'incremental':
|
||||
if self.app.incremental_var.get():
|
||||
self._set_backup_type("incremental")
|
||||
self._refresh_backup_options_ui()
|
||||
|
||||
def handle_compression_change(self):
|
||||
self._refresh_backup_options_ui()
|
||||
@@ -109,23 +111,20 @@ class Actions:
|
||||
def handle_encryption_change(self):
|
||||
self._refresh_backup_options_ui()
|
||||
|
||||
def on_toggle_accurate_size_calc(self):
|
||||
if not self.app.genaue_berechnung_var.get():
|
||||
return
|
||||
def on_incremental_size_calc(self):
|
||||
|
||||
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.")
|
||||
|
||||
app_logger.log("Accurate incremental size calculation requested.")
|
||||
self.app.accurate_calculation_running = True
|
||||
app_logger.log("Incremental size calculation requested.")
|
||||
self.app.incremental_calculation_running = True
|
||||
self._set_ui_state(False, keep_cancel_enabled=True)
|
||||
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
|
||||
self.app.start_cancel_button.config(text=Msg.STR["cancel_backup"])
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["please_wait"], foreground="#0078d7")
|
||||
self.app._update_info_label(Msg.STR["please_wait"], color="#0078d7")
|
||||
self.app.task_progress.config(mode="indeterminate")
|
||||
self.app.task_progress.start()
|
||||
self.app.left_canvas_data.update({
|
||||
@@ -140,10 +139,9 @@ class Actions:
|
||||
|
||||
if not folder_path or not button_text:
|
||||
app_logger.log(
|
||||
"Cannot start accurate calculation, source folder info missing.")
|
||||
"Cannot start incremental calculation, source folder info missing.")
|
||||
self._set_ui_state(True)
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
self.app.accurate_calculation_running = False
|
||||
self.app.incremental_calculation_running = False
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
return
|
||||
|
||||
@@ -151,35 +149,37 @@ class Actions:
|
||||
status = 'failure'
|
||||
size = 0
|
||||
try:
|
||||
exclude_file_paths = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(
|
||||
AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(
|
||||
AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
is_system = (button_text == "Computer")
|
||||
source_name = "system" if is_system else button_text
|
||||
is_encrypted = self.app.encrypted_var.get()
|
||||
|
||||
base_dest = self.app.destination_path
|
||||
correct_parent_dir = os.path.join(base_dest, "pybackup")
|
||||
dummy_dest_for_calc = os.path.join(
|
||||
correct_parent_dir, "dummy_name")
|
||||
exclude_files = []
|
||||
if is_system:
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_files.append(
|
||||
AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_files.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_files.append(
|
||||
AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
|
||||
size = self.app.data_processing.get_incremental_backup_size(
|
||||
size = self.app.backup_manager.estimate_incremental_size(
|
||||
source_path=folder_path,
|
||||
dest_path=dummy_dest_for_calc,
|
||||
is_system=True,
|
||||
exclude_files=exclude_file_paths
|
||||
is_system=is_system,
|
||||
source_name=source_name,
|
||||
base_dest_path=self.app.destination_path,
|
||||
is_encrypted=is_encrypted,
|
||||
exclude_files=exclude_files
|
||||
)
|
||||
status = 'success' if size > 0 else 'failure'
|
||||
status = 'success'
|
||||
except Exception as e:
|
||||
app_logger.log(f"Error during threaded_incremental_calc: {e}")
|
||||
status = 'failure'
|
||||
finally:
|
||||
if self.app.accurate_calculation_running:
|
||||
if self.app.incremental_calculation_running:
|
||||
self.app.queue.put(
|
||||
(button_text, size, self.app.mode, 'accurate_incremental', status))
|
||||
(button_text, size, self.app.mode, 'incremental_incremental', status))
|
||||
|
||||
self.app.calculation_thread = threading.Thread(
|
||||
target=threaded_incremental_calc)
|
||||
@@ -187,9 +187,9 @@ class Actions:
|
||||
self.app.calculation_thread.start()
|
||||
|
||||
def on_sidebar_button_click(self, button_text):
|
||||
if self.app.backup_is_running or self.app.accurate_calculation_running:
|
||||
if self.app.backup_is_running or self.app.incremental_calculation_running:
|
||||
app_logger.log(
|
||||
"Action blocked: Backup or accurate calculation is in progress.")
|
||||
"Action blocked: Backup or incremental calculation is in progress.")
|
||||
return
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
@@ -197,8 +197,6 @@ class Actions:
|
||||
self.app.navigation.toggle_mode(
|
||||
self.app.mode, trigger_calculation=False)
|
||||
|
||||
# self.app.log_window.clear_log()
|
||||
|
||||
REVERSE_FOLDER_MAP = {
|
||||
"Computer": "Computer",
|
||||
Msg.STR["cat_documents"]: "Documents",
|
||||
@@ -213,7 +211,7 @@ class Actions:
|
||||
if not folder_path or not folder_path.exists():
|
||||
print(
|
||||
f"Folder not found for {canonical_key} (Path: {folder_path})")
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
return
|
||||
|
||||
if self.app.mode == 'restore':
|
||||
@@ -239,19 +237,18 @@ class Actions:
|
||||
else:
|
||||
extra_info = Msg.STR["user_restore_info"]
|
||||
|
||||
# Update the info label based on the current mode
|
||||
if self.app.mode == 'backup':
|
||||
self._update_backup_type_controls()
|
||||
else:
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_destination_path", folder_path)
|
||||
self.app._update_info_label(Msg.STR["backup_mode"])
|
||||
elif self.app.mode == 'restore':
|
||||
self.app._update_info_label(Msg.STR["restore"])
|
||||
|
||||
self._start_left_canvas_calculation(
|
||||
button_text, str(folder_path), icon_name, extra_info)
|
||||
# Update sync mode display when source changes
|
||||
self.app._update_sync_mode_display()
|
||||
|
||||
def _start_left_canvas_calculation(self, button_text, folder_path, icon_name, extra_info):
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
if self.app.mode == 'backup' and not self.app.destination_path:
|
||||
self.app.left_canvas_data.update({
|
||||
'icon': icon_name,
|
||||
@@ -312,6 +309,7 @@ class Actions:
|
||||
|
||||
if self.app.mode == 'backup':
|
||||
self._update_backup_type_controls()
|
||||
self._refresh_backup_options_ui()
|
||||
else:
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_destination_path", folder_path)
|
||||
@@ -341,9 +339,8 @@ class Actions:
|
||||
def _update_right_canvas_info(self, path):
|
||||
try:
|
||||
if self.app.mode == "backup":
|
||||
# Unmount previous destination if it was mounted
|
||||
if self.app.destination_path:
|
||||
self.app.backup_manager.encryption_manager.unmount(
|
||||
self.app.backup_manager.encryption_manager.unmount_and_reset_owner(
|
||||
self.app.destination_path)
|
||||
|
||||
self.app.destination_path = path
|
||||
@@ -361,49 +358,46 @@ class Actions:
|
||||
f.write(f"{backup_root_to_exclude}\n")
|
||||
except IOError as e:
|
||||
app_logger.log(f"Error updating exclusion list: {e}")
|
||||
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
self.app.destination_total_bytes = total
|
||||
self.app.destination_used_bytes = used
|
||||
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
|
||||
try:
|
||||
total, used, free = shutil.disk_usage(path)
|
||||
self.app.destination_total_bytes = total
|
||||
self.app.destination_used_bytes = used
|
||||
size_str = f"{used / (1024**3):.2f} GB / {total / (1024**3):.2f} GB"
|
||||
except FileNotFoundError:
|
||||
size_str = "N/A"
|
||||
|
||||
self.app.right_canvas_data.update({
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'folder': os.path.basename(path.rstrip('/')) if not path.startswith(('ssh:', 'sftp:')) else path,
|
||||
'path_display': path,
|
||||
'size': size_str
|
||||
})
|
||||
self.app.config_manager.set_setting(
|
||||
"backup_destination_path", path)
|
||||
self.app.header_frame.refresh_status() # Refresh keyring status
|
||||
self.app.header_frame.refresh_status()
|
||||
self.app.drawing.redraw_right_canvas()
|
||||
self.app.drawing.update_target_projection()
|
||||
|
||||
current_source = self.app.left_canvas_data.get('folder')
|
||||
if current_source:
|
||||
self.on_sidebar_button_click(current_source)
|
||||
self._update_backup_type_controls()
|
||||
|
||||
elif self.app.mode == "restore":
|
||||
self.app.right_canvas_data.update({
|
||||
'folder': os.path.basename(path.rstrip('/')),
|
||||
'folder': os.path.basename(path.rstrip('/')) if not path.startswith(('ssh:', 'sftp:')) else path,
|
||||
'path_display': path,
|
||||
'size': ''
|
||||
})
|
||||
self.app.config_manager.set_setting(
|
||||
"restore_source_path", path)
|
||||
self.app.drawing.calculate_restore_folder_size()
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
self.app.start_cancel_button.config(state="normal")
|
||||
|
||||
except FileNotFoundError:
|
||||
with message_box_animation(self.app.calculating_animation):
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
with message_box_animation(self.app.animated_icon):
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["path_not_found"].format(path=path)).show()
|
||||
|
||||
def reset_to_default_settings(self):
|
||||
try:
|
||||
AppConfig.create_default_user_excludes()
|
||||
except OSError as e:
|
||||
app_logger.log(f"Error creating default user exclude list: {e}")
|
||||
|
||||
self.app.config_manager.set_setting("backup_destination_path", None)
|
||||
self.app.config_manager.set_setting("restore_source_path", None)
|
||||
@@ -426,7 +420,7 @@ class Actions:
|
||||
settings_frame.load_and_display_excludes()
|
||||
settings_frame._load_hidden_files()
|
||||
self.app.destination_path = None
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
|
||||
self.app.backup_left_canvas_data.clear()
|
||||
self.app.backup_right_canvas_data.clear()
|
||||
@@ -441,7 +435,7 @@ class Actions:
|
||||
self.app.backup_content_frame.user_backups_frame._load_backup_content()
|
||||
|
||||
with message_box_animation(self.app.animated_icon):
|
||||
MessageDialog(master=self.app, message_type="info",
|
||||
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:
|
||||
@@ -499,29 +493,28 @@ class Actions:
|
||||
self.app.right_canvas.config(cursor="")
|
||||
|
||||
if enable:
|
||||
self._update_backup_type_controls()
|
||||
self.app.update_backup_options_from_config()
|
||||
self.app.actions._update_backup_type_controls()
|
||||
else:
|
||||
checkboxes = [
|
||||
self.app.full_backup_cb,
|
||||
self.app.incremental_cb,
|
||||
self.app.accurate_size_cb,
|
||||
self.app.compressed_cb,
|
||||
self.app.encrypted_cb,
|
||||
self.app.test_run_cb,
|
||||
self.app.bypass_security_cb
|
||||
]
|
||||
self.app.incremental_size_btn.config(state="disabled")
|
||||
for cb in checkboxes:
|
||||
cb.config(state="disabled")
|
||||
|
||||
if keep_cancel_enabled:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
self.app.start_cancel_button.config(state="normal")
|
||||
|
||||
def toggle_start_cancel(self):
|
||||
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)
|
||||
if self.app.incremental_calculation_running:
|
||||
app_logger.log("Incremental size calculation cancelled by user.")
|
||||
self.app.incremental_calculation_running = False
|
||||
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
if self.app.left_canvas_animation:
|
||||
@@ -531,9 +524,8 @@ class Actions:
|
||||
|
||||
self.app.task_progress.stop()
|
||||
self.app.task_progress.config(mode="determinate", value=0)
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C")
|
||||
self.app.start_pause_button.config(text=Msg.STR["start"])
|
||||
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
|
||||
|
||||
@@ -549,14 +541,12 @@ class Actions:
|
||||
delete_path)
|
||||
app_logger.log(
|
||||
Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
self.app.info_label.config(
|
||||
text=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.info_label.config(
|
||||
text="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:
|
||||
@@ -567,27 +557,24 @@ class Actions:
|
||||
shutil.rmtree(delete_path)
|
||||
app_logger.log(
|
||||
Msg.STR["backup_cancelled_and_deleted_msg"])
|
||||
self.app.info_label.config(
|
||||
text=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.info_label.config(
|
||||
text=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.info_label.config(
|
||||
text="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
|
||||
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self.app.start_cancel_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
|
||||
else:
|
||||
if self.app.start_pause_button['state'] == 'disabled':
|
||||
if self.app.start_cancel_button['state'] == 'disabled':
|
||||
return
|
||||
|
||||
self.app.backup_is_running = True
|
||||
@@ -597,13 +584,12 @@ class Actions:
|
||||
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_info_label("Backup wird vorbereitet...")
|
||||
self.app._update_duration()
|
||||
|
||||
self.app.start_pause_button["text"] = Msg.STR["cancel_backup"]
|
||||
self.app.start_cancel_button["text"] = Msg.STR["cancel_backup"]
|
||||
self.app.update_idletasks()
|
||||
|
||||
# self.app.log_window.clear_log()
|
||||
self._set_ui_state(False, allow_log_and_backup_toggle=True)
|
||||
|
||||
self.app.animated_icon.start()
|
||||
@@ -616,59 +602,34 @@ class Actions:
|
||||
app_logger.log(
|
||||
"No source folder selected, aborting backup.")
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self.app.start_cancel_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
|
||||
if source_folder == "Computer":
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
self.app.last_backup_was_system = True
|
||||
mode = "full" if self.app.full_backup_var.get() else "incremental"
|
||||
self._start_system_backup(mode, source_size_bytes)
|
||||
else:
|
||||
self.app.last_backup_was_system = False
|
||||
self._start_user_backup()
|
||||
else: # restore mode
|
||||
# Restore logic would go here
|
||||
pass
|
||||
|
||||
def _start_system_backup(self, mode, source_size_bytes):
|
||||
base_dest = self.app.destination_path
|
||||
if not base_dest:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
if base_dest.startswith("/home"):
|
||||
with message_box_animation(self.app.animated_icon):
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
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:
|
||||
app_logger.log(
|
||||
"Could not set locale to de_DE.UTF-8. Using default.")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_system_{mode}"
|
||||
# The backup_manager will add /pybackup/
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
|
||||
|
||||
@@ -680,14 +641,15 @@ class Actions:
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.MANUAL_EXCLUDE_LIST_PATH)
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
is_dry_run = self.app.test_run_var.get()
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path="/",
|
||||
dest_path=final_dest,
|
||||
dest_path=base_dest,
|
||||
is_system=True,
|
||||
source_name="system",
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=exclude_file_paths,
|
||||
source_size=source_size_bytes,
|
||||
@@ -702,39 +664,18 @@ class Actions:
|
||||
source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0)
|
||||
|
||||
if not base_dest or not source_path:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
self.app.backup_is_running = False
|
||||
self.app.start_pause_button["text"] = Msg.STR["start"]
|
||||
self.app.start_cancel_button["text"] = Msg.STR["start"]
|
||||
self._set_ui_state(True)
|
||||
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
|
||||
|
||||
# Determine mode for user backup based on UI selection
|
||||
mode = "full" if self.app.vollbackup_var.get() else "incremental"
|
||||
mode = "full" if self.app.full_backup_var.get() else "incremental"
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
is_dry_run = self.app.test_run_var.get()
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
use_trash_bin = self.app.config_manager.get_setting(
|
||||
"use_trash_bin", False)
|
||||
@@ -744,8 +685,9 @@ class Actions:
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path=source_path,
|
||||
dest_path=final_dest,
|
||||
dest_path=base_dest,
|
||||
is_system=False,
|
||||
source_name=source_name,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=None,
|
||||
source_size=source_size_bytes,
|
||||
@@ -754,11 +696,3 @@ class Actions:
|
||||
mode=mode,
|
||||
use_trash_bin=use_trash_bin,
|
||||
no_trash_bin=no_trash_bin)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%m-%Y")
|
||||
time_str = now.strftime("%H:%M:%S")
|
||||
folder_name = f"{date_str}_{time_str}_user_{source_name}_{mode}"
|
||||
|
||||
final_dest = os.path.join(base_dest, folder_name)
|
||||
self.app.current_backup_path = final_dest
|
||||
|
@@ -6,8 +6,7 @@ from pathlib import Path
|
||||
from core.pbp_app_config import AppConfig, Msg
|
||||
from shared_libs.animated_icon import AnimatedIcon
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
from shared_libs.message import MessageDialog
|
||||
from pyimage_ui.password_dialog import PasswordDialog
|
||||
from shared_libs.message import MessageDialog, PasswordDialog
|
||||
|
||||
|
||||
class AdvancedSettingsFrame(ttk.Frame):
|
||||
@@ -19,25 +18,21 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.app_instance = app_instance
|
||||
self.current_view_index = 0
|
||||
|
||||
self.info_label = ttk.Label(
|
||||
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="left")
|
||||
self.info_label.pack(pady=10, fill=tk.X, padx=10)
|
||||
|
||||
nav_frame = ttk.Frame(self)
|
||||
nav_frame.pack(fill=tk.X, padx=10, pady=5)
|
||||
|
||||
nav_frame.pack(fill=tk.X, padx=10, pady=(0, 5))
|
||||
ttk.Separator(nav_frame, orient=tk.HORIZONTAL).pack(
|
||||
fill=tk.X, pady=(0, 15))
|
||||
top_nav_frame = ttk.Frame(nav_frame)
|
||||
top_nav_frame.pack(side=tk.LEFT)
|
||||
|
||||
self.nav_buttons_defs = [
|
||||
(Msg.STR["system_excludes"], lambda: self._switch_view(0)),
|
||||
(Msg.STR["manual_excludes"], lambda: self._switch_view(1)),
|
||||
# New button for Keyfile/Automation
|
||||
(Msg.STR["keyfile_settings"], lambda: self._switch_view(2)),
|
||||
(Msg.STR["animation_settings_title"],
|
||||
lambda: self._switch_view(3)), # Animation settings
|
||||
lambda: self._switch_view(3)),
|
||||
(Msg.STR["backup_defaults_title"],
|
||||
lambda: self._switch_view(4)), # Backup Defaults
|
||||
lambda: self._switch_view(4)),
|
||||
]
|
||||
|
||||
self.nav_buttons = []
|
||||
@@ -78,6 +73,10 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.tree.tag_configure("backup_dest_exclude", foreground="gray")
|
||||
self.tree.bind("<Button-1>", self._toggle_include_status)
|
||||
|
||||
self.info_label = ttk.Label(
|
||||
self, text=Msg.STR["advanced_settings_warning"], wraplength=780, justify="left")
|
||||
self.info_label.pack(pady=10, fill=tk.X, padx=10)
|
||||
|
||||
self.manual_excludes_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["manual_excludes"], padding=10)
|
||||
|
||||
@@ -122,24 +121,44 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.force_incremental_var = tk.BooleanVar()
|
||||
self.force_compression_var = tk.BooleanVar()
|
||||
self.force_encryption_var = tk.BooleanVar()
|
||||
self.use_trash_bin_var = tk.BooleanVar()
|
||||
self.no_trash_bin_var = tk.BooleanVar()
|
||||
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_full_var, self.force_incremental_var, self.force_full_var.get())).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_incremental_var, self.force_full_var, self.force_incremental_var.get())).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_compression"],
|
||||
variable=self.force_compression_var).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_encryption"],
|
||||
variable=self.force_encryption_var).pack(anchor=tk.W)
|
||||
self.full_backup_checkbutton = ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_full_backup"], variable=self.force_full_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_full_var, self.force_incremental_var, self.force_full_var.get()), style="Switch.TCheckbutton")
|
||||
self.full_backup_checkbutton.pack(anchor=tk.W)
|
||||
|
||||
ttk.Separator(self.backup_defaults_frame, orient=tk.HORIZONTAL).pack(
|
||||
fill=tk.X, pady=5)
|
||||
self.incremental_checkbutton = ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["force_incremental_backup"], variable=self.force_incremental_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.force_incremental_var, self.force_full_var, self.force_incremental_var.get()), style="Switch.TCheckbutton")
|
||||
self.incremental_checkbutton.pack(anchor=tk.W)
|
||||
|
||||
self.compression_checkbutton = ttk.Checkbutton(
|
||||
self.backup_defaults_frame, text=Msg.STR["force_compression"], variable=self.force_compression_var, command=self._on_compression_toggle, style="Switch.TCheckbutton")
|
||||
self.compression_checkbutton.pack(anchor=tk.W)
|
||||
|
||||
self.encryption_checkbutton = ttk.Checkbutton(
|
||||
self.backup_defaults_frame, text=Msg.STR["force_encryption"], variable=self.force_encryption_var, style="Switch.TCheckbutton")
|
||||
self.encryption_checkbutton.pack(anchor=tk.W)
|
||||
|
||||
ttk.Separator(self.backup_defaults_frame,
|
||||
orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
|
||||
|
||||
trash_info_label = ttk.Label(
|
||||
self.backup_defaults_frame, text=Msg.STR["trash_bin_explanation"], wraplength=750, justify="left")
|
||||
trash_info_label.pack(anchor=tk.W, pady=5)
|
||||
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["use_trash_bin"], variable=self.use_trash_bin_var, command=lambda: self._handle_trash_checkbox_click(
|
||||
self.use_trash_bin_var, self.no_trash_bin_var), style="Switch.TCheckbutton").pack(anchor=tk.W)
|
||||
ttk.Checkbutton(self.backup_defaults_frame, text=Msg.STR["no_trash_bin"], variable=self.no_trash_bin_var, command=lambda: self._handle_trash_checkbox_click(
|
||||
self.no_trash_bin_var, self.use_trash_bin_var), style="Switch.TCheckbutton").pack(anchor=tk.W)
|
||||
|
||||
ttk.Separator(self.backup_defaults_frame,
|
||||
orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
|
||||
|
||||
encryption_note = ttk.Label(
|
||||
self.backup_defaults_frame, text=Msg.STR["encryption_note_system_backup"], wraplength=750, justify="left")
|
||||
encryption_note.pack(anchor=tk.W, pady=5)
|
||||
|
||||
# --- Keyfile/Automation Settings ---
|
||||
self.keyfile_settings_frame = ttk.LabelFrame(
|
||||
view_container, text=Msg.STR["automation_settings_title"], padding=10)
|
||||
|
||||
@@ -153,10 +172,7 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
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 = (f"To run automated backups, an administrator must create a file in /etc/sudoers.d/\n"
|
||||
f"with the following content (replace 'punix' with the correct username):\n"
|
||||
# Path needs to be updated
|
||||
f"punix ALL=(ALL) NOPASSWD: /path/to/pybackup-cli.py")
|
||||
sudoers_info_text = Msg.STR["sudoers_info_text"]
|
||||
sudoers_info_label = ttk.Label(
|
||||
self.keyfile_settings_frame, text=sudoers_info_text, justify="left")
|
||||
|
||||
@@ -165,7 +181,6 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
|
||||
self.keyfile_settings_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# --- Action Buttons ---
|
||||
button_frame = ttk.Frame(self)
|
||||
button_frame.pack(pady=10)
|
||||
|
||||
@@ -174,16 +189,10 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
ttk.Button(button_frame, text=Msg.STR["cancel"], command=self._cancel_changes).pack(
|
||||
side=tk.LEFT, padx=5)
|
||||
|
||||
# Initial packing of frames (all hidden except the first one by _switch_view)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.manual_excludes_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Initially packed, then hidden by _switch_view
|
||||
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self._load_system_folders()
|
||||
@@ -194,9 +203,42 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
|
||||
self._switch_view(self.current_view_index)
|
||||
|
||||
def _on_compression_toggle(self):
|
||||
if self.force_compression_var.get():
|
||||
self.force_incremental_var.set(False)
|
||||
self.incremental_checkbutton.config(state="disabled")
|
||||
|
||||
self.force_encryption_var.set(False)
|
||||
self.encryption_checkbutton.config(state="disabled")
|
||||
else:
|
||||
self.incremental_checkbutton.config(state="normal")
|
||||
self.encryption_checkbutton.config(state="normal")
|
||||
|
||||
def _handle_trash_checkbox_click(self, changed_var, other_var):
|
||||
enforce_backup_type_exclusivity(
|
||||
changed_var, other_var, changed_var.get())
|
||||
self._on_trash_setting_change()
|
||||
|
||||
def _on_trash_setting_change(self):
|
||||
use_trash = self.use_trash_bin_var.get()
|
||||
no_trash = self.no_trash_bin_var.get()
|
||||
|
||||
if no_trash:
|
||||
self.app_instance.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_pure_sync"], foreground="red")
|
||||
elif use_trash:
|
||||
self.app_instance.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_trash_bin"], foreground="orange")
|
||||
else:
|
||||
self.app_instance.sync_mode_label.config(
|
||||
text=Msg.STR["sync_mode_no_delete"], foreground="green")
|
||||
|
||||
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(self, message_type="error", title="Error",
|
||||
MessageDialog(message_type="error", title="Error",
|
||||
text="Please select a backup destination first.")
|
||||
return
|
||||
|
||||
@@ -204,26 +246,25 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.app_instance.destination_path, "pybackup")
|
||||
container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks")
|
||||
if not os.path.exists(container_path):
|
||||
MessageDialog(self, message_type="error", title="Error",
|
||||
MessageDialog(message_type="error", title="Error",
|
||||
text="No encrypted container found at the destination.")
|
||||
return
|
||||
|
||||
# Prompt for the existing password to authorize adding a new key
|
||||
password_dialog = PasswordDialog(
|
||||
self, title="Enter Existing Password", confirm=False)
|
||||
self, title="Enter Existing Password", confirm=False, translations=Msg.STR)
|
||||
password, _ = password_dialog.get_password()
|
||||
|
||||
if not password:
|
||||
return # User cancelled
|
||||
return
|
||||
|
||||
key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(
|
||||
self.app_instance.destination_path, password)
|
||||
|
||||
if key_file_path:
|
||||
MessageDialog(self, message_type="info", title="Success",
|
||||
MessageDialog(message_type="info", title="Success",
|
||||
text=f"Key file created and added successfully!\nPath: {key_file_path}")
|
||||
else:
|
||||
MessageDialog(self, message_type="error", title="Error",
|
||||
MessageDialog(message_type="error", title="Error",
|
||||
text="Failed to create or add key file. See log for details.")
|
||||
|
||||
self._update_key_file_status()
|
||||
@@ -246,14 +287,12 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.current_view_index = index
|
||||
self.update_nav_buttons(index)
|
||||
|
||||
# Hide all frames first
|
||||
self.tree_frame.pack_forget()
|
||||
self.manual_excludes_frame.pack_forget()
|
||||
self.keyfile_settings_frame.pack_forget()
|
||||
self.animation_settings_frame.pack_forget()
|
||||
self.backup_defaults_frame.pack_forget()
|
||||
|
||||
# Show the selected frame and update info label
|
||||
if index == 0:
|
||||
self.tree_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["advanced_settings_warning"])
|
||||
@@ -262,14 +301,13 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.info_label.config(text=Msg.STR["manual_excludes_info"])
|
||||
elif index == 2:
|
||||
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Use automation title for info
|
||||
self.info_label.config(text=Msg.STR["automation_settings_title"])
|
||||
self.info_label.config(text="")
|
||||
elif index == 3:
|
||||
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["animation_settings_title"])
|
||||
self.info_label.config(text="")
|
||||
elif index == 4:
|
||||
self.backup_defaults_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.info_label.config(text=Msg.STR["backup_defaults_title"])
|
||||
self.info_label.config(text="")
|
||||
|
||||
def update_nav_buttons(self, active_index):
|
||||
for i, button in enumerate(self.nav_buttons):
|
||||
@@ -318,6 +356,11 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.config_manager.get_setting("force_compression", False))
|
||||
self.force_encryption_var.set(
|
||||
self.config_manager.get_setting("force_encryption", False))
|
||||
self.use_trash_bin_var.set(
|
||||
self.config_manager.get_setting("use_trash_bin", False))
|
||||
self.no_trash_bin_var.set(
|
||||
self.config_manager.get_setting("no_trash_bin", False))
|
||||
self._on_compression_toggle()
|
||||
|
||||
def _load_animation_settings(self):
|
||||
backup_anim = self.config_manager.get_setting(
|
||||
@@ -409,6 +452,10 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
"force_compression", self.force_compression_var.get())
|
||||
self.config_manager.set_setting(
|
||||
"force_encryption", self.force_encryption_var.get())
|
||||
self.config_manager.set_setting(
|
||||
"use_trash_bin", self.use_trash_bin_var.get())
|
||||
self.config_manager.set_setting(
|
||||
"no_trash_bin", self.no_trash_bin_var.get())
|
||||
|
||||
if self.app_instance:
|
||||
self.app_instance.update_backup_options_from_config()
|
||||
@@ -424,8 +471,8 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
self.app_instance.animated_icon = AnimatedIcon(
|
||||
self.app_instance.action_frame, width=20, height=20, use_pillow=True, bg=bg_color, animation_type=initial_animation_type)
|
||||
|
||||
self.app_instance.animated_icon.pack(
|
||||
side=tk.LEFT, padx=5, before=self.app_instance.task_progress)
|
||||
self.app_instance.animated_icon.grid(
|
||||
row=0, column=0, rowspan=2, padx=5)
|
||||
|
||||
self.app_instance.animated_icon.stop("DISABLE")
|
||||
self.app_instance.animated_icon.animation_type = backup_animation_type
|
||||
@@ -472,7 +519,6 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
for item in self.manual_excludes_listbox.get(0, tk.END):
|
||||
f.write(f"{item}\n")
|
||||
|
||||
# Instead of destroying the Toplevel, hide this frame and show main settings
|
||||
self.pack_forget()
|
||||
if self.show_main_settings_callback:
|
||||
self.show_main_settings_callback()
|
||||
@@ -484,7 +530,6 @@ class AdvancedSettingsFrame(ttk.Frame):
|
||||
current_source)
|
||||
|
||||
def _cancel_changes(self):
|
||||
# Hide this frame and show main settings without applying changes
|
||||
self.pack_forget()
|
||||
if self.show_main_settings_callback:
|
||||
self.show_main_settings_callback()
|
||||
|
@@ -6,7 +6,6 @@ from core.pbp_app_config import Msg
|
||||
from pyimage_ui.system_backup_content_frame import SystemBackupContentFrame
|
||||
from pyimage_ui.user_backup_content_frame import UserBackupContentFrame
|
||||
from shared_libs.logger import app_logger
|
||||
from shared_libs.message import MessageDialog
|
||||
|
||||
|
||||
class BackupContentFrame(ttk.Frame):
|
||||
@@ -119,8 +118,6 @@ class BackupContentFrame(ttk.Frame):
|
||||
|
||||
def _switch_view(self, index):
|
||||
self.current_view_index = index
|
||||
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
|
||||
self.app.config_manager.set_setting(config_key, index)
|
||||
self.update_nav_buttons(index)
|
||||
|
||||
if index == 0:
|
||||
@@ -141,7 +138,7 @@ class BackupContentFrame(ttk.Frame):
|
||||
button.configure(style="Gray.Toolbutton")
|
||||
self.nav_progress_bars[i].pack_forget()
|
||||
|
||||
def show(self, backup_path):
|
||||
def show(self, backup_path, initial_tab_index=0):
|
||||
app_logger.log(
|
||||
f"BackupContentFrame: show called with path {backup_path}")
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
@@ -151,21 +148,7 @@ class BackupContentFrame(ttk.Frame):
|
||||
# Check if the destination is encrypted and trigger mount if necessary
|
||||
is_encrypted = self.backup_manager.encryption_manager.is_encrypted(
|
||||
backup_path)
|
||||
if is_encrypted and not self.backup_manager.encryption_manager.is_mounted(backup_path):
|
||||
app_logger.log(
|
||||
"Encrypted destination is not mounted. Attempting to mount.")
|
||||
mount_point = self.backup_manager.encryption_manager.mount(
|
||||
backup_path)
|
||||
if not mount_point:
|
||||
app_logger.log("Mount failed. Cannot display backup content.")
|
||||
MessageDialog(
|
||||
message_type="error", title=Msg.STR["error"], text=Msg.STR["err_unlock_failed"])
|
||||
# Clear views and return if mount fails
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
return
|
||||
# Refresh header status after successful mount
|
||||
self.app.header_frame.refresh_status()
|
||||
self.viewing_encrypted = is_encrypted # Set this flag for remembering the view
|
||||
|
||||
pybackup_dir = os.path.join(backup_path, "pybackup")
|
||||
|
||||
@@ -177,7 +160,8 @@ class BackupContentFrame(ttk.Frame):
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
return
|
||||
|
||||
all_backups = self.backup_manager.list_all_backups(backup_path)
|
||||
all_backups = self.backup_manager.list_all_backups(
|
||||
backup_path, mount_if_needed=True)
|
||||
if all_backups:
|
||||
system_backups, user_backups = all_backups
|
||||
self.system_backups_frame.show(backup_path, system_backups)
|
||||
@@ -187,9 +171,8 @@ class BackupContentFrame(ttk.Frame):
|
||||
self.system_backups_frame.show(backup_path, [])
|
||||
self.user_backups_frame.show(backup_path, [])
|
||||
|
||||
config_key = "last_encrypted_backup_content_view" if self.viewing_encrypted else "last_backup_content_view"
|
||||
last_view = self.app.config_manager.get_setting(config_key, 0)
|
||||
self._switch_view(last_view)
|
||||
# Use the passed index to switch to the correct view
|
||||
self.after(10, lambda: self._switch_view(initial_tab_index))
|
||||
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
|
||||
class CommentEditorDialog(tk.Toplevel):
|
||||
def __init__(self, master, info_file_path, backup_manager):
|
||||
super().__init__(master)
|
||||
@@ -13,17 +14,20 @@ class CommentEditorDialog(tk.Toplevel):
|
||||
main_frame = ttk.Frame(self, padding=10)
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self.text_widget = tk.Text(main_frame, wrap="word", height=10, width=40)
|
||||
self.text_widget = tk.Text(
|
||||
main_frame, wrap="word", height=10, width=40)
|
||||
self.text_widget.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack(fill=tk.X)
|
||||
|
||||
ttk.Button(button_frame, text="Speichern & Schließen", command=self._save_and_close).pack(side=tk.RIGHT)
|
||||
ttk.Button(button_frame, text="Abbrechen", command=self.destroy).pack(side=tk.RIGHT, padx=5)
|
||||
ttk.Button(button_frame, text="Speichern & Schließen",
|
||||
command=self._save_and_close).pack(side=tk.RIGHT)
|
||||
ttk.Button(button_frame, text="Abbrechen",
|
||||
command=self.destroy).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
self._load_comment()
|
||||
|
||||
|
||||
self.transient(master)
|
||||
self.grab_set()
|
||||
self.wait_window(self)
|
||||
|
@@ -166,7 +166,7 @@ class Drawing:
|
||||
if self.app.right_calculation_thread and self.app.right_calculation_thread.is_alive():
|
||||
self.app.right_calculation_stop_event.set()
|
||||
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
path_to_calculate = self.app.right_canvas_data.get('path_display')
|
||||
if path_to_calculate and os.path.isdir(path_to_calculate):
|
||||
self.app.right_canvas_data['calculating'] = True
|
||||
@@ -213,7 +213,7 @@ class Drawing:
|
||||
self.redraw_right_canvas_restore()
|
||||
|
||||
if not self.app.left_canvas_data.get('calculating', False):
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
self.app.start_cancel_button.config(state="normal")
|
||||
|
||||
if not stop_event.is_set():
|
||||
self.app.after(0, update_ui)
|
||||
@@ -240,46 +240,53 @@ class Drawing:
|
||||
required_space *= 2 # Double the space for compression process
|
||||
|
||||
projected_total_used = self.app.destination_used_bytes + required_space
|
||||
|
||||
|
||||
if self.app.destination_total_bytes > 0:
|
||||
projected_total_percentage = projected_total_used / self.app.destination_total_bytes
|
||||
projected_total_percentage = projected_total_used / \
|
||||
self.app.destination_total_bytes
|
||||
else:
|
||||
projected_total_percentage = 0
|
||||
|
||||
info_font = (AppConfig.UI_CONFIG["font_family"], 10, "bold")
|
||||
info_messages = []
|
||||
|
||||
# First, check for critical space issues
|
||||
if projected_total_used > self.app.destination_total_bytes or projected_total_percentage >= 0.95:
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#D32F2F", outline="") # Red bar
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
|
||||
), fill="#D32F2F", outline="") # Red bar
|
||||
info_messages.append(Msg.STR["warning_not_enough_space"])
|
||||
|
||||
|
||||
elif projected_total_percentage >= 0.90:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(), fill="#E8740C", outline="") # Orange bar
|
||||
self.app.start_cancel_button.config(state="normal")
|
||||
canvas.create_rectangle(0, 0, canvas_width, canvas.winfo_height(
|
||||
), fill="#E8740C", outline="") # Orange bar
|
||||
info_messages.append(Msg.STR["warning_space_over_90_percent"])
|
||||
|
||||
|
||||
else:
|
||||
# Only enable the button if the source is not larger than the partition itself
|
||||
if not self.app.source_larger_than_partition:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
self.app.start_cancel_button.config(state="normal")
|
||||
else:
|
||||
self.app.start_pause_button.config(state="disabled")
|
||||
self.app.start_cancel_button.config(state="disabled")
|
||||
|
||||
used_percentage = self.app.destination_used_bytes / self.app.destination_total_bytes
|
||||
used_percentage = self.app.destination_used_bytes / \
|
||||
self.app.destination_total_bytes
|
||||
used_width = canvas_width * used_percentage
|
||||
canvas.create_rectangle(0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
canvas.create_rectangle(
|
||||
0, 0, used_width, canvas.winfo_height(), fill="#0078d7", outline="")
|
||||
|
||||
# Draw the projected part only if there is space
|
||||
projected_percentage = self.app.source_size_bytes / self.app.destination_total_bytes
|
||||
projected_percentage = self.app.source_size_bytes / \
|
||||
self.app.destination_total_bytes
|
||||
projected_width = canvas_width * projected_percentage
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width, canvas.winfo_height(), fill="#50E6FF", outline="")
|
||||
canvas.create_rectangle(used_width, 0, used_width + projected_width,
|
||||
canvas.winfo_height(), fill="#50E6FF", outline="")
|
||||
|
||||
# Add other informational messages if no critical warnings are present
|
||||
if not info_messages:
|
||||
if self.app.source_larger_than_partition:
|
||||
info_messages.append(Msg.STR["warning_source_larger_than_partition"])
|
||||
info_messages.append(
|
||||
Msg.STR["warning_source_larger_than_partition"])
|
||||
elif self.app.is_first_backup:
|
||||
info_messages.append(Msg.STR["ready_for_first_backup"])
|
||||
elif self.app.mode == "backup":
|
||||
@@ -287,7 +294,8 @@ class Drawing:
|
||||
else:
|
||||
info_messages.append(Msg.STR["restore_mode_info"])
|
||||
|
||||
self.app.info_label.config(text="\n".join(info_messages), font=info_font)
|
||||
self.app.info_label.config(
|
||||
text="\n".join(info_messages), font=("Helvetica", 14))
|
||||
|
||||
self.app.target_size_label.config(
|
||||
text=f"{projected_total_used / (1024**3):.2f} GB / {self.app.destination_total_bytes / (1024**3):.2f} GB")
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from shared_libs.message import MessageDialog
|
||||
import keyring
|
||||
|
||||
|
||||
@@ -20,9 +19,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.keyring_usage_label.grid(
|
||||
row=4, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
self.check_keyring_availability()
|
||||
|
||||
@@ -51,7 +51,8 @@ class EncryptionFrame(ttk.Frame):
|
||||
clear_password_button.grid(row=2, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
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)
|
||||
self.status_message_label.grid(
|
||||
row=3, column=0, sticky="ew", padx=10, pady=5)
|
||||
|
||||
def set_context(self, username):
|
||||
self.username = username
|
||||
@@ -75,39 +76,47 @@ class EncryptionFrame(ttk.Frame):
|
||||
def set_session_password(self):
|
||||
password = self.password_entry.get()
|
||||
if not password:
|
||||
self.status_message_label.config(text="Password cannot be empty.", foreground="red")
|
||||
self.status_message_label.config(
|
||||
text="Password cannot be empty.", foreground="red")
|
||||
return
|
||||
|
||||
self.encryption_manager.set_session_password(password, self.save_to_keyring_var.get())
|
||||
|
||||
self.encryption_manager.set_session_password(
|
||||
password, self.save_to_keyring_var.get())
|
||||
|
||||
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")
|
||||
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.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")
|
||||
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")
|
||||
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
|
||||
self.keyring_usage_label.config(
|
||||
text=f'No password for "{self.username}" found in the keyring.', foreground="orange")
|
||||
|
@@ -2,7 +2,8 @@ import tkinter as tk
|
||||
import os
|
||||
|
||||
from core.pbp_app_config import Msg
|
||||
from shared_libs.common_tools import IconManager
|
||||
from shared_libs.logger import app_logger
|
||||
|
||||
|
||||
class HeaderFrame(tk.Frame):
|
||||
def __init__(self, container, image_manager, encryption_manager, app, **kwargs):
|
||||
@@ -62,20 +63,32 @@ class HeaderFrame(tk.Frame):
|
||||
font=("Helvetica", 10, "bold"),
|
||||
bg="#455A64",
|
||||
)
|
||||
self.keyring_status_label.grid(row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
|
||||
|
||||
self.keyring_status_label.grid(
|
||||
row=0, column=0, sticky="ne", padx=(10, 10), pady=(10, 0))
|
||||
|
||||
self.refresh_status()
|
||||
|
||||
def refresh_status(self):
|
||||
"""Checks the keyring status based on the current destination and updates the label."""
|
||||
app_logger.log("HeaderFrame: Refreshing status...")
|
||||
dest_path = self.app.destination_path
|
||||
app_logger.log(f"HeaderFrame: Destination path is '{dest_path}'")
|
||||
|
||||
if not dest_path or not self.encryption_manager.is_encrypted(dest_path):
|
||||
self.keyring_status_label.config(text="") # Clear status if not encrypted
|
||||
app_logger.log(
|
||||
"HeaderFrame: No destination path or not encrypted. Clearing status.")
|
||||
# Clear status if not encrypted
|
||||
self.keyring_status_label.config(text="")
|
||||
return
|
||||
|
||||
app_logger.log("HeaderFrame: Destination is encrypted.")
|
||||
username = os.path.basename(dest_path.rstrip('/'))
|
||||
app_logger.log(f"HeaderFrame: Username is '{username}'")
|
||||
|
||||
if self.encryption_manager.is_mounted(dest_path):
|
||||
is_mounted = self.encryption_manager.is_mounted(dest_path)
|
||||
app_logger.log(f"HeaderFrame: Is mounted? {is_mounted}")
|
||||
|
||||
if is_mounted:
|
||||
status_text = "Key: In Use"
|
||||
auth_method = getattr(self.encryption_manager, 'auth_method', None)
|
||||
if auth_method == 'keyring':
|
||||
@@ -86,18 +99,27 @@ class HeaderFrame(tk.Frame):
|
||||
text=status_text,
|
||||
fg="#2E8B57" # SeaGreen
|
||||
)
|
||||
elif self.encryption_manager.is_key_in_keyring(username):
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyring)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
elif os.path.exists(self.encryption_manager.get_key_file_path(dest_path)):
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyfile)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
else:
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Not Available",
|
||||
fg="#A9A9A9" # DarkGray
|
||||
)
|
||||
key_in_keyring = self.encryption_manager.is_key_in_keyring(
|
||||
username)
|
||||
app_logger.log(f"HeaderFrame: Key in keyring? {key_in_keyring}")
|
||||
key_file_exists = os.path.exists(
|
||||
self.encryption_manager.get_key_file_path(dest_path))
|
||||
app_logger.log(f"HeaderFrame: Key file exists? {key_file_exists}")
|
||||
|
||||
if key_in_keyring:
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyring)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
elif key_file_exists:
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Available (Keyfile)",
|
||||
fg="#FFD700" # Gold
|
||||
)
|
||||
else:
|
||||
self.keyring_status_label.config(
|
||||
text="Key: Not Available",
|
||||
fg="#A9A9A9" # DarkGray
|
||||
)
|
||||
app_logger.log("HeaderFrame: Status refresh complete.")
|
||||
|
@@ -105,15 +105,13 @@ class Navigation:
|
||||
self.app.restore_size_frame_after.grid_remove()
|
||||
self.app.mode_button_icon = self.app.image_manager.get_icon(
|
||||
"forward_extralarge")
|
||||
self.app.info_label.config(text=Msg.STR["backup_mode_info"])
|
||||
self.app._update_info_label(Msg.STR["backup_mode"])
|
||||
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.bypass_security_cb.config(
|
||||
state='disabled') # This one is mode-dependent
|
||||
# Let the central config function handle the state of these checkboxes
|
||||
self.app.update_backup_options_from_config()
|
||||
else: # restore
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
@@ -121,7 +119,7 @@ class Navigation:
|
||||
self.app.restore_size_frame_after.grid()
|
||||
self.app.mode_button_icon = self.app.image_manager.get_icon(
|
||||
"back_extralarge")
|
||||
self.app.info_label.config(text=Msg.STR["restore_mode_info"])
|
||||
self.app._update_info_label(Msg.STR["restore"])
|
||||
self.app.full_backup_cb.config(state='disabled')
|
||||
self.app.incremental_cb.config(state='disabled')
|
||||
self.app.compressed_cb.config(state='disabled')
|
||||
@@ -135,6 +133,8 @@ class Navigation:
|
||||
self.app.drawing.redraw_right_canvas()
|
||||
|
||||
def toggle_mode(self, mode=None, active_index=None, trigger_calculation=True):
|
||||
if self.app.refresh_log_var.get():
|
||||
self.app.log_window.clear_log()
|
||||
if self.app.backup_is_running:
|
||||
# If a backup is running, we only want to switch the view to the main backup screen.
|
||||
# We don't reset anything.
|
||||
@@ -142,7 +142,6 @@ class Navigation:
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
|
||||
|
||||
# Show the main content frames
|
||||
self.app.canvas_frame.grid()
|
||||
@@ -187,7 +186,7 @@ class Navigation:
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
|
||||
|
||||
self.app.canvas_frame.grid()
|
||||
self.app.source_size_frame.grid()
|
||||
self.app.target_size_frame.grid()
|
||||
@@ -234,6 +233,8 @@ class Navigation:
|
||||
self._update_task_bar_visibility("log")
|
||||
|
||||
def toggle_scheduler_frame(self, active_index=None):
|
||||
if self.app.refresh_log_var.get():
|
||||
self.app.log_window.clear_log()
|
||||
self._cancel_calculation()
|
||||
if active_index is not None:
|
||||
self.app.drawing.update_nav_buttons(active_index)
|
||||
@@ -242,7 +243,7 @@ class Navigation:
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.settings_frame.hide()
|
||||
self.app.backup_content_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()
|
||||
@@ -252,6 +253,8 @@ class Navigation:
|
||||
self._update_task_bar_visibility("scheduler")
|
||||
|
||||
def toggle_settings_frame(self, active_index=None):
|
||||
if self.app.refresh_log_var.get():
|
||||
self.app.log_window.clear_log()
|
||||
self._cancel_calculation()
|
||||
if active_index is not None:
|
||||
self.app.drawing.update_nav_buttons(active_index)
|
||||
@@ -260,7 +263,7 @@ class Navigation:
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.backup_content_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
|
||||
|
||||
self.app.source_size_frame.grid_remove()
|
||||
self.app.target_size_frame.grid_remove()
|
||||
self.app.restore_size_frame_before.grid_remove()
|
||||
@@ -269,40 +272,41 @@ class Navigation:
|
||||
self.app.top_bar.grid()
|
||||
self._update_task_bar_visibility("settings")
|
||||
|
||||
def toggle_backup_content_frame(self, active_index=None):
|
||||
# Accept argument but ignore it
|
||||
def toggle_backup_content_frame(self, _=None):
|
||||
if self.app.refresh_log_var.get():
|
||||
self.app.log_window.clear_log()
|
||||
self._cancel_calculation()
|
||||
if active_index is not None:
|
||||
self.app.drawing.update_nav_buttons(active_index)
|
||||
self.app.drawing.update_nav_buttons(2) # Index 2 for Backup Content
|
||||
|
||||
if not self.app.destination_path:
|
||||
MessageDialog(master=self.app, message_type="info",
|
||||
MessageDialog(message_type="info",
|
||||
title=Msg.STR["info_menu"], text=Msg.STR["err_no_dest_folder"])
|
||||
self.toggle_mode("backup", 0)
|
||||
return
|
||||
|
||||
# Mount the destination if it is encrypted and not already mounted
|
||||
if self.app.backup_manager.encryption_manager.is_encrypted(self.app.destination_path):
|
||||
if not self.app.backup_manager.encryption_manager.is_mounted(self.app.destination_path):
|
||||
mount_point = self.app.backup_manager.encryption_manager.mount(self.app.destination_path)
|
||||
if not mount_point:
|
||||
MessageDialog(master=self.app, message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR.get("err_mount_failed", "Mounting failed."))
|
||||
self.toggle_mode("backup", 0)
|
||||
return
|
||||
|
||||
self.app.header_frame.refresh_status()
|
||||
|
||||
self.app.canvas_frame.grid_remove()
|
||||
self.app.log_frame.grid_remove()
|
||||
self.app.scheduler_frame.hide()
|
||||
self.app.settings_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.backup_content_frame.show(self.app.destination_path)
|
||||
|
||||
if self.app.next_backup_content_view == 'user':
|
||||
initial_tab_index = 1
|
||||
else: # Default to system
|
||||
initial_tab_index = 0
|
||||
|
||||
self.app.backup_content_frame.show(
|
||||
self.app.destination_path, initial_tab_index)
|
||||
|
||||
# Reset to default for next time
|
||||
self.app.next_backup_content_view = 'system'
|
||||
|
||||
self.app.top_bar.grid()
|
||||
self._update_task_bar_visibility("scheduler")
|
||||
|
||||
|
||||
|
@@ -1,63 +0,0 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
class PasswordDialog(tk.Toplevel):
|
||||
def __init__(self, parent, title="Password Required", confirm=True):
|
||||
super().__init__(parent)
|
||||
self.title(title)
|
||||
self.parent = parent
|
||||
self.password = None
|
||||
self.save_to_keyring = tk.BooleanVar()
|
||||
self.confirm = confirm
|
||||
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
ttk.Label(self, text="Please enter the password for the encrypted backup:").pack(padx=20, pady=10)
|
||||
self.password_entry = ttk.Entry(self, show="*")
|
||||
self.password_entry.pack(padx=20, pady=5, fill="x", expand=True)
|
||||
self.password_entry.focus_set()
|
||||
|
||||
if self.confirm:
|
||||
ttk.Label(self, text="Confirm password:").pack(padx=20, pady=10)
|
||||
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)
|
||||
|
||||
ok_button = ttk.Button(button_frame, text="OK", command=self.on_ok)
|
||||
ok_button.pack(side="left", padx=5)
|
||||
cancel_button = ttk.Button(button_frame, text="Cancel", command=self.on_cancel)
|
||||
cancel_button.pack(side="left", padx=5)
|
||||
|
||||
self.bind("<Return>", lambda event: self.on_ok())
|
||||
self.bind("<Escape>", lambda event: self.on_cancel())
|
||||
|
||||
self.wait_window(self)
|
||||
|
||||
def on_ok(self):
|
||||
password = self.password_entry.get()
|
||||
|
||||
if not password:
|
||||
messagebox.showerror("Error", "Password cannot be empty.", parent=self)
|
||||
return
|
||||
|
||||
if self.confirm:
|
||||
confirm = self.confirm_entry.get()
|
||||
if password != confirm:
|
||||
messagebox.showerror("Error", "Passwords do not match.", parent=self)
|
||||
return
|
||||
|
||||
self.password = password
|
||||
self.destroy()
|
||||
|
||||
def on_cancel(self):
|
||||
self.password = None
|
||||
self.destroy()
|
||||
|
||||
def get_password(self):
|
||||
return self.password, self.save_to_keyring.get()
|
@@ -5,6 +5,7 @@ import os
|
||||
from shared_libs.custom_file_dialog import CustomFileDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
from core.pbp_app_config import Msg
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
|
||||
|
||||
class SchedulerFrame(ttk.Frame):
|
||||
@@ -17,7 +18,8 @@ class SchedulerFrame(ttk.Frame):
|
||||
self, text=Msg.STR["scheduled_jobs"], padding=10)
|
||||
self.jobs_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("active", "type", "frequency", "destination", "sources")
|
||||
columns = ("active", "type", "frequency",
|
||||
"destination", "sources", "options")
|
||||
self.jobs_tree = ttk.Treeview(
|
||||
self.jobs_frame, columns=columns, show="headings")
|
||||
self.jobs_tree.heading("active", text=Msg.STR["active"])
|
||||
@@ -25,6 +27,7 @@ class SchedulerFrame(ttk.Frame):
|
||||
self.jobs_tree.heading("frequency", text=Msg.STR["frequency"])
|
||||
self.jobs_tree.heading("destination", text=Msg.STR["destination"])
|
||||
self.jobs_tree.heading("sources", text=Msg.STR["sources"])
|
||||
self.jobs_tree.heading("options", text=Msg.STR["backup_options"])
|
||||
for col in columns:
|
||||
self.jobs_tree.column(col, width=100, anchor="center")
|
||||
self.jobs_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
@@ -51,20 +54,56 @@ class SchedulerFrame(ttk.Frame):
|
||||
}
|
||||
self.frequency = tk.StringVar(value="daily")
|
||||
|
||||
self.backup_type_system_var = tk.BooleanVar(value=True)
|
||||
self.backup_type_user_var = tk.BooleanVar(value=False)
|
||||
|
||||
self.freq_daily_var = tk.BooleanVar(value=True)
|
||||
self.freq_weekly_var = tk.BooleanVar(value=False)
|
||||
self.freq_monthly_var = tk.BooleanVar(value=False)
|
||||
|
||||
type_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["backup_type"], padding=10)
|
||||
type_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type,
|
||||
value="system", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
ttk.Radiobutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type,
|
||||
value="user", command=self._toggle_user_sources).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(type_frame, text=Msg.STR["system_backup_menu"], variable=self.backup_type_system_var,
|
||||
style="Switch.TCheckbutton", command=lambda: self._handle_backup_type_switch("system")).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(type_frame, text=Msg.STR["user_backup_menu"], variable=self.backup_type_user_var,
|
||||
style="Switch.TCheckbutton", command=lambda: self._handle_backup_type_switch("user")).pack(anchor=tk.W)
|
||||
|
||||
# Container for source folders and backup options
|
||||
source_options_container = ttk.Frame(self.add_job_frame)
|
||||
source_options_container.pack(fill=tk.X, padx=5, pady=5)
|
||||
source_options_container.columnconfigure(0, weight=1)
|
||||
source_options_container.columnconfigure(1, weight=1)
|
||||
|
||||
self.user_sources_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["source_folders"], padding=10)
|
||||
self.user_sources_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
source_options_container, text=Msg.STR["source_folders"], padding=10)
|
||||
self.user_sources_frame.grid(
|
||||
row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
for name, var in self.user_sources.items():
|
||||
ttk.Checkbutton(self.user_sources_frame, text=name,
|
||||
variable=var).pack(anchor=tk.W)
|
||||
variable=var, style="Switch.TCheckbutton").pack(anchor=tk.W)
|
||||
|
||||
options_frame = ttk.LabelFrame(
|
||||
source_options_container, text=Msg.STR["backup_options"], padding=10)
|
||||
options_frame.grid(row=0, column=1, sticky="nsew", padx=5, pady=5)
|
||||
|
||||
self.full_var = tk.BooleanVar(value=True)
|
||||
self.incremental_var = tk.BooleanVar(value=False)
|
||||
self.compress_var = tk.BooleanVar(value=False)
|
||||
self.encrypt_var = tk.BooleanVar(value=False)
|
||||
|
||||
self.full_checkbutton = ttk.Checkbutton(options_frame, text=Msg.STR["full_backup"], variable=self.full_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.full_var, self.incremental_var, self.full_var.get()), style="Switch.TCheckbutton")
|
||||
self.full_checkbutton.pack(anchor=tk.W)
|
||||
self.incremental_checkbutton = ttk.Checkbutton(options_frame, text=Msg.STR["incremental_backup"], variable=self.incremental_var, command=lambda: enforce_backup_type_exclusivity(
|
||||
self.incremental_var, self.full_var, self.incremental_var.get()), style="Switch.TCheckbutton")
|
||||
self.incremental_checkbutton.pack(anchor=tk.W)
|
||||
self.compress_checkbutton = ttk.Checkbutton(
|
||||
options_frame, text=Msg.STR["compression"], variable=self.compress_var, command=self._on_compression_toggle_scheduler, style="Switch.TCheckbutton")
|
||||
self.compress_checkbutton.pack(anchor=tk.W)
|
||||
self.encrypt_checkbutton = ttk.Checkbutton(
|
||||
options_frame, text=Msg.STR["encryption"], variable=self.encrypt_var, style="Switch.TCheckbutton")
|
||||
self.encrypt_checkbutton.pack(anchor=tk.W)
|
||||
|
||||
dest_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["dest_folder"], padding=10)
|
||||
@@ -77,12 +116,12 @@ class SchedulerFrame(ttk.Frame):
|
||||
freq_frame = ttk.LabelFrame(
|
||||
self.add_job_frame, text=Msg.STR["frequency"], padding=10)
|
||||
freq_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_daily"],
|
||||
variable=self.frequency, value="daily").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_weekly"],
|
||||
variable=self.frequency, value="weekly").pack(anchor=tk.W)
|
||||
ttk.Radiobutton(freq_frame, text=Msg.STR["freq_monthly"],
|
||||
variable=self.frequency, value="monthly").pack(anchor=tk.W)
|
||||
ttk.Checkbutton(freq_frame, text=Msg.STR["freq_daily"], variable=self.freq_daily_var,
|
||||
style="Switch.TCheckbutton", command=lambda: self._handle_freq_switch("daily")).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(freq_frame, text=Msg.STR["freq_weekly"], variable=self.freq_weekly_var,
|
||||
style="Switch.TCheckbutton", command=lambda: self._handle_freq_switch("weekly")).pack(anchor=tk.W)
|
||||
ttk.Checkbutton(freq_frame, text=Msg.STR["freq_monthly"], variable=self.freq_monthly_var,
|
||||
style="Switch.TCheckbutton", command=lambda: self._handle_freq_switch("monthly")).pack(anchor=tk.W)
|
||||
|
||||
add_button_frame = ttk.Frame(self.add_job_frame)
|
||||
add_button_frame.pack(pady=10)
|
||||
@@ -97,6 +136,34 @@ class SchedulerFrame(ttk.Frame):
|
||||
# Initially, hide the add_job_frame
|
||||
self.add_job_frame.pack_forget()
|
||||
|
||||
def _handle_backup_type_switch(self, changed_var):
|
||||
if changed_var == "system":
|
||||
if self.backup_type_system_var.get():
|
||||
self.backup_type_user_var.set(False)
|
||||
self.backup_type.set("system")
|
||||
else:
|
||||
# Prevent unsetting both
|
||||
self.backup_type_system_var.set(True)
|
||||
elif changed_var == "user":
|
||||
if self.backup_type_user_var.get():
|
||||
self.backup_type_system_var.set(False)
|
||||
self.backup_type.set("user")
|
||||
else:
|
||||
self.backup_type_user_var.set(True)
|
||||
self._toggle_user_sources()
|
||||
|
||||
def _handle_freq_switch(self, changed_var):
|
||||
vars = {"daily": self.freq_daily_var,
|
||||
"weekly": self.freq_weekly_var, "monthly": self.freq_monthly_var}
|
||||
if vars[changed_var].get():
|
||||
self.frequency.set(changed_var)
|
||||
for var_name, var_obj in vars.items():
|
||||
if var_name != changed_var:
|
||||
var_obj.set(False)
|
||||
else:
|
||||
# Prevent unsetting all
|
||||
vars[changed_var].set(True)
|
||||
|
||||
def show(self):
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
self._load_scheduled_jobs()
|
||||
@@ -104,13 +171,34 @@ class SchedulerFrame(ttk.Frame):
|
||||
def hide(self):
|
||||
self.grid_remove()
|
||||
|
||||
def _on_compression_toggle_scheduler(self):
|
||||
if self.compress_var.get():
|
||||
self.incremental_var.set(False)
|
||||
self.incremental_checkbutton.config(state="disabled")
|
||||
|
||||
self.encrypt_var.set(False)
|
||||
self.encrypt_checkbutton.config(state="disabled")
|
||||
else:
|
||||
self.incremental_checkbutton.config(state="normal")
|
||||
self.encrypt_checkbutton.config(state="normal")
|
||||
|
||||
def _toggle_scheduler_view(self):
|
||||
if self.jobs_frame.winfo_ismapped():
|
||||
self.jobs_frame.pack_forget()
|
||||
self.add_job_frame.pack(fill=tk.BOTH, expand=True)
|
||||
# Reset or load default values for new job
|
||||
self.full_var.set(True)
|
||||
self.incremental_var.set(False)
|
||||
self.compress_var.set(False)
|
||||
self.encrypt_var.set(False)
|
||||
self.destination.set("")
|
||||
for var in self.user_sources.values():
|
||||
var.set(False)
|
||||
self._on_compression_toggle_scheduler() # Update state of incremental checkbox
|
||||
else:
|
||||
self.add_job_frame.pack_forget()
|
||||
self.jobs_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self._load_scheduled_jobs()
|
||||
|
||||
def _toggle_user_sources(self):
|
||||
state = "normal" if self.backup_type.get() == "user" else "disabled"
|
||||
@@ -127,7 +215,7 @@ class SchedulerFrame(ttk.Frame):
|
||||
def _save_job(self):
|
||||
dest = self.destination.get()
|
||||
if not dest:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_dest_folder"])
|
||||
return
|
||||
|
||||
@@ -139,22 +227,34 @@ class SchedulerFrame(ttk.Frame):
|
||||
job_sources = [name for name,
|
||||
var in self.user_sources.items() if var.get()]
|
||||
if not job_sources:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_source_folder"])
|
||||
return
|
||||
|
||||
script_path = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), "..", "main_app.py"))
|
||||
os.path.dirname(__file__), "..", "pybackup-cli.py"))
|
||||
command = f"python3 {script_path} --backup-type {job_type} --destination \"{dest}\""
|
||||
|
||||
if self.full_var.get():
|
||||
command += " --full"
|
||||
if self.incremental_var.get():
|
||||
command += " --incremental"
|
||||
if self.compress_var.get():
|
||||
command += " --compress"
|
||||
if self.encrypt_var.get():
|
||||
command += " --encrypt"
|
||||
|
||||
if job_type == "user":
|
||||
command += f" --sources "
|
||||
for s in job_sources:
|
||||
command += f'\"{s}\" ' # This line has an issue with escaping
|
||||
command += f'\"{s}\" '
|
||||
|
||||
comment = f"{self.backup_manager.app_tag}; type:{job_type}; freq:{job_frequency}; dest:{dest}"
|
||||
if job_type == "user":
|
||||
comment += f"; sources:{','.join(job_sources)}"
|
||||
|
||||
comment += f"; full:{self.full_var.get()}; incremental:{self.incremental_var.get()}; compress:{self.compress_var.get()}; encrypt:{self.encrypt_var.get()}"
|
||||
|
||||
job_details = {
|
||||
"command": command,
|
||||
"comment": comment,
|
||||
@@ -172,15 +272,25 @@ class SchedulerFrame(ttk.Frame):
|
||||
self.jobs_tree.delete(i)
|
||||
jobs = self.backup_manager.get_scheduled_jobs()
|
||||
for job in jobs:
|
||||
options = []
|
||||
if job.get("full"):
|
||||
options.append("Full")
|
||||
if job.get("incremental"):
|
||||
options.append("Incremental")
|
||||
if job.get("compress"):
|
||||
options.append("Compressed")
|
||||
if job.get("encrypt"):
|
||||
options.append("Encrypted")
|
||||
|
||||
self.jobs_tree.insert("", "end", values=(
|
||||
job["active"], job["type"], job["frequency"], job["destination"], ", ".join(
|
||||
job["sources"])
|
||||
job["sources"]), ", ".join(options)
|
||||
), iid=job["id"])
|
||||
|
||||
def _remove_selected_job(self):
|
||||
selected_item = self.jobs_tree.focus()
|
||||
if not selected_item:
|
||||
MessageDialog(master=self, message_type="error",
|
||||
MessageDialog(message_type="error",
|
||||
title=Msg.STR["error"], text=Msg.STR["err_no_job_selected"])
|
||||
return
|
||||
|
||||
|
@@ -1,27 +1,87 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
from shared_libs.message import MessageDialog, PasswordDialog
|
||||
|
||||
|
||||
class SettingsFrame(ttk.Frame):
|
||||
def __init__(self, master, navigation, actions, **kwargs):
|
||||
def __init__(self, master, navigation, actions, encryption_manager, image_manager, config_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self.navigation = navigation
|
||||
self.actions = actions
|
||||
self.encryption_manager = encryption_manager
|
||||
self.image_manager = image_manager
|
||||
self.config_manager = config_manager
|
||||
|
||||
self.pbp_app_config = AppConfig()
|
||||
self.user_exclude_patterns = []
|
||||
|
||||
# --- Action Buttons ---
|
||||
self.button_frame = ttk.Frame(self)
|
||||
self.button_frame.pack(fill=tk.X, padx=10)
|
||||
ttk.Separator(self.button_frame, orient=tk.HORIZONTAL).pack(
|
||||
fill=tk.X, pady=(0, 5))
|
||||
self.show_hidden_button = ttk.Button(
|
||||
self.button_frame, command=self._toggle_hidden_files_view, style="Gray.Toolbutton")
|
||||
self.show_hidden_button.pack(side=tk.LEFT)
|
||||
self.unhide_icon = self.image_manager.get_icon(
|
||||
'hide')
|
||||
self.hide_icon = self.image_manager.get_icon(
|
||||
'unhide')
|
||||
self.show_hidden_button.config(image=self.unhide_icon)
|
||||
|
||||
ttk.Separator(self.button_frame, orient=tk.VERTICAL).pack(
|
||||
side=tk.LEFT, ipady=15, padx=5)
|
||||
|
||||
add_to_exclude_button = ttk.Button(
|
||||
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)
|
||||
|
||||
advanced_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings, style="Gray.Toolbutton")
|
||||
advanced_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
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")
|
||||
hard_reset_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)
|
||||
|
||||
# --- Container for Treeviews ---
|
||||
self.trees_container = ttk.Frame(self)
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
||||
|
||||
# --- Bottom Buttons ---
|
||||
self.bottom_button_frame = ttk.Frame(self)
|
||||
self.bottom_button_frame.pack(pady=10)
|
||||
|
||||
apply_button = ttk.Button(
|
||||
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(
|
||||
@@ -61,69 +121,100 @@ class SettingsFrame(ttk.Frame):
|
||||
self.hidden_tree.bind("<Button-1>", self._toggle_include_status_hidden)
|
||||
self.hidden_tree_frame.pack_forget() # Initially hidden
|
||||
|
||||
# --- Action Buttons ---
|
||||
self.button_frame = ttk.Frame(self)
|
||||
self.button_frame.pack(fill=tk.X, padx=10, pady=10)
|
||||
# --- Hard Reset Frame (initially hidden) ---
|
||||
self.hard_reset_frame = ttk.LabelFrame(
|
||||
self, text=Msg.STR["full_delete_config_settings"], padding=10)
|
||||
|
||||
self.show_hidden_button = ttk.Button(
|
||||
self.button_frame, command=self._toggle_hidden_files_view, style="TButton.Borderless.Round")
|
||||
self.show_hidden_button.pack(side=tk.LEFT)
|
||||
self.unhide_icon = self.master.master.master.image_manager.get_icon(
|
||||
'hide')
|
||||
self.hide_icon = self.master.master.master.image_manager.get_icon(
|
||||
'unhide')
|
||||
self.show_hidden_button.config(image=self.unhide_icon)
|
||||
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)
|
||||
|
||||
add_to_exclude_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["add_to_exclude_list"], command=self._add_to_exclude_list)
|
||||
add_to_exclude_button.pack(side=tk.LEFT, padx=5)
|
||||
hard_reset_button_frame = ttk.Frame(self.hard_reset_frame)
|
||||
hard_reset_button_frame.pack(pady=10)
|
||||
|
||||
apply_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["apply"], command=self._apply_changes)
|
||||
apply_button.pack(side=tk.LEFT, padx=5)
|
||||
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)
|
||||
|
||||
cancel_button = ttk.Button(self.button_frame, text=Msg.STR["cancel"],
|
||||
command=lambda: self.navigation.toggle_mode("backup", 0))
|
||||
cancel_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
advanced_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["advanced"], command=self._open_advanced_settings)
|
||||
advanced_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
reset_button = ttk.Button(
|
||||
self.button_frame, text=Msg.STR["default_settings"], command=self.actions.reset_to_default_settings)
|
||||
reset_button.pack(side=tk.RIGHT)
|
||||
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)
|
||||
|
||||
self.hidden_files_visible = False
|
||||
self.advanced_settings_frame_instance = None # To hold the instance of AdvancedSettingsFrame
|
||||
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
|
||||
|
||||
try:
|
||||
shutil.rmtree(AppConfig.APP_DIR)
|
||||
# Restart the application
|
||||
os.execl(sys.executable, sys.executable, *sys.argv)
|
||||
except Exception as e:
|
||||
MessageDialog(message_type="error", text=str(e)).show()
|
||||
|
||||
def _toggle_hard_reset_view(self):
|
||||
self.hard_reset_visible = not self.hard_reset_visible
|
||||
if self.hard_reset_visible:
|
||||
self.trees_container.pack_forget()
|
||||
self.button_frame.pack_forget()
|
||||
self.bottom_button_frame.pack_forget()
|
||||
self.hard_reset_frame.pack(
|
||||
fill=tk.BOTH, expand=True, padx=10, pady=5)
|
||||
else:
|
||||
self.hard_reset_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)
|
||||
self.bottom_button_frame.pack(pady=10)
|
||||
|
||||
def _add_to_exclude_list(self) -> bool:
|
||||
result = MessageDialog("ask", Msg.STR["exclude_dialog_text"], title=Msg.STR["add_to_exclude_list"], buttons=[
|
||||
Msg.STR["add_folder_button"], Msg.STR["add_file_button"]]).show()
|
||||
|
||||
path = None
|
||||
if result:
|
||||
if result == 0: # First button: Folder
|
||||
dialog = CustomFileDialog(
|
||||
self, mode="dir", title=Msg.STR["add_to_exclude_list"])
|
||||
self.wait_window(dialog)
|
||||
path = dialog.get_result()
|
||||
dialog.destroy()
|
||||
else:
|
||||
elif result == 1: # Second button: File
|
||||
dialog = CustomFileDialog(
|
||||
self, filetypes=[("All Files", "*.*")], title=Msg.STR["add_to_exclude_list"])
|
||||
self, filetypes=[("All Files", "*.*")])
|
||||
self.wait_window(dialog)
|
||||
path = dialog.get_result()
|
||||
dialog.destroy()
|
||||
|
||||
if path:
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'a') as f:
|
||||
if os.path.isdir(path):
|
||||
f.write(f"\n{path}/*")
|
||||
else:
|
||||
f.write(f"\n{path}")
|
||||
try:
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
lines = {line.strip() for line in f if line.strip()}
|
||||
except FileNotFoundError:
|
||||
lines = set()
|
||||
|
||||
new_entry = f"{path}/*" if os.path.isdir(path) else path
|
||||
lines.add(new_entry)
|
||||
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'w') as f:
|
||||
for line in sorted(list(lines)):
|
||||
f.write(f"{line}\n")
|
||||
|
||||
self.load_and_display_excludes()
|
||||
self._load_hidden_files()
|
||||
if hasattr(self, 'advanced_settings_frame_instance') and self.advanced_settings_frame_instance:
|
||||
self.advanced_settings_frame_instance._load_manual_excludes()
|
||||
|
||||
def show(self):
|
||||
self.grid(row=2, column=0, sticky="nsew")
|
||||
@@ -136,15 +227,18 @@ class SettingsFrame(ttk.Frame):
|
||||
all_patterns = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.GENERATED_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
all_patterns.extend(
|
||||
[line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.USER_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
all_patterns.extend(
|
||||
[line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
if AppConfig.MANUAL_EXCLUDE_LIST_PATH.exists():
|
||||
with open(AppConfig.MANUAL_EXCLUDE_LIST_PATH, 'r') as f:
|
||||
all_patterns.extend([line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
all_patterns.extend(
|
||||
[line.strip() for line in f if line.strip() and not line.startswith('#')])
|
||||
|
||||
return all_patterns
|
||||
|
||||
@@ -246,18 +340,19 @@ class SettingsFrame(ttk.Frame):
|
||||
|
||||
def _open_advanced_settings(self):
|
||||
# Hide main settings UI elements
|
||||
self.trees_container.pack_forget() # Hide the container for treeviews
|
||||
self.trees_container.pack_forget() # Hide the container for treeviews
|
||||
self.button_frame.pack_forget()
|
||||
self.bottom_button_frame.pack_forget()
|
||||
|
||||
# Create AdvancedSettingsFrame if not already created
|
||||
if not self.advanced_settings_frame_instance:
|
||||
self.advanced_settings_frame_instance = AdvancedSettingsFrame(
|
||||
self, # Parent is now self (SettingsFrame)
|
||||
config_manager=self.master.master.master.config_manager,
|
||||
self, # Parent is now self (SettingsFrame)
|
||||
config_manager=self.config_manager,
|
||||
app_instance=self.master.master.master,
|
||||
show_main_settings_callback=self._show_main_settings
|
||||
)
|
||||
|
||||
|
||||
# Pack the AdvancedSettingsFrame
|
||||
self.advanced_settings_frame_instance.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
@@ -265,10 +360,12 @@ class SettingsFrame(ttk.Frame):
|
||||
# Hide advanced settings frame
|
||||
if self.advanced_settings_frame_instance:
|
||||
self.advanced_settings_frame_instance.pack_forget()
|
||||
|
||||
|
||||
# Show main settings UI elements
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Re-pack the container for treeviews
|
||||
self.button_frame.pack(fill=tk.X, padx=10, pady=10) # Re-pack the button frame
|
||||
# Re-pack the container for treeviews
|
||||
self.button_frame.pack(fill=tk.X, padx=10)
|
||||
self.trees_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||||
self.bottom_button_frame.pack(pady=10)
|
||||
|
||||
def _toggle_hidden_files_view(self):
|
||||
self.hidden_files_visible = not self.hidden_files_visible
|
||||
|
@@ -15,13 +15,6 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
self.system_backups_list = []
|
||||
self.backup_path = None
|
||||
|
||||
self.tag_colors = [
|
||||
("full_blue", "#0078D7", "inc_blue", "#50E6FF"),
|
||||
("full_orange", "#E8740C", "inc_orange", "#FFB366"),
|
||||
("full_green", "#107C10", "inc_green", "#50E680"),
|
||||
("full_purple", "#8B107C", "inc_purple", "#D46EE5"),
|
||||
]
|
||||
|
||||
columns = ("date", "time", "type", "size", "comment")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self, columns=columns, show="headings")
|
||||
@@ -31,18 +24,19 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
self.content_tree.heading("size", text=Msg.STR["size"])
|
||||
self.content_tree.heading("comment", text=Msg.STR["comment"])
|
||||
|
||||
self.content_tree.column("date", width=100, anchor="w")
|
||||
self.content_tree.column("time", width=80, anchor="center")
|
||||
self.content_tree.column("type", width=120, anchor="center")
|
||||
self.content_tree.column("size", width=100, anchor="e")
|
||||
self.content_tree.column("comment", width=300, anchor="w")
|
||||
self.content_tree.column("date", width=100, anchor="center")
|
||||
self.content_tree.column("time", width=100, anchor="center")
|
||||
self.content_tree.column("type", width=180, anchor="w")
|
||||
self.content_tree.column("size", width=90, anchor="center")
|
||||
self.content_tree.column("comment", width=310, anchor="w")
|
||||
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
self.content_tree.bind("<<TreeviewSelect>>", self._on_item_select)
|
||||
|
||||
def show(self, backup_path, system_backups):
|
||||
self.backup_path = backup_path
|
||||
self.system_backups_list = system_backups
|
||||
self.system_backups_list = sorted(system_backups, key=lambda b: (
|
||||
b.get('is_encrypted'), b.get('folder_name', '')))
|
||||
self._load_backup_content()
|
||||
|
||||
def _load_backup_content(self):
|
||||
@@ -52,27 +46,38 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
if not self.system_backups_list:
|
||||
return
|
||||
|
||||
color_index = -1
|
||||
for i, backup_info in enumerate(self.system_backups_list):
|
||||
colors = ["#0078D7", "#7e4818", "#8B107C", "#005A9E", "#2b3e4e"]
|
||||
last_full_backup_tag = {}
|
||||
color_index = 0
|
||||
|
||||
for backup_info in self.system_backups_list:
|
||||
is_encrypted = backup_info.get("is_encrypted")
|
||||
group_key = (is_encrypted,)
|
||||
current_color_tag = ""
|
||||
|
||||
if backup_info.get("backup_type_base") == "Full":
|
||||
color_index = (color_index + 1) % len(self.tag_colors)
|
||||
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
|
||||
current_color_tag = f"color_{color_index}"
|
||||
self.content_tree.tag_configure(
|
||||
full_tag, foreground=full_color)
|
||||
self.content_tree.tag_configure(
|
||||
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
|
||||
current_tag = full_tag
|
||||
current_color_tag, foreground=colors[color_index % len(colors)])
|
||||
color_index += 1
|
||||
last_full_backup_tag[group_key] = current_color_tag
|
||||
else:
|
||||
_, _, inc_tag, _ = self.tag_colors[color_index]
|
||||
current_tag = inc_tag
|
||||
if group_key in last_full_backup_tag:
|
||||
current_color_tag = last_full_backup_tag[group_key]
|
||||
else:
|
||||
current_color_tag = ""
|
||||
|
||||
backup_type_display = backup_info.get("type", "N/A")
|
||||
if backup_info.get("backup_type_base") != "Full":
|
||||
backup_type_display = f"▲ {backup_type_display}"
|
||||
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "N/A"),
|
||||
backup_info.get("time", "N/A"),
|
||||
backup_info.get("type", "N/A"),
|
||||
backup_type_display,
|
||||
backup_info.get("size", "N/A"),
|
||||
backup_info.get("comment", ""),
|
||||
), tags=(current_tag,), iid=backup_info.get("folder_name"))
|
||||
), tags=(current_color_tag,), iid=backup_info.get("folder_name"))
|
||||
|
||||
self._on_item_select(None)
|
||||
|
||||
@@ -88,18 +93,17 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
selected_backup = next((b for b in self.system_backups_list if b.get(
|
||||
"folder_name") == selected_item_id), None)
|
||||
if not selected_backup:
|
||||
return
|
||||
return
|
||||
|
||||
is_encrypted = selected_backup.get('is_encrypted', False)
|
||||
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
|
||||
info_file_path = os.path.join(
|
||||
self.backup_path, "pybackup", info_file_name)
|
||||
|
||||
if not os.path.exists(info_file_path):
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
info_file_path = selected_backup.get('info_file_path')
|
||||
if not info_file_path or not os.path.isfile(info_file_path):
|
||||
MessageDialog(message_type="error", title="Error",
|
||||
text=f"Metadata file not found: {info_file_path}")
|
||||
return
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self.parent_view.show(self.backup_path)
|
||||
self.parent_view.show(
|
||||
self.backup_path, self.system_backups_list) # Refresh view
|
||||
|
||||
def _restore_selected(self):
|
||||
selected_item_id = self.content_tree.focus()
|
||||
@@ -122,7 +126,7 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
self.backup_manager.start_restore(
|
||||
source_path=selected_backup['full_path'],
|
||||
dest_path=restore_dest_path,
|
||||
is_compressed=selected_backup['is_compressed']
|
||||
is_compressed=selected_backup.get('is_compressed', False)
|
||||
)
|
||||
|
||||
def _delete_selected(self):
|
||||
@@ -142,21 +146,19 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
|
||||
if is_encrypted:
|
||||
username = os.path.basename(self.backup_path.rstrip('/'))
|
||||
# Get password in the UI thread before starting the background task
|
||||
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
|
||||
password = self.backup_manager.encryption_manager.get_password(
|
||||
username, confirm=False)
|
||||
if not password:
|
||||
self.actions.logger.log("Password entry cancelled, aborting deletion.")
|
||||
self.actions.logger.log(
|
||||
"Password entry cancelled, aborting deletion.")
|
||||
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.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
|
||||
self.parent_view.show_deletion_status(
|
||||
Msg.STR["deleting_backup_in_progress"])
|
||||
|
||||
self.backup_manager.start_delete_backup(
|
||||
path_to_delete=folder_to_delete,
|
||||
info_file_path=info_file_to_delete,
|
||||
path_to_delete=folder_to_delete,
|
||||
is_encrypted=is_encrypted,
|
||||
is_system=True,
|
||||
base_dest_path=self.backup_path,
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from core.pbp_app_config import Msg
|
||||
from pyimage_ui.comment_editor_dialog import CommentEditorDialog
|
||||
from shared_libs.message import MessageDialog
|
||||
|
||||
|
||||
class UserBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, actions, parent_view, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
@@ -16,35 +16,30 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
self.user_backups_list = []
|
||||
self.backup_path = None
|
||||
|
||||
self.tag_colors = [
|
||||
("full_blue", "#0078D7", "inc_blue", "#50E6FF"),
|
||||
("full_orange", "#E8740C", "inc_orange", "#FFB366"),
|
||||
("full_green", "#107C10", "inc_green", "#50E680"),
|
||||
("full_purple", "#8B107C", "inc_purple", "#D46EE5"),
|
||||
]
|
||||
|
||||
columns = ("date", "time", "type", "size", "comment", "folder_name")
|
||||
self.content_tree = ttk.Treeview(self, columns=columns, show="headings")
|
||||
columns = ("date", "time", "type", "size", "folder_name", "comment")
|
||||
self.content_tree = ttk.Treeview(
|
||||
self, columns=columns, show="headings")
|
||||
self.content_tree.heading("date", text=Msg.STR["date"])
|
||||
self.content_tree.heading("time", text=Msg.STR["time"])
|
||||
self.content_tree.heading("type", text=Msg.STR["type"])
|
||||
self.content_tree.heading("size", text=Msg.STR["size"])
|
||||
self.content_tree.heading("comment", text=Msg.STR["comment"])
|
||||
self.content_tree.heading("folder_name", text=Msg.STR["folder"])
|
||||
self.content_tree.heading("comment", text=Msg.STR["comment"])
|
||||
|
||||
self.content_tree.column("date", width=100, anchor="w")
|
||||
self.content_tree.column("time", width=80, anchor="center")
|
||||
self.content_tree.column("type", width=120, anchor="center")
|
||||
self.content_tree.column("size", width=100, anchor="e")
|
||||
self.content_tree.column("comment", width=250, anchor="w")
|
||||
self.content_tree.column("folder_name", width=200, anchor="w")
|
||||
self.content_tree.column("date", width=100, anchor="center")
|
||||
self.content_tree.column("time", width=100, anchor="center")
|
||||
self.content_tree.column("type", width=180, anchor="w")
|
||||
self.content_tree.column("size", width=90, anchor="center")
|
||||
self.content_tree.column("folder_name", width=130, anchor="center")
|
||||
self.content_tree.column("comment", width=180, anchor="w")
|
||||
|
||||
self.content_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
self.content_tree.bind("<<TreeviewSelect>>", self._on_item_select)
|
||||
|
||||
def show(self, backup_path, user_backups):
|
||||
self.backup_path = backup_path
|
||||
self.user_backups_list = user_backups
|
||||
self.user_backups_list = sorted(user_backups, key=lambda b: (
|
||||
b.get('source', ''), b.get('is_encrypted'), b.get('folder_name', '')))
|
||||
self._load_backup_content()
|
||||
|
||||
def _load_backup_content(self):
|
||||
@@ -54,28 +49,40 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
if not self.user_backups_list:
|
||||
return
|
||||
|
||||
color_index = -1
|
||||
for i, backup_info in enumerate(self.user_backups_list):
|
||||
colors = ["#0078D7", "#854710", "#8B107C", "#005A9E", "#2b3e4e"]
|
||||
last_full_backup_tag = {}
|
||||
color_index = 0
|
||||
|
||||
for backup_info in self.user_backups_list:
|
||||
source = backup_info.get("source")
|
||||
is_encrypted = backup_info.get("is_encrypted")
|
||||
group_key = (source, is_encrypted)
|
||||
current_color_tag = ""
|
||||
|
||||
if backup_info.get("backup_type_base") == "Full":
|
||||
color_index = (color_index + 1) % len(self.tag_colors)
|
||||
full_tag, full_color, inc_tag, inc_color = self.tag_colors[color_index]
|
||||
current_color_tag = f"color_{color_index}"
|
||||
self.content_tree.tag_configure(
|
||||
full_tag, foreground=full_color)
|
||||
self.content_tree.tag_configure(
|
||||
inc_tag, foreground=inc_color, font=("Helvetica", 10, "bold"))
|
||||
current_tag = full_tag
|
||||
current_color_tag, foreground=colors[color_index % len(colors)])
|
||||
color_index += 1
|
||||
last_full_backup_tag[group_key] = current_color_tag
|
||||
else:
|
||||
_, _, inc_tag, _ = self.tag_colors[color_index]
|
||||
current_tag = inc_tag
|
||||
if group_key in last_full_backup_tag:
|
||||
current_color_tag = last_full_backup_tag[group_key]
|
||||
else:
|
||||
current_color_tag = ""
|
||||
|
||||
backup_type_display = backup_info.get("type", "N/A")
|
||||
if backup_info.get("backup_type_base") != "Full":
|
||||
backup_type_display = f"▲ {backup_type_display}"
|
||||
|
||||
self.content_tree.insert("", "end", values=(
|
||||
backup_info.get("date", "N/A"),
|
||||
backup_info.get("time", "N/A"),
|
||||
backup_info.get("type", "N/A"),
|
||||
backup_type_display,
|
||||
backup_info.get("size", "N/A"),
|
||||
backup_info.get("comment", ""),
|
||||
backup_info.get("folder_name", "N/A")
|
||||
), tags=(current_tag,), iid=backup_info.get("folder_name"))
|
||||
backup_info.get("source", "N/A"),
|
||||
backup_info.get("comment", "")
|
||||
), tags=(current_color_tag,), iid=backup_info.get("folder_name"))
|
||||
self._on_item_select(None)
|
||||
|
||||
def _on_item_select(self, event):
|
||||
@@ -92,23 +99,23 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
if not selected_backup:
|
||||
return
|
||||
|
||||
is_encrypted = selected_backup.get('is_encrypted', False)
|
||||
info_file_name = f"{selected_item_id}{'_encrypted' if is_encrypted else ''}.txt"
|
||||
info_file_path = os.path.join(
|
||||
self.backup_path, "pybackup", info_file_name)
|
||||
|
||||
if not os.path.exists(info_file_path):
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
info_file_path = selected_backup.get('info_file_path')
|
||||
if not info_file_path or not os.path.isfile(info_file_path):
|
||||
MessageDialog(message_type="error", title="Error",
|
||||
text=f"Metadata file not found: {info_file_path}")
|
||||
return
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self.parent_view.show(self.backup_path)
|
||||
|
||||
self.parent_view.show(self.backup_path, self.user_backups_list)
|
||||
|
||||
def _restore_selected(self):
|
||||
selected_item_id = self.content_tree.focus()
|
||||
if not selected_item_id:
|
||||
return
|
||||
|
||||
MessageDialog(master=self, message_type="info", title="Info", text="User restore not implemented yet.")
|
||||
|
||||
MessageDialog(message_type="info",
|
||||
title="Info", text="User restore not implemented yet.")
|
||||
|
||||
def _delete_selected(self):
|
||||
selected_item_id = self.content_tree.focus()
|
||||
@@ -126,22 +133,19 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
password = None
|
||||
|
||||
if is_encrypted:
|
||||
username = os.path.basename(self.backup_path.rstrip('/'))
|
||||
# Get password in the UI thread before starting the background task
|
||||
password = self.backup_manager.encryption_manager.get_password(username, confirm=False)
|
||||
password = self.backup_manager.encryption_manager.get_password(
|
||||
confirm=False)
|
||||
if not password:
|
||||
self.actions.logger.log("Password entry cancelled, aborting deletion.")
|
||||
self.actions.logger.log(
|
||||
"Password entry cancelled, aborting deletion.")
|
||||
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.parent_view.show_deletion_status(Msg.STR["deleting_backup_in_progress"])
|
||||
self.parent_view.show_deletion_status(
|
||||
Msg.STR["deleting_backup_in_progress"])
|
||||
|
||||
self.backup_manager.start_delete_backup(
|
||||
path_to_delete=folder_to_delete,
|
||||
info_file_path=info_file_to_delete,
|
||||
path_to_delete=folder_to_delete,
|
||||
is_encrypted=is_encrypted,
|
||||
is_system=False,
|
||||
base_dest_path=self.backup_path,
|
||||
|
Reference in New Issue
Block a user