Files
cryptz/ui.py
T

626 lines
24 KiB
Python
Raw Normal View History

2026-05-31 18:46:06 +08:00
"""
CryptZ Ultimate v5 — GUI Module
Full-featured interface with drag&drop, password strength, hotkeys,
real progress, log panel, theme switching, context menu, etc.
"""
import os
import threading
import secrets
import time
import customtkinter as ctk
from tkinter import filedialog, messagebox, Menu
from typing import Optional
import pyotp
import qrcode
from PIL import Image, ImageTk
from crypto_engine import (
EncryptOptions, DecryptResult, encrypt, decrypt, verify_integrity,
check_password_strength, generate_password, secure_delete, EXTENSION,
)
# --- Theme setup ---
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
# Colors
COLORS = {
"strength_0": "#ff4444",
"strength_1": "#ff8800",
"strength_2": "#ffcc00",
"strength_3": "#88cc00",
"strength_4": "#00cc44",
"sidebar_bg": "#1a1a2e",
"accent": "#4361ee",
"danger": "#ef233c",
"success": "#06d6a0",
}
class CryptZApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("CryptZ Ultimate v5 — AES-256-GCM Archiver")
self.geometry("1100x800")
self.minsize(900, 650)
self.files_list: list[str] = []
self.hardware_key_path: Optional[str] = None
self.is_busy = False
self._setup_ui()
self._setup_hotkeys()
self._setup_dnd()
# ===== UI SETUP =====
def _setup_ui(self):
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
self._build_sidebar()
self._build_main_area()
self._build_statusbar()
def _build_sidebar(self):
self.sidebar = ctk.CTkFrame(self, width=300, corner_radius=0)
self.sidebar.grid(row=0, column=0, sticky="nsew")
self.sidebar.grid_propagate(False)
# Title
ctk.CTkLabel(self.sidebar, text="⚙ ПАРАМЕТРЫ", font=("Segoe UI", 18, "bold")).pack(pady=(25, 20))
# Password
pw_frame = ctk.CTkFrame(self.sidebar, fg_color="transparent")
pw_frame.pack(fill="x", padx=20, pady=(0, 5))
ctk.CTkLabel(pw_frame, text="Пароль:", font=("Segoe UI", 12)).pack(anchor="w")
self.pw_entry_frame = ctk.CTkFrame(self.sidebar, fg_color="transparent")
self.pw_entry_frame.pack(fill="x", padx=20)
self.password_var = ctk.StringVar()
self.password_var.trace_add("write", self._on_password_change)
self.pw_entry = ctk.CTkEntry(
self.pw_entry_frame, textvariable=self.password_var,
show="", placeholder_text="Введите пароль...", height=36
)
self.pw_entry.pack(side="left", fill="x", expand=True)
self.pw_show_btn = ctk.CTkButton(
self.pw_entry_frame, text="👁", width=36, height=36,
command=self._toggle_password_visibility, fg_color="#3d3d3d"
)
self.pw_show_btn.pack(side="left", padx=(5, 0))
self.pw_gen_btn = ctk.CTkButton(
self.pw_entry_frame, text="🎲", width=36, height=36,
command=self._generate_password, fg_color="#3d3d3d"
)
self.pw_gen_btn.pack(side="left", padx=(5, 0))
# Strength indicator
self.strength_frame = ctk.CTkFrame(self.sidebar, height=6, fg_color="transparent")
self.strength_frame.pack(fill="x", padx=20, pady=(4, 0))
self.strength_bar = ctk.CTkProgressBar(self.strength_frame, height=6)
self.strength_bar.pack(fill="x")
self.strength_bar.set(0)
self.strength_label = ctk.CTkLabel(self.sidebar, text="", font=("Segoe UI", 10))
self.strength_label.pack(anchor="w", padx=20)
# Checkboxes
ctk.CTkLabel(self.sidebar, text="").pack() # spacer
self.check_2fa = ctk.CTkCheckBox(self.sidebar, text="🔐 Использовать 2FA (TOTP)")
self.check_2fa.pack(pady=6, anchor="w", padx=30)
self.check_secure_del = ctk.CTkCheckBox(self.sidebar, text="🗑 Шредер (удалить оригиналы)")
self.check_secure_del.pack(pady=6, anchor="w", padx=30)
self.check_compress = ctk.CTkCheckBox(self.sidebar, text="📦 Сжатие (gzip)")
self.check_compress.pack(pady=6, anchor="w", padx=30)
self.check_compress.select()
# File key
ctk.CTkLabel(self.sidebar, text="").pack()
self.hw_key_btn = ctk.CTkButton(
self.sidebar, text="🔑 Файл-ключ: не выбран",
command=self._load_hw_key, fg_color="#3d3d3d", hover_color="#555555"
)
self.hw_key_btn.pack(fill="x", padx=20, pady=5)
self.hw_key_clear_btn = ctk.CTkButton(
self.sidebar, text="✕ Сбросить файл-ключ",
command=self._clear_hw_key, fg_color="#555555", hover_color="#773333", height=28
)
self.hw_key_clear_btn.pack(fill="x", padx=20, pady=(0, 10))
# Max attempts
attempts_frame = ctk.CTkFrame(self.sidebar, fg_color="transparent")
attempts_frame.pack(fill="x", padx=20, pady=5)
ctk.CTkLabel(attempts_frame, text="Макс. попыток:", font=("Segoe UI", 12)).pack(side="left")
self.attempts_slider = ctk.CTkSlider(attempts_frame, from_=1, to=20, number_of_steps=19, width=120)
self.attempts_slider.set(5)
self.attempts_slider.pack(side="left", padx=10)
self.attempts_label = ctk.CTkLabel(attempts_frame, text="5")
self.attempts_label.pack(side="left")
self.attempts_slider.configure(command=self._on_attempts_change)
# Theme switch
ctk.CTkLabel(self.sidebar, text="").pack()
theme_frame = ctk.CTkFrame(self.sidebar, fg_color="transparent")
theme_frame.pack(fill="x", padx=20, pady=10)
ctk.CTkLabel(theme_frame, text="Тема:").pack(side="left")
self.theme_menu = ctk.CTkOptionMenu(
theme_frame, values=["Dark", "Light", "System"],
command=self._change_theme, width=100
)
self.theme_menu.pack(side="right")
def _build_main_area(self):
main = ctk.CTkFrame(self)
main.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
main.grid_rowconfigure(1, weight=1)
main.grid_columnconfigure(0, weight=1)
# Toolbar
toolbar = ctk.CTkFrame(main, height=50, fg_color="transparent")
toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 5))
self.btn_add_files = ctk.CTkButton(toolbar, text="📂 Файлы", command=self._add_files, width=100)
self.btn_add_files.pack(side="left", padx=3)
self.btn_add_folder = ctk.CTkButton(toolbar, text="📁 Папка", command=self._add_folder, width=100)
self.btn_add_folder.pack(side="left", padx=3)
self.btn_clear = ctk.CTkButton(toolbar, text="🧹 Очистить", command=self._clear_files, width=100, fg_color="#555")
self.btn_clear.pack(side="left", padx=3)
# Spacer
ctk.CTkLabel(toolbar, text="").pack(side="left", fill="x", expand=True)
self.btn_encrypt = ctk.CTkButton(
toolbar, text="🔒 Зашифровать", command=self._do_encrypt,
width=140, fg_color=COLORS["accent"], hover_color="#3451cc"
)
self.btn_encrypt.pack(side="right", padx=3)
self.btn_decrypt = ctk.CTkButton(
toolbar, text="🔓 Расшифровать", command=self._do_decrypt,
width=140, fg_color=COLORS["success"], hover_color="#04a87d"
)
self.btn_decrypt.pack(side="right", padx=3)
self.btn_verify = ctk.CTkButton(
toolbar, text="✓ Проверить", command=self._do_verify,
width=110, fg_color="#555"
)
self.btn_verify.pack(side="right", padx=3)
# File list
self.file_frame = ctk.CTkFrame(main)
self.file_frame.grid(row=1, column=0, sticky="nsew")
self.drop_label = ctk.CTkLabel(
self.file_frame,
text="Перетащите файлы сюда\nили используйте кнопки выше",
font=("Segoe UI", 16), text_color="#888888"
)
self.drop_label.place(relx=0.5, rely=0.5, anchor="center")
self.file_scroll = ctk.CTkScrollableFrame(self.file_frame)
self.file_scroll.pack(fill="both", expand=True, padx=5, pady=5)
# Progress
progress_frame = ctk.CTkFrame(main, height=60, fg_color="transparent")
progress_frame.grid(row=2, column=0, sticky="ew", pady=(5, 0))
self.progress_bar = ctk.CTkProgressBar(progress_frame, height=12)
self.progress_bar.pack(fill="x", pady=(5, 2))
self.progress_bar.set(0)
self.progress_label = ctk.CTkLabel(progress_frame, text="Готов к работе", font=("Segoe UI", 11))
self.progress_label.pack(anchor="w")
# Log panel
self.log_frame = ctk.CTkFrame(main, height=120)
self.log_frame.grid(row=3, column=0, sticky="ew", pady=(5, 0))
ctk.CTkLabel(self.log_frame, text="📋 Журнал:", font=("Segoe UI", 11, "bold")).pack(anchor="w", padx=10, pady=(5, 0))
self.log_text = ctk.CTkTextbox(self.log_frame, height=80, font=("Consolas", 10))
self.log_text.pack(fill="both", expand=True, padx=5, pady=5)
self.log_text.configure(state="disabled")
def _build_statusbar(self):
self.statusbar = ctk.CTkFrame(self, height=28, corner_radius=0)
self.statusbar.grid(row=1, column=0, columnspan=2, sticky="ew")
self.status_files = ctk.CTkLabel(self.statusbar, text="Файлов: 0", font=("Segoe UI", 10))
self.status_files.pack(side="left", padx=15)
self.status_size = ctk.CTkLabel(self.statusbar, text="Размер: 0 B", font=("Segoe UI", 10))
self.status_size.pack(side="left", padx=15)
self.status_op = ctk.CTkLabel(self.statusbar, text="", font=("Segoe UI", 10))
self.status_op.pack(side="right", padx=15)
# ===== HOTKEYS =====
def _setup_hotkeys(self):
self.bind("<Control-o>", lambda e: self._add_files())
self.bind("<Control-e>", lambda e: self._do_encrypt())
self.bind("<Control-d>", lambda e: self._do_decrypt())
self.bind("<Control-g>", lambda e: self._generate_password())
self.bind("<Delete>", lambda e: self._clear_files())
# ===== DRAG & DROP =====
def _setup_dnd(self):
"""Try to setup tkinterdnd2. Silently skip if not available."""
try:
from tkinterdnd2 import DND_FILES
self.drop_target_register(DND_FILES)
self.dnd_bind("<<Drop>>", self._on_drop)
except (ImportError, Exception):
pass
def _on_drop(self, event):
"""Handle drag & drop files."""
files = self.tk.splitlist(event.data)
for f in files:
if f not in self.files_list:
self.files_list.append(f)
self._refresh_file_list()
# ===== FILE MANAGEMENT =====
def _add_files(self):
paths = filedialog.askopenfilenames(title="Выбрать файлы")
if paths:
for p in paths:
if p not in self.files_list:
self.files_list.append(p)
self._refresh_file_list()
self._log(f"Добавлено файлов: {len(paths)}")
def _add_folder(self):
folder = filedialog.askdirectory(title="Выбрать папку")
if folder:
count = 0
for root, dirs, files in os.walk(folder):
for fname in files:
full = os.path.join(root, fname)
if full not in self.files_list:
self.files_list.append(full)
count += 1
self._refresh_file_list()
self._log(f"Добавлено из папки: {count} файлов")
def _clear_files(self):
self.files_list.clear()
self._refresh_file_list()
self._log("Список очищен")
def _remove_file(self, path: str):
if path in self.files_list:
self.files_list.remove(path)
self._refresh_file_list()
def _refresh_file_list(self):
# Clear scrollable frame
for widget in self.file_scroll.winfo_children():
widget.destroy()
if not self.files_list:
self.drop_label.place(relx=0.5, rely=0.5, anchor="center")
else:
self.drop_label.place_forget()
for filepath in self.files_list:
row = ctk.CTkFrame(self.file_scroll, fg_color="transparent", height=30)
row.pack(fill="x", pady=1)
# Icon based on type
ext = os.path.splitext(filepath)[1].lower()
icon = "📄"
if ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"):
icon = "🖼"
elif ext in (".mp3", ".wav", ".flac", ".ogg"):
icon = "🎵"
elif ext in (".mp4", ".avi", ".mkv", ".mov"):
icon = "🎬"
elif ext in (".zip", ".rar", ".7z", ".tar", ".gz"):
icon = "📦"
elif ext == EXTENSION:
icon = "🔒"
elif os.path.isdir(filepath):
icon = "📁"
name = os.path.basename(filepath)
size = ""
if os.path.isfile(filepath):
s = os.path.getsize(filepath)
size = self._format_size(s)
label = ctk.CTkLabel(row, text=f"{icon} {name} ({size})", font=("Segoe UI", 11), anchor="w")
label.pack(side="left", fill="x", expand=True, padx=5)
del_btn = ctk.CTkButton(
row, text="", width=28, height=28, fg_color="#553333",
hover_color="#883333", command=lambda p=filepath: self._remove_file(p)
)
del_btn.pack(side="right", padx=5)
# Context menu on right-click
label.bind("<Button-3>", lambda e, p=filepath: self._show_context_menu(e, p))
self._update_statusbar()
def _show_context_menu(self, event, filepath):
menu = Menu(self, tearoff=0)
menu.add_command(label="Удалить из списка", command=lambda: self._remove_file(filepath))
menu.add_command(label="Открыть расположение", command=lambda: os.startfile(os.path.dirname(filepath)))
menu.post(event.x_root, event.y_root)
def _update_statusbar(self):
count = len(self.files_list)
total_size = sum(os.path.getsize(f) for f in self.files_list if os.path.isfile(f))
self.status_files.configure(text=f"Файлов: {count}")
self.status_size.configure(text=f"Размер: {self._format_size(total_size)}")
@staticmethod
def _format_size(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 ** 2:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 ** 3:
return f"{size_bytes / 1024**2:.1f} MB"
else:
return f"{size_bytes / 1024**3:.2f} GB"
# ===== PASSWORD =====
def _on_password_change(self, *args):
pw = self.password_var.get()
if not pw:
self.strength_bar.set(0)
self.strength_label.configure(text="")
return
score, label = check_password_strength(pw)
self.strength_bar.set(score / 4)
color = COLORS[f"strength_{score}"]
self.strength_bar.configure(progress_color=color)
self.strength_label.configure(text=label, text_color=color)
def _toggle_password_visibility(self):
current = self.pw_entry.cget("show")
self.pw_entry.configure(show="" if current == "" else "")
self.pw_show_btn.configure(text="🙈" if current == "" else "👁")
def _generate_password(self):
pw = generate_password(20)
self.password_var.set(pw)
self.pw_entry.configure(show="")
self.pw_show_btn.configure(text="🙈")
# Copy to clipboard
self.clipboard_clear()
self.clipboard_append(pw)
self._log("Пароль сгенерирован и скопирован в буфер обмена")
# ===== FILE KEY =====
def _load_hw_key(self):
path = filedialog.askopenfilename(title="Выбрать файл-ключ")
if path:
self.hardware_key_path = path
name = os.path.basename(path)
self.hw_key_btn.configure(text=f"🔑 Ключ: {name[:20]}")
self._log(f"Файл-ключ: {name}")
def _clear_hw_key(self):
self.hardware_key_path = None
self.hw_key_btn.configure(text="🔑 Файл-ключ: не выбран")
self._log("Файл-ключ сброшен")
# ===== SIDEBAR CALLBACKS =====
def _on_attempts_change(self, value):
self.attempts_label.configure(text=str(int(value)))
def _change_theme(self, value):
ctk.set_appearance_mode(value)
# ===== OPERATIONS =====
def _do_encrypt(self):
if self.is_busy:
return
if not self.files_list:
messagebox.showwarning("CryptZ", "Добавьте файлы для шифрования!")
return
pw = self.password_var.get()
if not pw:
messagebox.showwarning("CryptZ", "Введите пароль!")
return
output_path = filedialog.asksaveasfilename(
title="Сохранить зашифрованный архив",
defaultextension=EXTENSION,
filetypes=[("CryptZ Archive", f"*{EXTENSION}"), ("All files", "*.*")]
)
if not output_path:
return
# 2FA setup
use_2fa = self.check_2fa.get()
otp_secret = "NONE"
if use_2fa:
otp_secret = pyotp.random_base32()
self._show_2fa_setup(otp_secret)
options = EncryptOptions(
password=pw,
files=self.files_list.copy(),
output_path=output_path,
use_2fa=use_2fa,
otp_secret=otp_secret,
max_attempts=int(self.attempts_slider.get()),
hardware_key_path=self.hardware_key_path,
secure_delete=bool(self.check_secure_del.get()),
compress=bool(self.check_compress.get()),
progress_callback=self._progress_callback,
)
self.is_busy = True
self._log("Шифрование начато...")
threading.Thread(target=self._encrypt_thread, args=(options,), daemon=True).start()
def _encrypt_thread(self, options: EncryptOptions):
try:
result = encrypt(options)
self.after(0, lambda: self._on_encrypt_done(result))
except Exception as e:
self.after(0, lambda: self._on_error(str(e)))
def _on_encrypt_done(self, path):
self.is_busy = False
self._log(f"✅ Архив создан: {os.path.basename(path)}")
messagebox.showinfo("CryptZ", "Архив успешно создан!\n{}".format(path))
def _do_decrypt(self):
if self.is_busy:
return
pw = self.password_var.get()
if not pw:
messagebox.showwarning("CryptZ", "Введите пароль!")
return
file_path = filedialog.askopenfilename(
title="Выбрать зашифрованный архив",
filetypes=[("CryptZ Archive", f"*{EXTENSION}"), ("All files", "*.*")]
)
if not file_path:
return
output_dir = filedialog.askdirectory(title="Папка для извлечения")
if not output_dir:
return
self.is_busy = True
self._log("Расшифровка начата...")
threading.Thread(
target=self._decrypt_thread,
args=(file_path, pw, output_dir, None),
daemon=True
).start()
def _decrypt_thread(self, file_path, password, output_dir, otp_code):
result = decrypt(
file_path=file_path,
password=password,
output_dir=output_dir,
hardware_key_path=self.hardware_key_path,
otp_code=otp_code,
progress_callback=self._progress_callback,
)
self.after(0, lambda: self._on_decrypt_done(result, file_path, password, output_dir))
def _on_decrypt_done(self, result: DecryptResult, file_path, password, output_dir):
self.is_busy = False
if result.needs_2fa:
self._ask_2fa_code(file_path, password, output_dir)
return
if result.success:
self._log(f"{result.message}")
messagebox.showinfo("CryptZ", result.message)
else:
self._log(f"{result.message}")
messagebox.showerror("CryptZ", result.message)
def _ask_2fa_code(self, file_path, password, output_dir):
"""Show 2FA input dialog."""
dialog = ctk.CTkInputDialog(text="Введите код 2FA (6 цифр):", title="Двухфакторная аутентификация")
code = dialog.get_input()
if code:
self.is_busy = True
threading.Thread(
target=self._decrypt_thread,
args=(file_path, password, output_dir, code),
daemon=True
).start()
def _do_verify(self):
if self.is_busy:
return
pw = self.password_var.get()
if not pw:
messagebox.showwarning("CryptZ", "Введите пароль!")
return
file_path = filedialog.askopenfilename(
title="Выбрать архив для проверки",
filetypes=[("CryptZ Archive", f"*{EXTENSION}"), ("All files", "*.*")]
)
if not file_path:
return
ok, msg = verify_integrity(file_path, pw, self.hardware_key_path)
if ok:
self._log(f"{msg}")
messagebox.showinfo("CryptZ", msg)
else:
self._log(f"{msg}")
messagebox.showerror("CryptZ", msg)
# ===== 2FA SETUP =====
def _show_2fa_setup(self, otp_secret: str):
"""Show QR code window for 2FA setup."""
win = ctk.CTkToplevel(self)
win.title("Настройка 2FA")
win.geometry("400x450")
win.transient(self)
win.grab_set()
ctk.CTkLabel(win, text="Сканируйте QR-код в\nGoogle Authenticator / Authy",
font=("Segoe UI", 14)).pack(pady=15)
# Generate QR
uri = pyotp.TOTP(otp_secret).provisioning_uri(name="CryptZ", issuer_name="CryptZ Ultimate")
qr_img = qrcode.make(uri).resize((250, 250))
photo = ImageTk.PhotoImage(qr_img)
qr_label = ctk.CTkLabel(win, image=photo, text="")
qr_label.image = photo
qr_label.pack(pady=10)
ctk.CTkLabel(win, text=f"Секрет: {otp_secret}", font=("Consolas", 11)).pack(pady=5)
ctk.CTkButton(win, text="Готово", command=win.destroy, width=120).pack(pady=15)
# ===== PROGRESS & LOG =====
def _progress_callback(self, progress: float, message: str):
self.after(0, lambda: self._update_progress(progress, message))
def _update_progress(self, progress: float, message: str):
self.progress_bar.set(progress)
self.progress_label.configure(text=message)
self.status_op.configure(text=message)
def _log(self, message: str):
timestamp = time.strftime("%H:%M:%S")
self.log_text.configure(state="normal")
self.log_text.insert("end", "[{}] {}\n".format(timestamp, message))
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _on_error(self, error_msg: str):
self.is_busy = False
self._log(f"❌ Ошибка: {error_msg}")
messagebox.showerror("CryptZ", f"Ошибка: {error_msg}")