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

77
gui/about_dialog.py Normal file
View File

@@ -0,0 +1,77 @@
"""
About dialog — application info, features, quick start.
"""
import customtkinter as ctk
from version import __version__, __author__
from core.i18n import t
class AboutDialog(ctk.CTkToplevel):
def __init__(self, master):
super().__init__(master)
self.title(f"{t('about_title')}{t('version')} {__version__}")
self.geometry("500x480")
self.resizable(False, False)
self.transient(master)
self.grab_set()
# ── Header ──
ctk.CTkLabel(
self, text=t("about_title"),
font=ctk.CTkFont(size=24, weight="bold")
).pack(padx=20, pady=(25, 2))
ctk.CTkLabel(
self, text=f"v{__version__}",
font=ctk.CTkFont(size=13), text_color="#9ca3af"
).pack()
ctk.CTkLabel(
self, text=f"by {__author__}",
font=ctk.CTkFont(size=11), text_color="#6b7280"
).pack(pady=(0, 10))
# ── Separator ──
ctk.CTkFrame(self, height=1, fg_color="gray40").pack(fill="x", padx=30, pady=5)
# ── Description ──
ctk.CTkLabel(
self, text=t("about_desc"),
font=ctk.CTkFont(size=12), text_color="#9ca3af",
justify="center", wraplength=440
).pack(padx=20, pady=(8, 5))
# ── Separator ──
ctk.CTkFrame(self, height=1, fg_color="gray40").pack(fill="x", padx=30, pady=5)
# ── Features ──
ctk.CTkLabel(
self, text=t("about_features_title"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
).pack(fill="x", padx=35, pady=(8, 3))
ctk.CTkLabel(
self, text=t("about_features"),
font=ctk.CTkFont(size=12), anchor="w", justify="left"
).pack(fill="x", padx=40, pady=(0, 5))
# ── Separator ──
ctk.CTkFrame(self, height=1, fg_color="gray40").pack(fill="x", padx=30, pady=5)
# ── Quick Start ──
ctk.CTkLabel(
self, text=t("about_howto_title"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
).pack(fill="x", padx=35, pady=(8, 3))
ctk.CTkLabel(
self, text=t("about_howto"),
font=ctk.CTkFont(size=12), anchor="w", justify="left"
).pack(fill="x", padx=40, pady=(0, 10))
# ── Close button ──
ctk.CTkButton(
self, text=t("close"), width=120, command=self.destroy
).pack(pady=(10, 20))

View File

@@ -7,13 +7,17 @@ from tkinter import messagebox
from core.server_store import ServerStore
from core.status_checker import StatusChecker
from core import i18n
from core.i18n import t, LANGUAGES
from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog
from gui.about_dialog import AboutDialog
from gui.tabs.terminal_tab import TerminalTab
from gui.tabs.files_tab import FilesTab
from gui.tabs.info_tab import InfoTab
from gui.tabs.keys_tab import KeysTab
from gui.tabs.setup_tab import SetupTab
from gui.tabs.totp_tab import TOTPTab
class App(ctk.CTk):
@@ -55,30 +59,54 @@ class App(ctk.CTk):
main = ctk.CTkFrame(self, fg_color="transparent")
main.pack(side="right", fill="both", expand=True)
# Header bar (language + about)
header_bar = ctk.CTkFrame(main, fg_color="transparent", height=40)
header_bar.pack(fill="x", padx=10, pady=(8, 0))
header_bar.pack_propagate(False)
# Language selector
lang_values = list(LANGUAGES.values())
current_display = LANGUAGES.get(i18n.get_language(), "English")
self._lang_var = ctk.StringVar(value=current_display)
self.lang_menu = ctk.CTkOptionMenu(
header_bar, values=lang_values, variable=self._lang_var,
width=110, height=30, command=self._change_language
)
self.lang_menu.pack(side="right", padx=(5, 0))
# About button
self.about_btn = ctk.CTkButton(
header_bar, text="", width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._show_about
)
self.about_btn.pack(side="right", padx=(5, 5))
# Tabview
self.tabview = ctk.CTkTabview(main)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
# Tabs
self.tabview.add("Terminal")
self.tabview.add("Files")
self.tabview.add("Info")
self.tabview.add("Keys")
self.tabview.add("Setup")
# Tab names stored for language updates
self._tab_keys = ["terminal", "files", "info", "keys", "totp", "setup"]
for key in self._tab_keys:
self.tabview.add(t(key))
self.terminal_tab = TerminalTab(self.tabview.tab("Terminal"), self.store)
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store)
self.terminal_tab.pack(fill="both", expand=True)
self.files_tab = FilesTab(self.tabview.tab("Files"), self.store)
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store)
self.files_tab.pack(fill="both", expand=True)
self.info_tab = InfoTab(self.tabview.tab("Info"), self.store, edit_callback=self._edit_server)
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
self.info_tab.pack(fill="both", expand=True)
self.keys_tab = KeysTab(self.tabview.tab("Keys"), self.store)
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
self.keys_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tabview.tab("Setup"), self.store)
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
self.totp_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
self.setup_tab.pack(fill="both", expand=True)
def _on_server_select(self, alias: str):
@@ -86,6 +114,7 @@ class App(ctk.CTk):
self.files_tab.set_server(alias)
self.info_tab.set_server(alias)
self.keys_tab.set_server(alias)
self.totp_tab.set_server(alias)
def _add_server(self):
dialog = ServerDialog(self, self.store)
@@ -99,7 +128,7 @@ class App(ctk.CTk):
self.info_tab.refresh()
def _delete_server(self, alias: str):
if messagebox.askyesno("Delete Server", f"Remove '{alias}'?"):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)):
self.store.remove_server(alias)
self._on_server_select(None)
@@ -107,6 +136,92 @@ class App(ctk.CTk):
self.sidebar.update_statuses()
self.info_tab.refresh()
def _show_about(self):
AboutDialog(self)
def _get_current_tab_key(self) -> str:
"""Get the i18n key of the currently active tab."""
try:
current_name = self.tabview.get()
# Match against current language translations
for key in self._tab_keys:
if t(key) == current_name:
return key
except Exception:
pass
return self._tab_keys[0]
def _change_language(self, display_name: str):
# Remember current tab KEY before language switch
active_tab_key = self._get_current_tab_key()
# Find lang code from display name
lang_code = "en"
for code, name in LANGUAGES.items():
if name == display_name:
lang_code = code
break
i18n.set_language(lang_code)
self.store._save_settings()
self._apply_language(active_tab_key)
def _apply_language(self, restore_tab_key: str | None = None):
# Remember selected server
alias = self.sidebar.get_selected()
# Use provided key or default to first tab
current_key = restore_tab_key or self._tab_keys[0]
# Detach tab contents
self.terminal_tab.pack_forget()
self.files_tab.pack_forget()
self.info_tab.pack_forget()
self.keys_tab.pack_forget()
self.totp_tab.pack_forget()
self.setup_tab.pack_forget()
# Get the main frame and destroy old tabview
main = self.tabview.master
self.tabview.destroy()
# Create new tabview with translated names
self.tabview = ctk.CTkTabview(main)
self.tabview.pack(fill="both", expand=True, padx=10, pady=10)
for key in self._tab_keys:
self.tabview.add(t(key))
# Re-parent tab contents
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store)
self.terminal_tab.pack(fill="both", expand=True)
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store)
self.files_tab.pack(fill="both", expand=True)
self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
self.info_tab.pack(fill="both", expand=True)
self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store)
self.keys_tab.pack(fill="both", expand=True)
self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store)
self.totp_tab.pack(fill="both", expand=True)
self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store)
self.setup_tab.pack(fill="both", expand=True)
# Restore active tab by key
try:
self.tabview.set(t(current_key))
except Exception:
pass
# Restore server selection
if alias:
self._on_server_select(alias)
# Update sidebar
self.sidebar.update_language()
def _on_close(self):
self.checker.stop()
self.destroy()

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")))

