v1.2.0 + v1.3.0: Localization, About dialog, TOTP/2FA, stability improvements

v1.2.0:
- GUI localization (EN/RU/ZH) with language switcher and persistent selection
- About dialog (ⓘ) with app info, features, quick start guide
- core/i18n.py — internationalization module with t() function
- All GUI components translated via t() keys

v1.3.0:
- TOTP/2FA tab — Google Authenticator compatible codes with live 30s countdown,
  one-click copy, per-server secret management
- core/totp.py — TOTP module (pyotp, RFC 6238)
- core/logger.py — rotating file logger (5MB, 3 backups)
- Stronger Fernet encryption key with automatic migration from old key
- Thread-safe server store with locks, atomic writes, auto-restore on corruption
- Parallel status checks via ThreadPoolExecutor (up to 10 concurrent)
- SSH client: explicit channel cleanup, Unix key permissions
- Server dialog: port validation (1-65535), TOTP secret field
- Language change preserves active tab and server selection
- pyotp dependency added

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 11:07:51 -05:00
parent f86d6a7214
commit bf39fd7b67
26 changed files with 2029 additions and 246 deletions

View File

@@ -4,6 +4,7 @@ Server add/edit dialog — modal window with all server fields.
import customtkinter as ctk
from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t
class ServerDialog(ctk.CTkToplevel):
@@ -13,8 +14,8 @@ class ServerDialog(ctk.CTkToplevel):
self.editing = server
self.result = None
self.title("Edit Server" if server else "Add Server")
self.geometry("450x520")
self.title(t("edit_server") if server else t("add_server"))
self.geometry("450x580")
self.resizable(False, False)
self.grab_set()
@@ -28,13 +29,13 @@ class ServerDialog(ctk.CTkToplevel):
entry_pad = {"padx": 20, "pady": (2, 5)}
# Alias
ctk.CTkLabel(self, text="Alias", anchor="w").pack(fill="x", **pad)
self.alias_entry = ctk.CTkEntry(self, placeholder_text="my-server")
ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad)
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.alias_entry.pack(fill="x", **entry_pad)
# IP
ctk.CTkLabel(self, text="IP / Hostname", anchor="w").pack(fill="x", **pad)
self.ip_entry = ctk.CTkEntry(self, placeholder_text="1.2.3.4")
ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad)
self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
self.ip_entry.pack(fill="x", **entry_pad)
# Type + Port row
@@ -43,7 +44,7 @@ class ServerDialog(ctk.CTkToplevel):
type_frame = ctk.CTkFrame(row, fg_color="transparent")
type_frame.pack(side="left", fill="x", expand=True, padx=(0, 5))
ctk.CTkLabel(type_frame, text="Type", anchor="w").pack(fill="x")
ctk.CTkLabel(type_frame, text=t("type"), anchor="w").pack(fill="x")
self.type_var = ctk.StringVar(value="ssh")
self.type_menu = ctk.CTkOptionMenu(
type_frame, values=SERVER_TYPES, variable=self.type_var,
@@ -53,35 +54,41 @@ class ServerDialog(ctk.CTkToplevel):
port_frame = ctk.CTkFrame(row, fg_color="transparent")
port_frame.pack(side="left", fill="x", expand=True, padx=(5, 0))
ctk.CTkLabel(port_frame, text="Port", anchor="w").pack(fill="x")
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text="22")
ctk.CTkLabel(port_frame, text=t("port"), anchor="w").pack(fill="x")
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x")
# User
ctk.CTkLabel(self, text="Username", anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text="root")
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
self.user_entry.pack(fill="x", **entry_pad)
# Password
ctk.CTkLabel(self, text="Password", anchor="w").pack(fill="x", **pad)
ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad)
pass_frame = ctk.CTkFrame(self, fg_color="transparent")
pass_frame.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text="password")
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password"))
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_pass = ctk.CTkButton(pass_frame, text="Show", width=60, command=self._toggle_password)
self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password)
self.show_pass.pack(side="right")
self._pass_visible = False
# TOTP Secret
ctk.CTkLabel(self, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad)
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad)
# Notes
ctk.CTkLabel(self, text="Notes", anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text="optional description")
ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
self.notes_entry.pack(fill="x", **entry_pad)
# Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 20))
ctk.CTkButton(btn_frame, text="Cancel", fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
ctk.CTkButton(btn_frame, text="Save", command=self._save).pack(side="right", expand=True, padx=(5, 0))
ctk.CTkButton(btn_frame, text=t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
ctk.CTkButton(btn_frame, text=t("save"), command=self._save).pack(side="right", expand=True, padx=(5, 0))
# Fill values if editing
if server:
@@ -92,6 +99,7 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry.insert(0, str(server.get("port", 22)))
self.user_entry.insert(0, server.get("user", ""))
self.password_entry.insert(0, server.get("password", ""))
self.totp_entry.insert(0, server.get("totp_secret", ""))
self.notes_entry.insert(0, server.get("notes", ""))
def _on_type_change(self, value):
@@ -102,7 +110,7 @@ class ServerDialog(ctk.CTkToplevel):
def _toggle_password(self):
self._pass_visible = not self._pass_visible
self.password_entry.configure(show="" if self._pass_visible else "*")
self.show_pass.configure(text="Hide" if self._pass_visible else "Show")
self.show_pass.configure(text=t("hide") if self._pass_visible else t("show"))
def _save(self):
alias = self.alias_entry.get().strip()
@@ -111,19 +119,23 @@ class ServerDialog(ctk.CTkToplevel):
user = self.user_entry.get().strip()
password = self.password_entry.get()
server_type = self.type_var.get()
totp_secret = self.totp_entry.get().strip()
notes = self.notes_entry.get().strip()
# Validation
if not alias:
self._show_error("Alias is required")
self._show_error(t("alias_required"))
return
if not ip:
self._show_error("IP is required")
self._show_error(t("ip_required"))
return
try:
port = int(port_str) if port_str else DEFAULT_PORTS.get(server_type, 22)
except ValueError:
self._show_error("Port must be a number")
self._show_error(t("port_must_be_number"))
return
if port < 1 or port > 65535:
self._show_error(t("port_out_of_range"))
return
server_data = {
@@ -135,6 +147,8 @@ class ServerDialog(ctk.CTkToplevel):
"type": server_type,
"notes": notes,
}
if totp_secret:
server_data["totp_secret"] = totp_secret
try:
if self.editing:
@@ -148,5 +162,5 @@ class ServerDialog(ctk.CTkToplevel):
def _show_error(self, message: str):
# Simple error via title flash
self.title(f"Error: {message}")
self.after(2000, lambda: self.title("Edit Server" if self.editing else "Add Server"))
self.title(t("error_prefix").format(msg=message))
self.after(2000, lambda: self.title(t("edit_server") if self.editing else t("add_server")))