View File

@@ -3,6 +3,7 @@ Sidebar — server list with search, add/edit/delete buttons.
"""
import customtkinter as ctk
from core.i18n import t
from gui.widgets.status_badge import StatusBadge
@@ -18,14 +19,14 @@ class Sidebar(ctk.CTkFrame):
self.pack_propagate(False)
# Title
title = ctk.CTkLabel(self, text="Servers", font=ctk.CTkFont(size=18, weight="bold"))
title.pack(padx=15, pady=(15, 5))
self.title_label = ctk.CTkLabel(self, text=t("servers"), font=ctk.CTkFont(size=18, weight="bold"))
self.title_label.pack(padx=15, pady=(15, 5))
# Search
self.search_var = ctk.StringVar()
self.search_var.trace_add("write", lambda *_: self._refresh_list())
search = ctk.CTkEntry(self, placeholder_text="Search...", textvariable=self.search_var)
search.pack(fill="x", padx=10, pady=(5, 10))
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
self.search_entry.pack(fill="x", padx=10, pady=(5, 10))
# Server list
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
@@ -34,11 +35,11 @@ class Sidebar(ctk.CTkFrame):
# Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=10, pady=10)
self.add_btn = ctk.CTkButton(btn_frame, text="+ Add", width=70, height=30, command=self._on_add)
self.add_btn = ctk.CTkButton(btn_frame, text=t("add"), width=70, height=30, command=self._on_add)
self.add_btn.pack(side="left", padx=(0, 3))
self.edit_btn = ctk.CTkButton(btn_frame, text="Edit", width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn = ctk.CTkButton(btn_frame, text=t("edit"), width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn.pack(side="left", padx=3)
self.del_btn = ctk.CTkButton(btn_frame, text="Delete", width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn = ctk.CTkButton(btn_frame, text=t("delete"), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn.pack(side="right", padx=(3, 0))
# Callbacks for add/edit/delete — set by app.py
@@ -50,6 +51,13 @@ class Sidebar(ctk.CTkFrame):
self.store.subscribe(self._refresh_list)
self._refresh_list()
def update_language(self):
self.title_label.configure(text=t("servers"))
self.search_entry.configure(placeholder_text=t("search"))
self.add_btn.configure(text=t("add"))
self.edit_btn.configure(text=t("edit"))
self.del_btn.configure(text=t("delete"))
def _refresh_list(self):
# Clear
for widget in self.list_frame.winfo_children():

View File

@@ -7,6 +7,7 @@ import threading
import customtkinter as ctk
from tkinter import filedialog
from core.ssh_client import SSHClientWrapper
from core.i18n import t
class FilesTab(ctk.CTkFrame):
@@ -16,48 +17,54 @@ class FilesTab(ctk.CTkFrame):
self._current_alias: str | None = None
# Upload section
upload_label = ctk.CTkLabel(self, text="Upload", font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
upload_label.pack(fill="x", padx=15, pady=(15, 5))
self.upload_label = ctk.CTkLabel(self, text=t("upload"), font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
self.upload_label.pack(fill="x", padx=15, pady=(15, 5))
upload_frame = ctk.CTkFrame(self, fg_color="transparent")
upload_frame.pack(fill="x", padx=15, pady=(0, 5))
ctk.CTkLabel(upload_frame, text="Local:", width=60, anchor="w").pack(side="left")
self.upload_local = ctk.CTkEntry(upload_frame, placeholder_text="/path/to/local/file")
self.upload_local_label = ctk.CTkLabel(upload_frame, text=t("local"), width=60, anchor="w")
self.upload_local_label.pack(side="left")
self.upload_local = ctk.CTkEntry(upload_frame, placeholder_text=t("placeholder_local_file"))
self.upload_local.pack(side="left", fill="x", expand=True, padx=5)
ctk.CTkButton(upload_frame, text="Browse", width=70, command=self._browse_upload).pack(side="right")
self.browse_upload_btn = ctk.CTkButton(upload_frame, text=t("browse"), width=70, command=self._browse_upload)
self.browse_upload_btn.pack(side="right")
upload_remote_frame = ctk.CTkFrame(self, fg_color="transparent")
upload_remote_frame.pack(fill="x", padx=15, pady=(0, 5))
ctk.CTkLabel(upload_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left")
self.upload_remote = ctk.CTkEntry(upload_remote_frame, placeholder_text="/remote/path/file")
self.upload_remote_label = ctk.CTkLabel(upload_remote_frame, text=t("remote"), width=60, anchor="w")
self.upload_remote_label.pack(side="left")
self.upload_remote = ctk.CTkEntry(upload_remote_frame, placeholder_text=t("placeholder_remote_file"))
self.upload_remote.pack(side="left", fill="x", expand=True, padx=5)
self.upload_btn = ctk.CTkButton(upload_remote_frame, text="Upload", width=70, command=self._upload)
self.upload_btn = ctk.CTkButton(upload_remote_frame, text=t("upload"), width=70, command=self._upload)
self.upload_btn.pack(side="right")
# Separator
ctk.CTkFrame(self, height=2, fg_color="gray40").pack(fill="x", padx=15, pady=10)
# Download section
download_label = ctk.CTkLabel(self, text="Download", font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
download_label.pack(fill="x", padx=15, pady=(5, 5))
self.download_label = ctk.CTkLabel(self, text=t("download"), font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
self.download_label.pack(fill="x", padx=15, pady=(5, 5))
download_remote_frame = ctk.CTkFrame(self, fg_color="transparent")
download_remote_frame.pack(fill="x", padx=15, pady=(0, 5))
ctk.CTkLabel(download_remote_frame, text="Remote:", width=60, anchor="w").pack(side="left")
self.download_remote = ctk.CTkEntry(download_remote_frame, placeholder_text="/remote/path/file")
self.download_remote_label = ctk.CTkLabel(download_remote_frame, text=t("remote"), width=60, anchor="w")
self.download_remote_label.pack(side="left")
self.download_remote = ctk.CTkEntry(download_remote_frame, placeholder_text=t("placeholder_remote_file"))
self.download_remote.pack(side="left", fill="x", expand=True, padx=5)
download_local_frame = ctk.CTkFrame(self, fg_color="transparent")
download_local_frame.pack(fill="x", padx=15, pady=(0, 5))
ctk.CTkLabel(download_local_frame, text="Local:", width=60, anchor="w").pack(side="left")
self.download_local = ctk.CTkEntry(download_local_frame, placeholder_text="/path/to/save")
self.download_local_label = ctk.CTkLabel(download_local_frame, text=t("local"), width=60, anchor="w")
self.download_local_label.pack(side="left")
self.download_local = ctk.CTkEntry(download_local_frame, placeholder_text=t("placeholder_save_path"))
self.download_local.pack(side="left", fill="x", expand=True, padx=5)
ctk.CTkButton(download_local_frame, text="Browse", width=70, command=self._browse_download).pack(side="left", padx=(5, 0))
self.download_btn = ctk.CTkButton(download_local_frame, text="Download", width=80, command=self._download)
self.browse_download_btn = ctk.CTkButton(download_local_frame, text=t("browse"), width=70, command=self._browse_download)
self.browse_download_btn.pack(side="left", padx=(5, 0))
self.download_btn = ctk.CTkButton(download_local_frame, text=t("download"), width=80, command=self._download)
self.download_btn.pack(side="right")
# Progress
@@ -94,15 +101,15 @@ class FilesTab(ctk.CTkFrame):
def _upload(self):
if not self._current_alias:
self._log_msg("[!] No server selected")
self._log_msg(t("no_server_selected"))
return
local = self.upload_local.get().strip()
remote = self.upload_remote.get().strip()
if not local or not remote:
self._log_msg("[!] Both paths required")
self._log_msg(t("both_paths_required"))
return
if not os.path.exists(local):
self._log_msg(f"[!] File not found: {local}")
self._log_msg(t("file_not_found").format(path=local))
return
server = self.store.get_server(self._current_alias)
@@ -121,7 +128,7 @@ class FilesTab(ctk.CTkFrame):
try:
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
wrapper.upload(local, remote, progress_cb=_progress)
self.after(0, lambda: self._log_msg(f"OK: {local} -> {self._current_alias}:{remote}"))
self.after(0, lambda: self._log_msg(t("upload_ok").format(local=local, alias=self._current_alias, remote=remote)))
except Exception as e:
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
finally:
@@ -131,12 +138,12 @@ class FilesTab(ctk.CTkFrame):
def _download(self):
if not self._current_alias:
self._log_msg("[!] No server selected")
self._log_msg(t("no_server_selected"))
return
remote = self.download_remote.get().strip()
local = self.download_local.get().strip()
if not remote or not local:
self._log_msg("[!] Both paths required")
self._log_msg(t("both_paths_required"))
return
server = self.store.get_server(self._current_alias)
@@ -154,7 +161,7 @@ class FilesTab(ctk.CTkFrame):
try:
wrapper = SSHClientWrapper(server, self.store.get_ssh_key_path())
wrapper.download(remote, local, progress_cb=_progress)
self.after(0, lambda: self._log_msg(f"OK: {self._current_alias}:{remote} -> {local}"))
self.after(0, lambda: self._log_msg(t("download_ok").format(alias=self._current_alias, remote=remote, local=local)))
except Exception as e:
self.after(0, lambda: self._log_msg(f"[ERROR] {e}"))
finally:

View File

@@ -3,9 +3,22 @@ Info tab — display server details, edit button.
"""
import customtkinter as ctk
from core.i18n import t
class InfoTab(ctk.CTkFrame):
# Map field keys to i18n keys
_FIELD_KEYS = ["alias", "ip", "port", "user", "type", "notes", "status"]
_FIELD_I18N = {
"alias": "info_alias",
"ip": "info_ip",
"port": "info_port",
"user": "info_user",
"type": "info_type",
"notes": "info_notes",
"status": "info_status",
}
def __init__(self, master, store, edit_callback=None):
super().__init__(master, fg_color="transparent")
self.store = store
@@ -13,7 +26,7 @@ class InfoTab(ctk.CTkFrame):
self._current_alias: str | None = None
# Header
self.header = ctk.CTkLabel(self, text="No server selected", font=ctk.CTkFont(size=20, weight="bold"))
self.header = ctk.CTkLabel(self, text=t("no_server_selected_info"), font=ctk.CTkFont(size=20, weight="bold"))
self.header.pack(padx=20, pady=(20, 10))
# Info card
@@ -21,17 +34,20 @@ class InfoTab(ctk.CTkFrame):
self.card.pack(fill="x", padx=20, pady=10)
self._fields: dict[str, ctk.CTkLabel] = {}
for label in ["Alias", "IP", "Port", "User", "Type", "Notes", "Status"]:
self._field_labels: dict[str, ctk.CTkLabel] = {}
for key in self._FIELD_KEYS:
row = ctk.CTkFrame(self.card, fg_color="transparent")
row.pack(fill="x", padx=15, pady=4)
ctk.CTkLabel(row, text=f"{label}:", width=80, anchor="w",
font=ctk.CTkFont(size=12), text_color="#9ca3af").pack(side="left")
label = ctk.CTkLabel(row, text=t(self._FIELD_I18N[key]), width=80, anchor="w",
font=ctk.CTkFont(size=12), text_color="#9ca3af")
label.pack(side="left")
val = ctk.CTkLabel(row, text="-", anchor="w", font=ctk.CTkFont(size=13))
val.pack(side="left", fill="x", expand=True)
self._fields[label] = val
self._field_labels[key] = label
self._fields[key] = val
# Edit button
self.edit_btn = ctk.CTkButton(self, text="Edit Server", command=self._on_edit)
self.edit_btn = ctk.CTkButton(self, text=t("edit_server_btn"), command=self._on_edit)
self.edit_btn.pack(pady=15)
def set_server(self, alias: str | None):
@@ -40,7 +56,7 @@ class InfoTab(ctk.CTkFrame):
def refresh(self):
if not self._current_alias:
self.header.configure(text="No server selected")
self.header.configure(text=t("no_server_selected_info"))
for v in self._fields.values():
v.configure(text="-")
return
@@ -50,16 +66,16 @@ class InfoTab(ctk.CTkFrame):
return
self.header.configure(text=server["alias"])
self._fields["Alias"].configure(text=server.get("alias", "-"))
self._fields["IP"].configure(text=server.get("ip", "-"))
self._fields["Port"].configure(text=str(server.get("port", 22)))
self._fields["User"].configure(text=server.get("user", "root"))
self._fields["Type"].configure(text=server.get("type", "ssh").upper())
self._fields["Notes"].configure(text=server.get("notes", "-") or "-")
self._fields["alias"].configure(text=server.get("alias", "-"))
self._fields["ip"].configure(text=server.get("ip", "-"))
self._fields["port"].configure(text=str(server.get("port", 22)))
self._fields["user"].configure(text=server.get("user", "root"))
self._fields["type"].configure(text=server.get("type", "ssh").upper())
self._fields["notes"].configure(text=server.get("notes", "-") or "-")
status = self.store.get_status(self._current_alias)
color = {"online": "#22c55e", "offline": "#ef4444"}.get(status, "#9ca3af")
self._fields["Status"].configure(text=status.upper(), text_color=color)
self._fields["status"].configure(text=status.upper(), text_color=color)
def _on_edit(self):
if self.edit_callback and self._current_alias:

View File

@@ -6,6 +6,7 @@ import os
import threading
import customtkinter as ctk
from core.ssh_client import SSHClientWrapper
from core.i18n import t
class KeysTab(ctk.CTkFrame):
@@ -15,7 +16,8 @@ class KeysTab(ctk.CTkFrame):
self._current_alias: str | None = None
# Key info
ctk.CTkLabel(self, text="SSH Key", font=ctk.CTkFont(size=16, weight="bold"), anchor="w").pack(fill="x", padx=15, pady=(15, 5))
self.key_title = ctk.CTkLabel(self, text=t("ssh_key"), font=ctk.CTkFont(size=16, weight="bold"), anchor="w")
self.key_title.pack(fill="x", padx=15, pady=(15, 5))
self.key_path_label = ctk.CTkLabel(self, text="", anchor="w", text_color="#9ca3af")
self.key_path_label.pack(fill="x", padx=15)
@@ -27,13 +29,13 @@ class KeysTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5)
self.gen_btn = ctk.CTkButton(btn_frame, text="Generate Key", command=self._generate)
self.gen_btn = ctk.CTkButton(btn_frame, text=t("generate_key"), command=self._generate)
self.gen_btn.pack(side="left", padx=(0, 10))
self.install_btn = ctk.CTkButton(btn_frame, text="Install on Server", fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn = ctk.CTkButton(btn_frame, text=t("install_on_server"), fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn.pack(side="left")
self.copy_btn = ctk.CTkButton(btn_frame, text="Copy Public Key", fg_color="#6b7280", command=self._copy_key)
self.copy_btn = ctk.CTkButton(btn_frame, text=t("copy_public_key"), fg_color="#6b7280", command=self._copy_key)
self.copy_btn.pack(side="right")
# Status log
@@ -48,7 +50,7 @@ class KeysTab(ctk.CTkFrame):
def _refresh_key_info(self):
key_path = self.store.get_ssh_key_path()
pub_path = key_path + ".pub"
self.key_path_label.configure(text=f"Path: {key_path}")
self.key_path_label.configure(text=t("key_path").format(path=key_path))
self.pub_key_box.configure(state="normal")
self.pub_key_box.delete("1.0", "end")
@@ -57,10 +59,10 @@ class KeysTab(ctk.CTkFrame):
with open(pub_path, "r") as f:
pub_key = f.read().strip()
self.pub_key_box.insert("1.0", pub_key)
self.gen_btn.configure(state="disabled", text="Key exists")
self.gen_btn.configure(state="disabled", text=t("key_exists"))
else:
self.pub_key_box.insert("1.0", "No key found. Click 'Generate Key' to create one.")
self.gen_btn.configure(state="normal", text="Generate Key")
self.pub_key_box.insert("1.0", t("no_key_found"))
self.gen_btn.configure(state="normal", text=t("generate_key"))
self.pub_key_box.configure(state="disabled")
@@ -82,14 +84,14 @@ class KeysTab(ctk.CTkFrame):
def _install(self):
if not self._current_alias:
self._log("[!] No server selected")
self._log(t("no_server_selected"))
return
server = self.store.get_server(self._current_alias)
if not server:
return
self.install_btn.configure(state="disabled", text="Installing...")
self.install_btn.configure(state="disabled", text=t("installing"))
def _do():
try:
@@ -99,7 +101,7 @@ class KeysTab(ctk.CTkFrame):
except Exception as e:
self.after(0, lambda: self._log(f"[ERROR] {e}"))
finally:
self.after(0, lambda: self.install_btn.configure(state="normal", text="Install on Server"))
self.after(0, lambda: self.install_btn.configure(state="normal", text=t("install_on_server")))
threading.Thread(target=_do, daemon=True).start()
@@ -111,6 +113,6 @@ class KeysTab(ctk.CTkFrame):
pub_key = f.read().strip()
self.clipboard_clear()
self.clipboard_append(pub_key)
self._log("Public key copied to clipboard")
self._log(t("key_copied"))
else:
self._log("[!] No public key to copy")
self._log(t("no_public_key"))

View File

@@ -1,10 +1,14 @@
"""
Setup tab — one-click installation for Claude Code integration.
Includes configuration path management and backup/restore.
"""
import os
import threading
from tkinter import filedialog, messagebox
import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t
class SetupTab(ctk.CTkFrame):
@@ -13,50 +17,54 @@ class SetupTab(ctk.CTkFrame):
self.store = store
# Header
ctk.CTkLabel(
self, text="Claude Code Integration",
self.header_label = ctk.CTkLabel(
self, text=t("claude_integration"),
font=ctk.CTkFont(size=20, weight="bold")
).pack(padx=20, pady=(20, 5))
)
self.header_label.pack(padx=20, pady=(20, 5))
ctk.CTkLabel(
self,
text="Setup everything so Claude Code can manage your servers via /ssh skill.\n"
"Both GUI and Claude Code share the same servers.json — add a server here,\n"
"Claude sees it immediately.",
self.desc_label = ctk.CTkLabel(
self, text=t("claude_desc"),
text_color="#9ca3af", justify="center"
).pack(padx=20, pady=(0, 15))
)
self.desc_label.pack(padx=20, pady=(0, 15))
# Status card
self.status_frame = ctk.CTkFrame(self)
self.status_frame.pack(fill="x", padx=20, pady=10)
ctk.CTkLabel(
self.status_frame, text="Status",
self.status_title = ctk.CTkLabel(
self.status_frame, text=t("status"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
).pack(fill="x", padx=15, pady=(10, 5))
)
self.status_title.pack(fill="x", padx=15, pady=(10, 5))
self._status_labels: dict[str, ctk.CTkLabel] = {}
self._status_text_labels: dict[str, ctk.CTkLabel] = {}
status_items = [
("shared_dir", "Shared config dir (~/.server-connections)"),
("servers_json", "servers.json"),
("ssh_script", "ssh.py (CLI tool)"),
("skill_installed", "/ssh skill for Claude Code"),
("ssh_key_exists", "SSH key (ed25519)"),
("shared_dir", "status_shared_dir"),
("servers_json", "status_servers_json"),
("ssh_script", "status_ssh_script"),
("encryption", "status_encryption"),
("skill_installed", "status_skill"),
("ssh_key_exists", "status_ssh_key"),
]
for key, label in status_items:
for key, i18n_key in status_items:
row = ctk.CTkFrame(self.status_frame, fg_color="transparent")
row.pack(fill="x", padx=15, pady=2)
indicator = ctk.CTkLabel(row, text="\u25cf", width=20, text_color="#6b7280")
indicator.pack(side="left")
ctk.CTkLabel(row, text=label, anchor="w").pack(side="left", fill="x", expand=True)
text_label = ctk.CTkLabel(row, text=t(i18n_key), anchor="w")
text_label.pack(side="left", fill="x", expand=True)
self._status_labels[key] = indicator
self._status_text_labels[key] = (text_label, i18n_key)
# Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=15)
self.install_all_btn = ctk.CTkButton(
btn_frame, text="Install Everything",
btn_frame, text=t("install_everything"),
font=ctk.CTkFont(size=14, weight="bold"),
height=40, fg_color="#22c55e", hover_color="#16a34a",
command=self._install_all
@@ -67,14 +75,71 @@ class SetupTab(ctk.CTkFrame):
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
ind_frame.pack(fill="x")
ctk.CTkButton(ind_frame, text="ssh.py", width=100, fg_color="#6b7280",
command=self._install_script).pack(side="left", padx=(0, 5))
ctk.CTkButton(ind_frame, text="/ssh skill", width=100, fg_color="#6b7280",
command=self._install_skill).pack(side="left", padx=5)
ctk.CTkButton(ind_frame, text="SSH key", width=100, fg_color="#6b7280",
command=self._gen_key).pack(side="left", padx=5)
ctk.CTkButton(ind_frame, text="Refresh", width=80, fg_color="#3b82f6",
command=self._refresh_status).pack(side="right")
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_py"), width=100, fg_color="#6b7280",
command=self._install_script)
self.ssh_py_btn.pack(side="left", padx=(0, 5))
self.skill_btn = ctk.CTkButton(ind_frame, text=t("install_skill"), width=100, fg_color="#6b7280",
command=self._install_skill)
self.skill_btn.pack(side="left", padx=5)
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_key"), width=100, fg_color="#6b7280",
command=self._gen_key)
self.ssh_key_btn.pack(side="left", padx=5)
self.refresh_btn = ctk.CTkButton(ind_frame, text=t("refresh"), width=80, fg_color="#3b82f6",
command=self._refresh_status)
self.refresh_btn.pack(side="right")
# ── Configuration section ─────────────────────
config_frame = ctk.CTkFrame(self)
config_frame.pack(fill="x", padx=20, pady=(5, 5))
self.config_title = ctk.CTkLabel(
config_frame, text=t("configuration"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
)
self.config_title.pack(fill="x", padx=15, pady=(10, 5))
# Config path row
path_row = ctk.CTkFrame(config_frame, fg_color="transparent")
path_row.pack(fill="x", padx=15, pady=5)
self.config_label = ctk.CTkLabel(path_row, text=t("config_label"), anchor="w", width=60)
self.config_label.pack(side="left")
self._path_label = ctk.CTkLabel(
path_row, text=store.get_config_path(),
anchor="w", text_color="#9ca3af",
font=ctk.CTkFont(family="Consolas", size=11)
)
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
self.change_path_btn = ctk.CTkButton(
path_row, text=t("change_path"), width=100, fg_color="#6b7280",
command=self._change_config_path
)
self.change_path_btn.pack(side="right")
# Backup row
backup_row = ctk.CTkFrame(config_frame, fg_color="transparent")
backup_row.pack(fill="x", padx=15, pady=(5, 10))
self.backup_btn = ctk.CTkButton(
backup_row, text=t("backup_now"), width=100, fg_color="#3b82f6",
command=self._backup_now
)
self.backup_btn.pack(side="left", padx=(0, 10))
self._backup_var = ctk.StringVar(value=t("select_backup"))
backups = store.list_backups()
values = backups if backups else [t("no_backups")]
self._backup_menu = ctk.CTkOptionMenu(
backup_row, variable=self._backup_var,
values=values, width=250
)
self._backup_menu.pack(side="left", padx=(0, 10))
self.restore_btn = ctk.CTkButton(
backup_row, text=t("restore"), width=80, fg_color="#ef4444", hover_color="#dc2626",
command=self._restore_backup
)
self.restore_btn.pack(side="left")
# Log
self.log = ctk.CTkTextbox(self, height=150, font=ctk.CTkFont(family="Consolas", size=11), state="disabled")
@@ -98,15 +163,15 @@ class SetupTab(ctk.CTkFrame):
label.configure(text="\u25cf", text_color="#ef4444") # red
def _install_all(self):
self.install_all_btn.configure(state="disabled", text="Installing...")
self.install_all_btn.configure(state="disabled", text=t("installing_all"))
def _do():
results = install_all()
for msg in results:
self.after(0, lambda m=msg: self._log(m))
self.after(0, self._refresh_status)
self.after(0, lambda: self._log("\nDone! Claude Code can now use /ssh to manage your servers."))
self.after(0, lambda: self.install_all_btn.configure(state="normal", text="Install Everything"))
self.after(0, lambda: self._log("\n" + t("install_done")))
self.after(0, lambda: self.install_all_btn.configure(state="normal", text=t("install_everything")))
threading.Thread(target=_do, daemon=True).start()
@@ -124,3 +189,46 @@ class SetupTab(ctk.CTkFrame):
msg = generate_ssh_key()
self._log(msg)
self._refresh_status()
# ── Configuration methods ─────────────────────────
def _change_config_path(self):
path = filedialog.askopenfilename(
title=t("select_servers_json"),
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
initialdir=os.path.dirname(self.store.get_config_path())
)
if path:
self.store.set_config_path(path)
self._path_label.configure(text=path)
self._log(t("config_changed").format(path=path))
def _backup_now(self):
try:
name = self.store.create_backup()
self._log(t("backup_created").format(name=name))
self._refresh_backups()
except Exception as e:
self._log(t("backup_failed").format(e=e))
def _restore_backup(self):
selected = self._backup_var.get()
if not selected or selected in (t("select_backup"), t("no_backups")):
self._log(t("no_backup_selected"))
return
if not messagebox.askyesno(t("restore_backup_title"), t("restore_confirm").format(name=selected)):
return
try:
self.store.restore_backup(selected)
self._log(t("restored").format(name=selected))
except Exception as e:
self._log(t("restore_failed").format(e=e))
def _refresh_backups(self):
backups = self.store.list_backups()
if backups:
self._backup_menu.configure(values=backups)
self._backup_var.set(backups[0])
else:
self._backup_menu.configure(values=[t("no_backups")])
self._backup_var.set(t("no_backups"))

View File

@@ -5,6 +5,7 @@ Terminal tab — command input + output display.
import threading
import customtkinter as ctk
from core.ssh_client import SSHClientWrapper
from core.i18n import t
class TerminalTab(ctk.CTkFrame):
@@ -22,17 +23,17 @@ class TerminalTab(ctk.CTkFrame):
input_frame.pack(fill="x", padx=10, pady=(0, 10))
self.sudo_var = ctk.BooleanVar(value=True)
self.sudo_check = ctk.CTkCheckBox(input_frame, text="sudo", variable=self.sudo_var, width=60)
self.sudo_check = ctk.CTkCheckBox(input_frame, text=t("sudo"), variable=self.sudo_var, width=60)
self.sudo_check.pack(side="left", padx=(0, 5))
self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text="Enter command...")
self.cmd_entry = ctk.CTkEntry(input_frame, placeholder_text=t("enter_command"))
self.cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.cmd_entry.bind("<Return>", lambda e: self._run_command())
self.run_btn = ctk.CTkButton(input_frame, text="Run", width=70, command=self._run_command)
self.run_btn = ctk.CTkButton(input_frame, text=t("run"), width=70, command=self._run_command)
self.run_btn.pack(side="left", padx=(0, 5))
self.clear_btn = ctk.CTkButton(input_frame, text="Clear", width=60, fg_color="#6b7280", command=self._clear)
self.clear_btn = ctk.CTkButton(input_frame, text=t("clear"), width=60, fg_color="#6b7280", command=self._clear)
self.clear_btn.pack(side="right")
def set_server(self, alias: str | None):
@@ -50,7 +51,7 @@ class TerminalTab(ctk.CTkFrame):
def _run_command(self):
if not self._current_alias:
self._append_output("[!] No server selected\n")
self._append_output(t("no_server_selected") + "\n")
return
command = self.cmd_entry.get().strip()
@@ -59,7 +60,7 @@ class TerminalTab(ctk.CTkFrame):
server = self.store.get_server(self._current_alias)
if not server:
self._append_output(f"[!] Server '{self._current_alias}' not found\n")
self._append_output(t("server_not_found").format(alias=self._current_alias) + "\n")
return
self.cmd_entry.delete(0, "end")
@@ -87,13 +88,13 @@ class TerminalTab(ctk.CTkFrame):
if code != 0:
self._append_output(f"[exit code: {code}]\n")
self._append_output("\n")
self.run_btn.configure(state="normal", text="Run")
self.run_btn.configure(state="normal", text=t("run"))
self.after(0, _show)
except Exception as e:
def _err():
self._append_output(f"[ERROR] {e}\n\n")
self.run_btn.configure(state="normal", text="Run")
self.run_btn.configure(state="normal", text=t("run"))
self.after(0, _err)
threading.Thread(target=_exec, daemon=True).start()

285
gui/tabs/totp_tab.py Normal file
View File

@@ -0,0 +1,285 @@
"""
TOTP tab — Google Authenticator compatible 2FA codes.
Live countdown, one-click copy, per-server secrets.
"""
import threading
import customtkinter as ctk
from core.i18n import t
class TOTPTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._timer_id = None
# Title
self.title_label = ctk.CTkLabel(
self, text=t("totp_title"),
font=ctk.CTkFont(size=16, weight="bold"), anchor="w"
)
self.title_label.pack(fill="x", padx=15, pady=(15, 5))
# Description
self.desc_label = ctk.CTkLabel(
self, text=t("totp_desc"),
anchor="w", text_color="#9ca3af", wraplength=600, justify="left"
)
self.desc_label.pack(fill="x", padx=15, pady=(0, 10))
# Server name
self.server_label = ctk.CTkLabel(
self, text=t("no_server_selected"),
font=ctk.CTkFont(size=13), anchor="w", text_color="#6b7280"
)
self.server_label.pack(fill="x", padx=15, pady=(0, 10))
# Code display frame
code_frame = ctk.CTkFrame(self, fg_color="#1e1e2e", corner_radius=12)
code_frame.pack(fill="x", padx=15, pady=(0, 10))
self.code_label = ctk.CTkLabel(
code_frame, text="------",
font=ctk.CTkFont(family="Consolas", size=42, weight="bold"),
text_color="#22c55e"
)
self.code_label.pack(pady=(20, 5))
self.timer_label = ctk.CTkLabel(
code_frame, text="",
font=ctk.CTkFont(size=12), text_color="#9ca3af"
)
self.timer_label.pack(pady=(0, 5))
self.progress_bar = ctk.CTkProgressBar(code_frame, width=300, height=6)
self.progress_bar.pack(pady=(0, 15))
self.progress_bar.set(1.0)
# Copy button
self.copy_btn = ctk.CTkButton(
self, text=t("totp_copy"), width=200, height=40,
font=ctk.CTkFont(size=14),
fg_color="#22c55e", hover_color="#16a34a",
command=self._copy_code
)
self.copy_btn.pack(pady=(5, 15))
# Secret management section
secret_frame = ctk.CTkFrame(self, fg_color="transparent")
secret_frame.pack(fill="x", padx=15, pady=(10, 5))
ctk.CTkLabel(
secret_frame, text=t("totp_secret_label"),
font=ctk.CTkFont(size=13, weight="bold"), anchor="w"
).pack(fill="x")
entry_row = ctk.CTkFrame(secret_frame, fg_color="transparent")
entry_row.pack(fill="x", pady=(5, 0))
self.secret_entry = ctk.CTkEntry(
entry_row, show="*",
placeholder_text=t("totp_secret_placeholder"),
font=ctk.CTkFont(family="Consolas", size=12)
)
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_secret_btn = ctk.CTkButton(
entry_row, text=t("show"), width=70,
fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_secret
)
self.show_secret_btn.pack(side="left", padx=(0, 5))
self._secret_visible = False
self.save_secret_btn = ctk.CTkButton(
entry_row, text=t("totp_save_secret"), width=100,
command=self._save_secret
)
self.save_secret_btn.pack(side="left", padx=(0, 5))
self.remove_secret_btn = ctk.CTkButton(
entry_row, text=t("totp_remove_secret"), width=100,
fg_color="#ef4444", hover_color="#dc2626",
command=self._remove_secret
)
self.remove_secret_btn.pack(side="left")
# Generate random secret button
self.gen_secret_btn = ctk.CTkButton(
secret_frame, text=t("totp_generate_secret"), width=180,
fg_color="#6b7280", hover_color="#4b5563",
command=self._generate_secret
)
self.gen_secret_btn.pack(anchor="w", pady=(8, 0))
# Status log
self.status_label = ctk.CTkLabel(
self, text="", anchor="w", text_color="#9ca3af"
)
self.status_label.pack(fill="x", padx=15, pady=(5, 10))
def set_server(self, alias: str | None):
self._current_alias = alias
self._stop_timer()
if not alias:
self.server_label.configure(text=t("no_server_selected"))
self.code_label.configure(text="------")
self.timer_label.configure(text="")
self.progress_bar.set(1.0)
self.secret_entry.delete(0, "end")
self.status_label.configure(text="")
return
self.server_label.configure(text=f"🖥 {alias}")
server = self.store.get_server(alias)
if not server:
return
secret = server.get("totp_secret", "")
self.secret_entry.delete(0, "end")
if secret:
self.secret_entry.insert(0, secret)
self._start_timer()
else:
self.code_label.configure(text="------")
self.timer_label.configure(text=t("totp_no_secret"))
self.progress_bar.set(1.0)
def _start_timer(self):
self._stop_timer()
self._update_code()
def _stop_timer(self):
if self._timer_id:
self.after_cancel(self._timer_id)
self._timer_id = None
def _update_code(self):
if not self._current_alias:
return
server = self.store.get_server(self._current_alias)
if not server:
return
secret = server.get("totp_secret", "")
if not secret:
self.code_label.configure(text="------")
self.timer_label.configure(text=t("totp_no_secret"))
self.progress_bar.set(1.0)
return
try:
from core.totp import get_code_with_timer
data = get_code_with_timer(secret)
code = data["code"]
remaining = data["remaining"]
progress = data["progress"]
# Format code with space in middle: "123 456"
formatted = f"{code[:3]} {code[3:]}"
self.code_label.configure(text=formatted)
self.timer_label.configure(text=t("totp_remaining").format(sec=remaining))
self.progress_bar.set(progress)
# Color based on time remaining
if remaining <= 5:
self.code_label.configure(text_color="#ef4444")
elif remaining <= 10:
self.code_label.configure(text_color="#f59e0b")
else:
self.code_label.configure(text_color="#22c55e")
except Exception as e:
self.code_label.configure(text="ERROR")
self.timer_label.configure(text=str(e))
# Schedule next update in 1 second
self._timer_id = self.after(1000, self._update_code)
def _copy_code(self):
code_text = self.code_label.cget("text").replace(" ", "")
if code_text and code_text != "------" and code_text != "ERROR":
self.clipboard_clear()
self.clipboard_append(code_text)
self.status_label.configure(text=t("totp_copied"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
else:
self.status_label.configure(text=t("totp_no_code"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
def _toggle_secret(self):
self._secret_visible = not self._secret_visible
self.secret_entry.configure(show="" if self._secret_visible else "*")
self.show_secret_btn.configure(text=t("hide") if self._secret_visible else t("show"))
def _save_secret(self):
if not self._current_alias:
self.status_label.configure(text=t("no_server_selected"), text_color="#ef4444")
return
secret = self.secret_entry.get().strip()
if not secret:
self.status_label.configure(text=t("totp_secret_empty"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
return
# Validate secret
try:
from core.totp import get_code
get_code(secret)
except Exception:
self.status_label.configure(text=t("totp_secret_invalid"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
return
server = self.store.get_server(self._current_alias)
if server:
server["totp_secret"] = secret
self.store.update_server(self._current_alias, server)
self.status_label.configure(text=t("totp_secret_saved"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
self._start_timer()
def _remove_secret(self):
if not self._current_alias:
return
server = self.store.get_server(self._current_alias)
if server and "totp_secret" in server:
del server["totp_secret"]
self.store.update_server(self._current_alias, server)
self.secret_entry.delete(0, "end")
self._stop_timer()
self.code_label.configure(text="------", text_color="#22c55e")
self.timer_label.configure(text="")
self.progress_bar.set(1.0)
self.status_label.configure(text=t("totp_secret_removed"), text_color="#9ca3af")
self.after(2000, lambda: self.status_label.configure(text=""))
def _generate_secret(self):
try:
from core.totp import generate_secret
secret = generate_secret()
self.secret_entry.delete(0, "end")
self.secret_entry.insert(0, secret)
self.status_label.configure(text=t("totp_secret_generated"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
except Exception as e:
self.status_label.configure(text=f"Error: {e}", text_color="#ef4444")
def update_language(self):
self.title_label.configure(text=t("totp_title"))
self.desc_label.configure(text=t("totp_desc"))
self.copy_btn.configure(text=t("totp_copy"))
self.save_secret_btn.configure(text=t("totp_save_secret"))
self.remove_secret_btn.configure(text=t("totp_remove_secret"))
self.gen_secret_btn.configure(text=t("totp_generate_secret"))
self.show_secret_btn.configure(
text=t("hide") if self._secret_visible else t("show")
)
if not self._current_alias:
self.server_label.configure(text=t("no_server_selected"))