diff --git a/CLAUDE.md b/CLAUDE.md index b597973..fff9d74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,7 @@ tools/ - **Observer** — `ServerStore` → UI обновляется автоматически - **Session pool** — SSH-сессии живут при переключении серверов - **Auto-sudo** — детекция `[sudo] password for`, автоотправка пароля +- **Windows sanitize** — `ssh.py` автоматически транслирует Linux-команды (ls, cat, grep, ps, etc.) в Windows-эквиваленты (cmd.exe/PowerShell) при подключении к Windows SSH серверам. Pipe-цепочки и && корректно обрабатываются. Кодировка принудительно UTF-8 через `chcp 65001` - **i18n** — все строки через `t(key)`, 3 языка ## Как пользоваться /ssh diff --git a/core/i18n.py b/core/i18n.py index 6a38f6c..2466efd 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -402,6 +402,8 @@ _EN = { "rdp_quality_lan": "LAN (Best)", "rdp_quality_broadband": "Broadband", "rdp_quality_modem": "Low Bandwidth", + "rdp_resolution": "Resolution", + "rdp_resolution_auto": "Auto (Fit Window)", "rdp_clipboard": "Share Clipboard", "rdp_drives": "Share Drives (Files)", "rdp_printers": "Share Printers", @@ -812,6 +814,8 @@ _RU = { "rdp_quality_lan": "LAN (лучшее)", "rdp_quality_broadband": "Broadband", "rdp_quality_modem": "Низкое", + "rdp_resolution": "Разрешение", + "rdp_resolution_auto": "Авто (по размеру окна)", "rdp_clipboard": "Буфер обмена", "rdp_drives": "Проброс дисков (файлы)", "rdp_printers": "Принтеры", @@ -1222,6 +1226,8 @@ _ZH = { "rdp_quality_lan": "局域网 (最佳)", "rdp_quality_broadband": "宽带", "rdp_quality_modem": "低带宽", + "rdp_resolution": "分辨率", + "rdp_resolution_auto": "自动 (适应窗口)", "rdp_clipboard": "共享剪贴板", "rdp_drives": "共享驱动器 (文件)", "rdp_printers": "共享打印机", diff --git a/core/icons.py b/core/icons.py new file mode 100644 index 0000000..3e39b95 --- /dev/null +++ b/core/icons.py @@ -0,0 +1,176 @@ +""" +Icon registry — semantic Unicode symbols for all GUI elements. +Centralized icon management for buttons, tabs, menus, and type badges. +""" + +# Semantic icon mapping +ICONS = { + # Navigation + "back": "\u2190", # ← + "up": "\u2191", # ↑ + "refresh": "\u21bb", # ↻ + + # CRUD + "add": "\uff0b", # + + "edit": "\u270e", # ✎ + "delete": "\u2715", # ✕ + "confirm": "\u2713", # ✓ + + # Transfer + "upload": "\u2b06", # ⬆ + "download": "\u2b07", # ⬇ + + # Actions + "execute": "\u25b6", # ▶ + "info": "\u2139", # ℹ + "clear": "\u232b", # ⌫ + "search": "\U0001f50d", # 🔍 + "hash": "#", + + # Files + "folder": "\U0001f4c1", # 📁 + "folder_open": "\U0001f4c2", # 📂 + "save": "\U0001f4be", # 💾 + + # Keys & security + "key": "\U0001f511", # 🔑 + "lock": "\U0001f510", # 🔐 + "eye": "\U0001f441", # 👁 + + # Clipboard + "copy": "\U0001f4cb", # 📋 + + # Settings + "gear": "\u2699", # ⚙ + "globe": "\U0001f310", # 🌐 + + # Status + "online": "\u25cf", # ● + "checking": "\u25d0", # ◐ + "offline": "\u2014", # — + + # Tabs + "terminal": "\u2328", # ⌨ + "query": "\u25b6", # ▶ + "dashboards": "\U0001f4ca", # 📊 + "metrics": "\U0001f4c8", # 📈 + "powershell": "\u2328", # ⌨ + "launch": "\U0001f5a5", # 🖥 + "totp": "\U0001f510", # 🔐 + + # Context menu + "connect": "\u25b6", # ▶ + "browser": "\U0001f310", # 🌐 + "status_check": "\u25cf",# ● +} + + +def icon(name: str) -> str: + """Get icon symbol by semantic name.""" + return ICONS.get(name, "") + + +def icon_text(name: str, label: str) -> str: + """Format 'icon label' string for buttons/menus.""" + sym = ICONS.get(name, "") + if sym: + return f"{sym} {label}" + return label + + +# Server type colors +TYPE_COLORS = { + "ssh": "#22c55e", + "telnet": "#a855f7", + "rdp": "#3b82f6", + "vnc": "#6366f1", + "winrm": "#0ea5e9", + "mariadb": "#f59e0b", + "mssql": "#ef4444", + "postgresql": "#3b82f6", + "redis": "#dc2626", + "grafana": "#f97316", + "prometheus": "#e11d48", +} + +# Unicode symbols for each server type (reliable, no PIL needed) +TYPE_SYMBOLS = { + "ssh": "\U0001f5a5", # 🖥 + "telnet": "\U0001f4df", # 📟 + "rdp": "\U0001f5b5", # 🖵 + "vnc": "\U0001f5b5", # 🖵 + "winrm": "\u229e", # ⊞ + "mariadb": "\U0001f4be", # 💾 + "mssql": "\U0001f4be", # 💾 + "postgresql": "\U0001f418",# 🐘 + "redis": "\u25c6", # ◆ + "grafana": "\U0001f4ca", # 📊 + "prometheus": "\U0001f525", # 🔥 +} + +# Short text labels for sidebar badge +TYPE_LABELS = { + "ssh": "SSH", + "telnet": "TEL", + "rdp": "RDP", + "vnc": "VNC", + "winrm": "PS", + "mariadb": "MDB", + "mssql": "SQL", + "postgresql": "PG", + "redis": "RDS", + "grafana": "GRF", + "prometheus": "PRM", +} + + +def type_display(server_type: str) -> str: + """Return 'symbol type' for dropdown display, e.g. '🖥 ssh'.""" + sym = TYPE_SYMBOLS.get(server_type, "") + if sym: + return f"{sym} {server_type}" + return server_type + + +def type_from_display(display: str) -> str: + """Extract raw type from display string, e.g. '🖥 ssh' -> 'ssh'.""" + # Strip the leading symbol + space + for stype in TYPE_SYMBOLS: + suffix = f" {stype}" + if display.endswith(suffix) and len(display) == len(suffix) + len(TYPE_SYMBOLS.get(stype, "")): + return stype + # Fallback: return as-is (might already be raw type) + return display.strip() + + +# Tab icon mapping (tab_key -> icon_name) +TAB_ICONS = { + "terminal": "terminal", + "files": "folder", + "info": "info", + "keys": "key", + "totp": "totp", + "setup": "gear", + "query": "query", + "console": "terminal", + "dashboards": "dashboards", + "metrics": "metrics", + "powershell": "powershell", + "launch": "launch", +} + +# Context menu icon mapping (i18n_key -> icon_name) +CTX_ICONS = { + "ctx_open_terminal": "terminal", + "ctx_browse_files": "folder", + "ctx_install_key": "key", + "ctx_open_powershell": "powershell", + "ctx_open_query": "query", + "ctx_open_console": "terminal", + "ctx_connect": "connect", + "ctx_open_browser": "browser", + "ctx_check_status": "status_check", + "ctx_copy_alias": "copy", + "edit": "edit", + "delete": "delete", +} diff --git a/core/remote_desktop.py b/core/remote_desktop.py index e77f75b..092e2d4 100644 --- a/core/remote_desktop.py +++ b/core/remote_desktop.py @@ -250,8 +250,12 @@ class EmbeddedRDP: self.on_failed = None # called on embed failure self.on_disconnected = None # called when mstsc exits - def generate_rdp_file(self, width: int, height: int) -> str: - """Build a .rdp temp file with all settings.""" + def generate_rdp_file(self, window_w: int, window_h: int) -> str: + """Build a .rdp temp file with all settings. + + window_w/window_h: physical frame size for embedding. + Session resolution comes from settings["resolution"] (e.g. "1920x1080" or "auto"). + """ s = self.server cfg = self.settings hostname = s["ip"] @@ -259,6 +263,17 @@ class EmbeddedRDP: user = s.get("user", "Administrator") password = s.get("password", "") + # Session resolution. + # "auto": use frame size — session matches the embed area exactly. + # Fixed (e.g. "1920x1080"): use that resolution, smart sizing scales + # the content to fit the frame. + resolution = cfg.get("resolution", "auto") + if resolution != "auto": + parts = resolution.split("x") + desk_w, desk_h = int(parts[0]), int(parts[1]) + else: + desk_w, desk_h = window_w, window_h + quality = cfg.get("quality", "auto") conn_type, bpp, no_wallpaper, no_themes, font_smooth, aero = _QUALITY_PRESETS.get(quality, _QUALITY_PRESETS["auto"]) @@ -269,10 +284,10 @@ class EmbeddedRDP: lines = [ f"full address:s:{hostname}:{port}", f"username:s:{user}", - f"desktopwidth:i:{width}", - f"desktopheight:i:{height}", + f"desktopwidth:i:{desk_w}", + f"desktopheight:i:{desk_h}", "screen mode id:i:1", # windowed (required for embedding) - "smart sizing:i:1", # scale to window + "use multimon:i:0", f"session bpp:i:{bpp}", f"connection type:i:{conn_type}", "compression:i:1", @@ -297,6 +312,10 @@ class EmbeddedRDP: "enablerdsaadauth:i:0", ] + # smart sizing scales the fixed-resolution bitmap to fit the mstsc window. + # No dynamic resolution — session resolution is set once at connect time. + lines.append("smart sizing:i:1") + if drives: lines.append(f"drivestoredirect:s:{drives}") @@ -314,8 +333,12 @@ class EmbeddedRDP: log.info(f"Embedded RDP file: {self._rdp_file}") return self._rdp_file - def launch(self, parent_hwnd: int, width: int = 1024, height: int = 768): - """Launch mstsc and start background embed thread.""" + def launch(self, parent_hwnd: int, window_w: int = 1024, window_h: int = 768): + """Launch mstsc and start background embed thread. + + window_w/window_h: physical size of the embedding frame. + Session resolution is set via settings["resolution"] in the .rdp file. + """ self._parent_hwnd = parent_hwnd # Pre-trust server certificate to suppress dialog @@ -323,16 +346,17 @@ class EmbeddedRDP: port = self.server.get("port", 3389) _trust_rdp_server(hostname, port) - rdp_file = self.generate_rdp_file(width, height) + rdp_file = self.generate_rdp_file(window_w, window_h) self._launch_time = time.time() + # Always launch mstsc at frame size — smart sizing scales the session self._process = subprocess.Popen( - ["mstsc.exe", rdp_file, f"/w:{width}", f"/h:{height}"], + ["mstsc.exe", rdp_file, f"/w:{window_w}", f"/h:{window_h}"], creationflags=0x00000010, # CREATE_NEW_CONSOLE suppressed ) log.info(f"mstsc.exe launched, PID={self._process.pid}") - threading.Thread(target=self._find_and_embed, args=(parent_hwnd, width, height), daemon=True).start() + threading.Thread(target=self._find_and_embed, args=(parent_hwnd, window_w, window_h), daemon=True).start() def _find_and_embed(self, parent_hwnd: int, width: int, height: int): """Background: poll for mstsc window using multi-strategy search, then embed. @@ -567,10 +591,13 @@ class EmbeddedRDP: # Resize to fill parent user32.MoveWindow(hwnd, 0, 0, width, height, True) - # Apply style change + # Apply style change — recalculates non-client area user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOZORDER | 0x0001 | 0x0002) + # Re-position after FRAMECHANGED to fix non-client area offset + user32.MoveWindow(hwnd, 0, 0, width, height, True) + # Focus user32.SetFocus(hwnd) @@ -593,9 +620,14 @@ class EmbeddedRDP: """Resize the embedded mstsc window.""" if not self._mstsc_hwnd or not self._connected: return + if width < 200 or height < 150: + return # Ignore degenerate sizes try: import ctypes - ctypes.windll.user32.MoveWindow(self._mstsc_hwnd, 0, 0, width, height, True) + user32 = ctypes.windll.user32 + if not user32.IsWindow(self._mstsc_hwnd): + return + user32.MoveWindow(self._mstsc_hwnd, 0, 0, width, height, True) except Exception: pass @@ -610,7 +642,7 @@ class EmbeddedRDP: pass def detach(self): - """Detach mstsc from parent — for fullscreen.""" + """Detach mstsc from parent (step 1). Call maximize() after a delay.""" if not self._mstsc_hwnd or not self._connected: return self._is_detached = True @@ -619,29 +651,67 @@ class EmbeddedRDP: import ctypes.wintypes user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 hwnd = self._mstsc_hwnd + # Thread input attachment for cross-process style changes + target_tid = user32.GetWindowThreadProcessId(hwnd, None) + our_tid = kernel32.GetCurrentThreadId() + attached = False + if target_tid != our_tid: + attached = bool(user32.AttachThreadInput(our_tid, target_tid, True)) + # Reparent to desktop user32.SetParent(hwnd, 0) - # Restore normal window style + # Remove WS_CHILD, set normal top-level window style + GWL_STYLE = -16 WS_OVERLAPPEDWINDOW = 0x00CF0000 WS_VISIBLE = 0x10000000 - user32.SetWindowLongW(hwnd, -16, WS_OVERLAPPEDWINDOW | WS_VISIBLE) - - # Maximize - user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE + WS_CHILD = 0x40000000 + style = user32.GetWindowLongW(hwnd, GWL_STYLE) & 0xFFFFFFFF + new_style = ((style & ~WS_CHILD) | WS_OVERLAPPEDWINDOW | WS_VISIBLE) & 0xFFFFFFFF + user32.SetWindowLongW(hwnd, GWL_STYLE, ctypes.c_long(new_style).value) + # Apply frame change SWP_FRAMECHANGED = 0x0020 SWP_NOZORDER = 0x0004 + SWP_NOSIZE = 0x0001 + SWP_NOMOVE = 0x0002 user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOZORDER | 0x0001 | 0x0002) - user32.SetForegroundWindow(hwnd) + SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOSIZE | SWP_NOMOVE) - log.info("mstsc detached to fullscreen") + if attached: + user32.AttachThreadInput(our_tid, target_tid, False) + + log.info("mstsc detached from parent") except Exception as e: log.error(f"Detach failed: {e}") + def maximize(self): + """Maximize the detached mstsc window (step 2, call after delay).""" + if not self._mstsc_hwnd: + return + try: + import ctypes + user32 = ctypes.windll.user32 + hwnd = self._mstsc_hwnd + + # Bring to foreground + user32.SetForegroundWindow(hwnd) + + # Try ShowWindow first + user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE + + # Fallback: explicit resize to screen + screen_w = user32.GetSystemMetrics(0) + screen_h = user32.GetSystemMetrics(1) + user32.MoveWindow(hwnd, 0, 0, screen_w, screen_h, True) + + log.info(f"mstsc maximize attempted ({screen_w}x{screen_h})") + except Exception as e: + log.error(f"Maximize failed: {e}") + def reattach(self, parent_hwnd: int, width: int, height: int): """Re-embed mstsc back into the tkinter frame.""" if not self._mstsc_hwnd: diff --git a/core/server_store.py b/core/server_store.py index 8202bd0..2d8014a 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -57,6 +57,7 @@ class ServerStore: self._last_backup_time: float = 0 self._last_backup_hash: str = "" self._terminal_font_size: int = 11 + self._window_geometry: str = "" self._servers_file: str = DEFAULT_SERVERS_FILE self._load_settings() self._load() @@ -77,6 +78,7 @@ class ServerStore: i18n.set_language(lang) self._check_interval = settings.get("check_interval", 60) self._terminal_font_size = settings.get("terminal_font_size", 11) + self._window_geometry = settings.get("window_geometry", "") except json.JSONDecodeError: log.warning("Corrupted settings.json, using defaults") except Exception as e: @@ -90,6 +92,7 @@ class ServerStore: "language": i18n.get_language(), "check_interval": self._check_interval, "terminal_font_size": self._terminal_font_size, + "window_geometry": self._window_geometry, } try: tmp = SETTINGS_FILE + ".tmp" diff --git a/gui/about_dialog.py b/gui/about_dialog.py index 256effd..685d0ed 100644 --- a/gui/about_dialog.py +++ b/gui/about_dialog.py @@ -16,6 +16,8 @@ class AboutDialog(ctk.CTkToplevel): self.resizable(False, False) self.transient(master) self.grab_set() + self.focus_force() + self.protocol("WM_DELETE_WINDOW", self._on_close) # ── Header ── ctk.CTkLabel( @@ -73,5 +75,12 @@ class AboutDialog(ctk.CTkToplevel): # ── Close button ── ctk.CTkButton( - self, text=t("close"), width=120, command=self.destroy + self, text=t("close"), width=120, command=self._on_close ).pack(pady=(10, 20)) + + def _on_close(self): + try: + self.grab_release() + except Exception: + pass + self.destroy() diff --git a/gui/app.py b/gui/app.py index d2aedbb..15f6595 100644 --- a/gui/app.py +++ b/gui/app.py @@ -10,6 +10,7 @@ from core.server_store import ServerStore from core.status_checker import StatusChecker from core import i18n from core.i18n import t, LANGUAGES +from core.icons import icon, TAB_ICONS from core.session_pool import SessionPool from gui.sidebar import Sidebar from gui.server_dialog import ServerDialog @@ -59,13 +60,20 @@ TAB_CLASSES = { } +def _tab_label(key: str) -> str: + """Return tab label with icon prefix: '📁 Files'.""" + icon_name = TAB_ICONS.get(key) + sym = icon(icon_name) if icon_name else "" + text = t(key) + return f"{sym} {text}" if sym else text + + class App(ctk.CTk): def __init__(self): super().__init__() # Window config self.title("ServerManager") - self.geometry("1100x700") self.minsize(900, 500) ctk.set_appearance_mode("dark") @@ -76,6 +84,13 @@ class App(ctk.CTk): self.checker = StatusChecker(self.store) self.session_pool = SessionPool(max_sessions=5) # Create session pool + # Restore saved window geometry or use default + saved_geo = self.store._window_geometry + if saved_geo: + self.geometry(saved_geo) + else: + self.geometry("1100x700") + # Layout self._build_layout() @@ -116,6 +131,8 @@ class App(ctk.CTk): header_bar.pack_propagate(False) # Language selector + self._lang_icon = ctk.CTkLabel(header_bar, text="\U0001f310", font=ctk.CTkFont(size=14), width=20) + self._lang_icon.pack(side="right", padx=(5, 0)) lang_values = list(LANGUAGES.values()) current_display = LANGUAGES.get(i18n.get_language(), "English") self._lang_var = ctk.StringVar(value=current_display) @@ -171,14 +188,14 @@ class App(ctk.CTk): self.tabview.pack(fill="both", expand=True, padx=10, pady=10) for key in self._tab_keys: - self.tabview.add(t(key)) + self.tabview.add(_tab_label(key)) # Create tab instances using TAB_CLASSES factory for key in self._tab_keys: cls = TAB_CLASSES.get(key) if cls is None: continue - parent = self.tabview.tab(t(key)) + parent = self.tabview.tab(_tab_label(key)) widget = self._create_tab_instance(cls, key, parent) widget.pack(fill="both", expand=True) self._tab_instances[key] = widget @@ -186,7 +203,7 @@ class App(ctk.CTk): # Restore previously active tab if still available if restore_tab_key and restore_tab_key in self._tab_keys: try: - self.tabview.set(t(restore_tab_key)) + self.tabview.set(_tab_label(restore_tab_key)) except Exception: pass @@ -257,7 +274,7 @@ class App(ctk.CTk): self.sidebar._select(alias) if tab_key in self._tab_keys: try: - self.tabview.set(t(tab_key)) + self.tabview.set(_tab_label(tab_key)) except Exception: pass @@ -303,9 +320,9 @@ class App(ctk.CTk): """Get the i18n key of the currently active tab.""" try: current_name = self.tabview.get() - # Match against current language translations + # Match against current language translations with icons for key in self._tab_keys: - if t(key) == current_name: + if _tab_label(key) == current_name: return key except Exception: pass @@ -473,7 +490,7 @@ class App(ctk.CTk): try: current = self.tabview.get() terminal = self._tab_instances.get("terminal") - if terminal and current == t("terminal"): + if terminal and current == _tab_label("terminal"): terminal._terminal.focus_terminal() else: self.focus_set() @@ -481,6 +498,12 @@ class App(ctk.CTk): pass def _on_close(self): + # Save window geometry (size + position) + try: + self.store._window_geometry = self.geometry() + self.store._save_settings() + except Exception: + pass # Clean up tab instances for key, widget in self._tab_instances.items(): if hasattr(widget, "on_close"): diff --git a/gui/server_dialog.py b/gui/server_dialog.py index 4494932..314dbd6 100644 --- a/gui/server_dialog.py +++ b/gui/server_dialog.py @@ -6,6 +6,7 @@ Form adapts visible fields based on selected server type. import customtkinter as ctk from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.i18n import t +from core.icons import icon_text, type_display, type_from_display # Which conditional fields to show for each server type. @@ -21,7 +22,7 @@ FIELD_MAP = { "redis": ["password", "db_index"], "grafana": ["api_token", "use_ssl"], "prometheus": ["use_ssl"], - "rdp": ["user", "password"], + "rdp": ["user", "password", "rdp_resolution", "rdp_quality", "rdp_clipboard", "rdp_drives", "rdp_printers"], "vnc": ["password"], } @@ -51,10 +52,14 @@ class ServerDialog(ctk.CTkToplevel): self.title(t("edit_server") if server else t("add_server")) self.geometry("450x720") self.resizable(False, False) - self.grab_set() - # Center on parent + # transient BEFORE grab_set — prevents focus lock on minimize self.transient(master) + self.grab_set() + self.focus_force() + + # Release grab on close (prevents stuck app) + self.protocol("WM_DELETE_WINDOW", self._on_close) self._field_frames: dict[str, ctk.CTkFrame] = {} self._build_ui(server) @@ -80,9 +85,10 @@ 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=t("type"), anchor="w").pack(fill="x") - self.type_var = ctk.StringVar(value="ssh") + self._type_display_values = [type_display(t) for t in SERVER_TYPES] + self.type_var = ctk.StringVar(value=type_display("ssh")) self.type_menu = ctk.CTkOptionMenu( - type_frame, values=SERVER_TYPES, variable=self.type_var, + type_frame, values=self._type_display_values, variable=self.type_var, command=self._on_type_change ) self.type_menu.pack(fill="x") @@ -126,7 +132,7 @@ class ServerDialog(ctk.CTkToplevel): pass_inner.pack(fill="x", padx=20, pady=(2, 5)) self.password_entry = ctk.CTkEntry(pass_inner, 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_inner, text=t("show"), width=60, command=self._toggle_password) + self.show_pass = ctk.CTkButton(pass_inner, text=icon_text("eye", t("show")), width=70, command=self._toggle_password) self.show_pass.pack(side="right") self._pass_visible = False self._field_frames["password"] = f @@ -160,6 +166,55 @@ class ServerDialog(ctk.CTkToplevel): self.api_token_entry.pack(fill="x", **entry_pad) self._field_frames["api_token"] = f + # --- rdp_resolution --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("rdp_resolution"), anchor="w").pack(fill="x", **pad) + self._rdp_resolution_var = ctk.StringVar(value=t("rdp_resolution_auto")) + resolution_values = [ + t("rdp_resolution_auto"), + "800\u00d7600", "1024\u00d7768", "1280\u00d71024", + "1366\u00d7768", "1600\u00d7900", "1920\u00d71080", + ] + self._rdp_resolution_menu = ctk.CTkOptionMenu(f, values=resolution_values, variable=self._rdp_resolution_var) + self._rdp_resolution_menu.pack(fill="x", **entry_pad) + self._field_frames["rdp_resolution"] = f + + # --- rdp_quality --- + f = ctk.CTkFrame(self, fg_color="transparent") + ctk.CTkLabel(f, text=t("rdp_quality"), anchor="w").pack(fill="x", **pad) + self._rdp_quality_var = ctk.StringVar(value="auto") + quality_values = [ + t("rdp_quality_auto"), t("rdp_quality_lan"), + t("rdp_quality_broadband"), t("rdp_quality_modem"), + ] + self._rdp_quality_map = { + t("rdp_quality_auto"): "auto", t("rdp_quality_lan"): "lan", + t("rdp_quality_broadband"): "broadband", t("rdp_quality_modem"): "modem", + } + self._rdp_quality_rmap = {v: k for k, v in self._rdp_quality_map.items()} + self._rdp_quality_menu = ctk.CTkOptionMenu(f, values=quality_values, variable=self._rdp_quality_var) + self._rdp_quality_menu.pack(fill="x", **entry_pad) + self._rdp_quality_var.set(t("rdp_quality_auto")) + self._field_frames["rdp_quality"] = f + + # --- rdp_clipboard --- + f = ctk.CTkFrame(self, fg_color="transparent") + self._rdp_clipboard_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox(f, text=t("rdp_clipboard"), variable=self._rdp_clipboard_var).pack(fill="x", padx=20, pady=(8, 2)) + self._field_frames["rdp_clipboard"] = f + + # --- rdp_drives --- + f = ctk.CTkFrame(self, fg_color="transparent") + self._rdp_drives_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox(f, text=t("rdp_drives"), variable=self._rdp_drives_var).pack(fill="x", padx=20, pady=(4, 2)) + self._field_frames["rdp_drives"] = f + + # --- rdp_printers --- + f = ctk.CTkFrame(self, fg_color="transparent") + self._rdp_printers_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox(f, text=t("rdp_printers"), variable=self._rdp_printers_var).pack(fill="x", padx=20, pady=(4, 2)) + self._field_frames["rdp_printers"] = f + # --- use_ssl --- f = ctk.CTkFrame(self, fg_color="transparent") self.use_ssl_var = ctk.BooleanVar(value=False) @@ -182,14 +237,14 @@ class ServerDialog(ctk.CTkToplevel): # ── Always visible: Buttons ── btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame.pack(fill="x", padx=20, pady=(15, 20)) - 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)) + ctk.CTkButton(btn_frame, text=icon_text("delete", t("cancel")), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5)) + ctk.CTkButton(btn_frame, text=icon_text("confirm", t("save")), command=self._save).pack(side="right", expand=True, padx=(5, 0)) # Fill values if editing if server: self.alias_entry.insert(0, server.get("alias", "")) self.ip_entry.insert(0, server.get("ip", "")) - self.type_var.set(server.get("type", "ssh")) + self.type_var.set(type_display(server.get("type", "ssh"))) 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", "")) @@ -201,6 +256,19 @@ class ServerDialog(ctk.CTkToplevel): self.api_token_entry.insert(0, server.get("api_token", "")) self.use_ssl_var.set(server.get("use_ssl", False)) + # RDP settings + res_raw = server.get("rdp_resolution", "auto") + if res_raw == "auto": + self._rdp_resolution_var.set(t("rdp_resolution_auto")) + else: + self._rdp_resolution_var.set(res_raw.replace("x", "\u00d7")) + q_raw = server.get("rdp_quality", "auto") + q_display = self._rdp_quality_rmap.get(q_raw, t("rdp_quality_auto")) + self._rdp_quality_var.set(q_display) + self._rdp_clipboard_var.set(server.get("rdp_clipboard", True)) + self._rdp_drives_var.set(server.get("rdp_drives", False)) + self._rdp_printers_var.set(server.get("rdp_printers", False)) + # Restore network interface selection saved_ip = server.get("bind_interface") if saved_ip: @@ -219,7 +287,7 @@ class ServerDialog(ctk.CTkToplevel): self._iface_var.set(unavail_label) # Apply field visibility for initial type - self._apply_field_visibility(self.type_var.get()) + self._apply_field_visibility(type_from_display(self.type_var.get())) def _apply_field_visibility(self, server_type: str): """Hide all conditional fields, then show only those for the given type.""" @@ -231,15 +299,16 @@ class ServerDialog(ctk.CTkToplevel): frame.pack_forget() def _on_type_change(self, value): - default_port = DEFAULT_PORTS.get(value, 22) + raw_type = type_from_display(value) + default_port = DEFAULT_PORTS.get(raw_type, 22) self.port_entry.delete(0, "end") self.port_entry.insert(0, str(default_port)) - self._apply_field_visibility(value) + self._apply_field_visibility(raw_type) 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=t("hide") if self._pass_visible else t("show")) + self.show_pass.configure(text=icon_text("eye", t("hide") if self._pass_visible else t("show"))) def _save(self): alias = self.alias_entry.get().strip() @@ -247,7 +316,7 @@ class ServerDialog(ctk.CTkToplevel): port_str = self.port_entry.get().strip() user = self.user_entry.get().strip() password = self.password_entry.get() - server_type = self.type_var.get() + server_type = type_from_display(self.type_var.get()) totp_secret = self.totp_entry.get().strip() notes = self.notes_entry.get().strip() @@ -313,6 +382,23 @@ class ServerDialog(ctk.CTkToplevel): if self.use_ssl_var.get(): server_data["use_ssl"] = True + # RDP settings + if "rdp_resolution" in visible: + res_display = self._rdp_resolution_var.get() + if res_display == t("rdp_resolution_auto"): + server_data["rdp_resolution"] = "auto" + else: + server_data["rdp_resolution"] = res_display.replace("\u00d7", "x") + if "rdp_quality" in visible: + q_display = self._rdp_quality_var.get() + server_data["rdp_quality"] = self._rdp_quality_map.get(q_display, "auto") + if "rdp_clipboard" in visible: + server_data["rdp_clipboard"] = self._rdp_clipboard_var.get() + if "rdp_drives" in visible: + server_data["rdp_drives"] = self._rdp_drives_var.get() + if "rdp_printers" in visible: + server_data["rdp_printers"] = self._rdp_printers_var.get() + try: if self.editing: if alias != self._original_alias and self.store.get_server(alias): @@ -326,6 +412,14 @@ class ServerDialog(ctk.CTkToplevel): except ValueError as e: self._show_error(str(e)) + def _on_close(self): + """Release grab and destroy — prevents stuck app on minimize.""" + try: + self.grab_release() + except Exception: + pass + self.destroy() + def _show_error(self, message: str): # Simple error via title flash self.title(t("error_prefix").format(msg=message)) diff --git a/gui/sidebar.py b/gui/sidebar.py index 596ff65..0d4a371 100644 --- a/gui/sidebar.py +++ b/gui/sidebar.py @@ -5,36 +5,11 @@ Sidebar — server list with search, add/edit/delete buttons, context menu. import tkinter as tk import customtkinter as ctk from core.i18n import t +from core.icons import ( + icon_text, TYPE_COLORS, TYPE_LABELS, CTX_ICONS, icon, +) from gui.widgets.status_badge import StatusBadge -TYPE_COLORS = { - "ssh": "#22c55e", - "telnet": "#a855f7", - "rdp": "#3b82f6", - "vnc": "#6366f1", - "winrm": "#0ea5e9", - "mariadb": "#f59e0b", - "mssql": "#ef4444", - "postgresql": "#3b82f6", - "redis": "#dc2626", - "grafana": "#f97316", - "prometheus": "#e11d48", -} - -TYPE_LABELS = { - "ssh": "SSH", - "telnet": "TEL", - "rdp": "RDP", - "vnc": "VNC", - "winrm": "PS", - "mariadb": "MDB", - "mssql": "SQL", - "postgresql": "PG", - "redis": "RDS", - "grafana": "GRF", - "prometheus": "PRM", -} - # Context menu: type → list of (i18n_key, tab_key_or_None) _CONTEXT_ACTIONS = { @@ -89,11 +64,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=t("add"), width=70, height=30, command=self._on_add) + self.add_btn = ctk.CTkButton(btn_frame, text=icon_text("add", 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=t("edit"), width=70, height=30, fg_color="#6b7280", command=self._on_edit) + self.edit_btn = ctk.CTkButton(btn_frame, text=icon_text("edit", 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=t("delete"), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete) + self.del_btn = ctk.CTkButton(btn_frame, text=icon_text("delete", 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 — set by app.py @@ -111,9 +86,9 @@ class Sidebar(ctk.CTkFrame): 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")) + self.add_btn.configure(text=icon_text("add", t("add"))) + self.edit_btn.configure(text=icon_text("edit", t("edit"))) + self.del_btn.configure(text=icon_text("delete", t("delete"))) self._update_sessions_label() def _refresh_list(self): @@ -260,16 +235,18 @@ class Sidebar(ctk.CTkFrame): # Type-specific actions actions = _CONTEXT_ACTIONS.get(stype, []) for label_key, tab_key in actions: + ctx_icon = icon(CTX_ICONS.get(label_key, "")) + label_text = f"{ctx_icon} {t(label_key)}" if ctx_icon else t(label_key) if tab_key: menu.add_command( - label=t(label_key), + label=label_text, command=lambda a=alias, tk=tab_key: ( self.open_tab_callback(a, tk) if self.open_tab_callback else None ), ) else: menu.add_command( - label=t(label_key), + label=label_text, command=lambda a=alias: ( self.open_browser_callback(a) if self.open_browser_callback else None ), @@ -280,13 +257,13 @@ class Sidebar(ctk.CTkFrame): # Universal actions menu.add_command( - label=t("ctx_check_status"), + label=icon_text("status_check", t("ctx_check_status")), command=lambda: ( self.check_status_callback(alias) if self.check_status_callback else None ), ) menu.add_command( - label=t("ctx_copy_alias"), + label=icon_text("copy", t("ctx_copy_alias")), command=lambda: self._copy_alias(alias), ) @@ -294,11 +271,11 @@ class Sidebar(ctk.CTkFrame): # Management menu.add_command( - label=t("edit"), + label=icon_text("edit", t("edit")), command=lambda: self.edit_callback(alias) if self.edit_callback else None, ) menu.add_command( - label=t("delete"), + label=icon_text("delete", t("delete")), command=lambda: self.delete_callback(alias) if self.delete_callback else None, foreground="#ef4444", ) diff --git a/gui/tabs/files_tab.py b/gui/tabs/files_tab.py index 20cd000..e2c05bb 100644 --- a/gui/tabs/files_tab.py +++ b/gui/tabs/files_tab.py @@ -13,6 +13,7 @@ from tkinter import messagebox, filedialog import customtkinter as ctk from core.i18n import t +from core.icons import icon_text from core.ssh_client import SFTPSession from gui.widgets.file_list import FileListWidget @@ -110,7 +111,7 @@ class FilesTab(ctk.CTkFrame): # Browse button self._browse_btn = ctk.CTkButton( - left_header, text=t("browse"), width=60, height=28, + left_header, text=icon_text("folder_open", t("browse")), width=75, height=28, command=self._browse_local, ) self._browse_btn.pack(side="left", padx=2) @@ -204,13 +205,13 @@ class FilesTab(ctk.CTkFrame): toolbar.pack(fill="x", padx=10, pady=4) self._upload_btn = ctk.CTkButton( - toolbar, text=f"{t('upload')} \u2192", width=110, height=30, + toolbar, text=icon_text("upload", t("upload")), width=110, height=30, command=self._upload_selected, ) self._upload_btn.pack(side="left", padx=(0, 4)) self._download_btn = ctk.CTkButton( - toolbar, text=f"\u2190 {t('download')}", width=110, height=30, + toolbar, text=icon_text("download", t("download")), width=110, height=30, command=self._download_selected, ) self._download_btn.pack(side="left", padx=4) @@ -219,20 +220,20 @@ class FilesTab(ctk.CTkFrame): sep.pack(side="left", padx=8) self._mkdir_btn = ctk.CTkButton( - toolbar, text=t("new_folder"), width=100, height=30, + toolbar, text=icon_text("folder", t("new_folder")), width=110, height=30, command=self._mkdir_remote, ) self._mkdir_btn.pack(side="left", padx=4) self._delete_btn = ctk.CTkButton( - toolbar, text=t("delete_files"), width=80, height=30, + toolbar, text=icon_text("delete", t("delete_files")), width=90, height=30, fg_color="#dc2626", hover_color="#b91c1c", command=self._delete_remote, ) self._delete_btn.pack(side="left", padx=4) self._rename_btn = ctk.CTkButton( - toolbar, text=t("rename_file"), width=100, height=30, + toolbar, text=icon_text("edit", t("rename_file")), width=110, height=30, command=self._rename_remote, ) self._rename_btn.pack(side="left", padx=4) diff --git a/gui/tabs/grafana_tab.py b/gui/tabs/grafana_tab.py index 20ff8de..7e36170 100644 --- a/gui/tabs/grafana_tab.py +++ b/gui/tabs/grafana_tab.py @@ -9,6 +9,7 @@ from tkinter import ttk import customtkinter as ctk from core.grafana_client import GrafanaClient from core.i18n import t +from core.icons import icon_text class GrafanaTab(ctk.CTkFrame): @@ -30,7 +31,7 @@ class GrafanaTab(ctk.CTkFrame): font=ctk.CTkFont(size=18, weight="bold")) title.pack(side="left") - self._refresh_btn = ctk.CTkButton(header_frame, text=t("grafana_refresh"), width=100, + self._refresh_btn = ctk.CTkButton(header_frame, text=icon_text("refresh", t("grafana_refresh")), width=110, command=self._refresh) self._refresh_btn.pack(side="right") @@ -129,7 +130,7 @@ class GrafanaTab(ctk.CTkFrame): self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444")) finally: self.after(0, lambda: self._refresh_btn.configure( - state="normal", text=t("grafana_refresh"))) + state="normal", text=icon_text("refresh", t("grafana_refresh")))) threading.Thread(target=_do, daemon=True).start() diff --git a/gui/tabs/info_tab.py b/gui/tabs/info_tab.py index 7630f62..5c8e948 100644 --- a/gui/tabs/info_tab.py +++ b/gui/tabs/info_tab.py @@ -4,6 +4,7 @@ Info tab — display server details, edit button. import customtkinter as ctk from core.i18n import t +from core.icons import icon_text class InfoTab(ctk.CTkFrame): @@ -55,7 +56,7 @@ class InfoTab(ctk.CTkFrame): self._fields[key] = val # Edit button - self.edit_btn = ctk.CTkButton(self, text=t("edit_server_btn"), command=self._on_edit) + self.edit_btn = ctk.CTkButton(self, text=icon_text("edit", t("edit_server_btn")), command=self._on_edit) self.edit_btn.pack(pady=15) def set_server(self, alias: str | None): diff --git a/gui/tabs/keys_tab.py b/gui/tabs/keys_tab.py index 58a3d30..ef0c1ef 100644 --- a/gui/tabs/keys_tab.py +++ b/gui/tabs/keys_tab.py @@ -7,6 +7,7 @@ import threading import customtkinter as ctk from core.ssh_client import SSHClientWrapper from core.i18n import t +from core.icons import icon_text class KeysTab(ctk.CTkFrame): @@ -29,13 +30,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=t("generate_key"), command=self._generate) + self.gen_btn = ctk.CTkButton(btn_frame, text=icon_text("key", t("generate_key")), command=self._generate) self.gen_btn.pack(side="left", padx=(0, 10)) - self.install_btn = ctk.CTkButton(btn_frame, text=t("install_on_server"), fg_color="#22c55e", hover_color="#16a34a", command=self._install) + self.install_btn = ctk.CTkButton(btn_frame, text=icon_text("upload", 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=t("copy_public_key"), fg_color="#6b7280", command=self._copy_key) + self.copy_btn = ctk.CTkButton(btn_frame, text=icon_text("copy", t("copy_public_key")), fg_color="#6b7280", command=self._copy_key) self.copy_btn.pack(side="right") # Status log diff --git a/gui/tabs/launch_tab.py b/gui/tabs/launch_tab.py index 4944c09..90e91d5 100644 --- a/gui/tabs/launch_tab.py +++ b/gui/tabs/launch_tab.py @@ -77,6 +77,22 @@ class LaunchTab(ctk.CTkFrame): ) card_title.pack(fill="x", padx=15, pady=(12, 8)) + # Resolution + r_row = ctk.CTkFrame(self._settings_card, fg_color="transparent") + r_row.pack(fill="x", padx=15, pady=3) + ctk.CTkLabel(r_row, text=t("rdp_resolution"), width=140, anchor="w").pack(side="left") + self._resolution_var = ctk.StringVar(value=t("rdp_resolution_auto")) + resolution_values = [ + t("rdp_resolution_auto"), + "800\u00d7600", "1024\u00d7768", "1280\u00d71024", + "1366\u00d7768", "1600\u00d7900", "1920\u00d71080", + ] + self._resolution_menu = ctk.CTkOptionMenu( + r_row, values=resolution_values, + variable=self._resolution_var, width=180, + ) + self._resolution_menu.pack(side="left") + # Quality q_row = ctk.CTkFrame(self._settings_card, fg_color="transparent") q_row.pack(fill="x", padx=15, pady=3) @@ -184,6 +200,11 @@ class LaunchTab(ctk.CTkFrame): self._info_label.configure(text=t("launch_rdp_info").format(alias=alias)) self._settings_card.pack(fill="x", padx=40, pady=(0, 15)) # Load saved RDP settings from server + res_raw = server.get("rdp_resolution", "auto") + if res_raw == "auto": + self._resolution_var.set(t("rdp_resolution_auto")) + else: + self._resolution_var.set(res_raw.replace("x", "\u00d7")) self._quality_var.set(self._quality_labels.get( server.get("rdp_quality", "auto"), self._quality_labels["auto"] )) @@ -235,6 +256,13 @@ class LaunchTab(ctk.CTkFrame): "printers": self._printers_var.get(), } + # Parse resolution + res = self._resolution_var.get() + if res == t("rdp_resolution_auto"): + settings["resolution"] = "auto" + else: + settings["resolution"] = res.replace("\u00d7", "x") + self._embedded_rdp = EmbeddedRDP(server, settings) # Set callbacks @@ -251,6 +279,9 @@ class LaunchTab(ctk.CTkFrame): self._rdp_frame.update_idletasks() parent_hwnd = self._rdp_frame.winfo_id() + + # Always use frame size for the mstsc window; + # session resolution is handled inside generate_rdp_file via settings w = max(self._rdp_frame.winfo_width(), 800) h = max(self._rdp_frame.winfo_height(), 600) @@ -262,9 +293,26 @@ class LaunchTab(ctk.CTkFrame): text=t("rdp_connected").format(alias=self._current_alias), text_color="#22c55e", ) + # Re-normalize position after embed settles + self.after(300, self._normalize_rdp_position) + self.after(1000, self._normalize_rdp_position) # Start monitoring self._start_monitor() + def _normalize_rdp_position(self): + """Force mstsc to fill the frame at (0,0) — fixes post-embed offset.""" + if not self._embedded_rdp or not self._embedded_rdp.connected: + return + if self._is_fullscreen: + return + try: + w = self._rdp_frame.winfo_width() + h = self._rdp_frame.winfo_height() + if w > 10 and h > 10: + self._embedded_rdp.resize(w, h) + except Exception: + pass + def _on_rdp_failed(self, error: str): """Called when embedding failed.""" self._toolbar_status.configure( @@ -335,12 +383,18 @@ class LaunchTab(ctk.CTkFrame): h = self._rdp_frame.winfo_height() self._embedded_rdp.reattach(parent_hwnd, w, h) else: - # Go fullscreen — detach + # Go fullscreen — detach, then maximize after event loop settles self._is_fullscreen = True self._fullscreen_btn.configure( text=icon_text("back", t("rdp_exit_fullscreen")), ) self._embedded_rdp.detach() + self.after(300, self._maximize_detached) + + def _maximize_detached(self): + """Called after delay — maximize the detached mstsc window.""" + if self._embedded_rdp: + self._embedded_rdp.maximize() # ── Resize handling with debounce ───────────────────────────── diff --git a/gui/tabs/powershell_tab.py b/gui/tabs/powershell_tab.py index b9b4d63..e433a70 100644 --- a/gui/tabs/powershell_tab.py +++ b/gui/tabs/powershell_tab.py @@ -8,6 +8,7 @@ import threading import customtkinter as ctk from core.winrm_client import WinRMClient from core.i18n import t +from core.icons import icon_text class PowershellTab(ctk.CTkFrame): @@ -68,7 +69,7 @@ class PowershellTab(ctk.CTkFrame): self._entry.bind("", lambda e: self._history_navigate(1)) self._exec_btn = ctk.CTkButton( - input_row, text=t("ps_execute"), width=90, + input_row, text=icon_text("execute", t("ps_execute")), width=100, command=self._execute, ) self._exec_btn.pack(side="right") diff --git a/gui/tabs/prometheus_tab.py b/gui/tabs/prometheus_tab.py index acf2b2b..4a438fb 100644 --- a/gui/tabs/prometheus_tab.py +++ b/gui/tabs/prometheus_tab.py @@ -8,6 +8,7 @@ from tkinter import ttk import customtkinter as ctk from core.prometheus_client import PrometheusClient from core.i18n import t +from core.icons import icon_text class PrometheusTab(ctk.CTkFrame): @@ -34,7 +35,7 @@ class PrometheusTab(ctk.CTkFrame): self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) self._query_entry.bind("", lambda e: self._execute_query()) - self._exec_btn = ctk.CTkButton(query_frame, text=t("prom_execute"), width=90, + self._exec_btn = ctk.CTkButton(query_frame, text=icon_text("execute", t("prom_execute")), width=100, command=self._execute_query) self._exec_btn.pack(side="left") @@ -56,7 +57,7 @@ class PrometheusTab(ctk.CTkFrame): font=ctk.CTkFont(size=14, weight="bold"), anchor="w") targets_label.pack(side="left") - self._refresh_btn = ctk.CTkButton(targets_header, text=t("prom_refresh"), width=90, + self._refresh_btn = ctk.CTkButton(targets_header, text=icon_text("refresh", t("prom_refresh")), width=100, command=self._refresh_all) self._refresh_btn.pack(side="right") @@ -198,7 +199,7 @@ class PrometheusTab(ctk.CTkFrame): self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444")) finally: self.after(0, lambda: self._refresh_btn.configure( - state="normal", text=t("prom_refresh"))) + state="normal", text=icon_text("refresh", t("prom_refresh")))) threading.Thread(target=_do, daemon=True).start() diff --git a/gui/tabs/query_tab.py b/gui/tabs/query_tab.py index 0a7bef3..567f4f8 100644 --- a/gui/tabs/query_tab.py +++ b/gui/tabs/query_tab.py @@ -11,6 +11,7 @@ from tkinter import ttk, filedialog import customtkinter as ctk from core.i18n import t +from core.icons import icon_text from core.sql_client import SQLClient @@ -73,7 +74,7 @@ class QueryTab(ctk.CTkFrame): self._exec_btn = ctk.CTkButton( btn_row, - text=f"{t('query_execute')} (F5)", + text=icon_text("execute", t("query_execute")), command=self._execute_query, width=130, fg_color="#2563eb", @@ -83,7 +84,7 @@ class QueryTab(ctk.CTkFrame): self._clear_btn = ctk.CTkButton( btn_row, - text=t("query_clear"), + text=icon_text("clear", t("query_clear")), command=self._clear_all, width=80, fg_color="#6b7280", @@ -93,7 +94,7 @@ class QueryTab(ctk.CTkFrame): self._export_btn = ctk.CTkButton( btn_row, - text=t("query_export_csv"), + text=icon_text("save", t("query_export_csv")), command=self._export_csv, width=110, fg_color="#059669", diff --git a/gui/tabs/redis_tab.py b/gui/tabs/redis_tab.py index 8401648..0a65e46 100644 --- a/gui/tabs/redis_tab.py +++ b/gui/tabs/redis_tab.py @@ -6,6 +6,7 @@ import threading import customtkinter as ctk from core.redis_client import RedisClient from core.i18n import t +from core.icons import icon_text class RedisTab(ctk.CTkFrame): @@ -66,26 +67,26 @@ class RedisTab(ctk.CTkFrame): btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame.pack(fill="x", padx=15, pady=5) - self._exec_btn = ctk.CTkButton(btn_frame, text=t("redis_execute"), width=90, + self._exec_btn = ctk.CTkButton(btn_frame, text=icon_text("execute", t("redis_execute")), width=100, command=self._execute_command) self._exec_btn.pack(side="left", padx=(0, 5)) - self._info_btn = ctk.CTkButton(btn_frame, text="INFO", width=70, + self._info_btn = ctk.CTkButton(btn_frame, text=icon_text("info", "INFO"), width=80, fg_color="#6b7280", hover_color="#4b5563", command=lambda: self._run_quick("INFO")) self._info_btn.pack(side="left", padx=(0, 5)) - self._dbsize_btn = ctk.CTkButton(btn_frame, text="DBSIZE", width=80, + self._dbsize_btn = ctk.CTkButton(btn_frame, text=icon_text("hash", "DBSIZE"), width=90, fg_color="#6b7280", hover_color="#4b5563", command=lambda: self._run_quick("DBSIZE")) self._dbsize_btn.pack(side="left", padx=(0, 5)) - self._scan_btn = ctk.CTkButton(btn_frame, text="SCAN", width=70, + self._scan_btn = ctk.CTkButton(btn_frame, text=icon_text("search", "SCAN"), width=80, fg_color="#6b7280", hover_color="#4b5563", command=lambda: self._run_quick("SCAN 0 COUNT 100")) self._scan_btn.pack(side="left", padx=(0, 5)) - self._clear_btn = ctk.CTkButton(btn_frame, text=t("redis_clear"), width=70, + self._clear_btn = ctk.CTkButton(btn_frame, text=icon_text("clear", t("redis_clear")), width=80, fg_color="#374151", hover_color="#1f2937", command=self._clear_output) self._clear_btn.pack(side="right") diff --git a/gui/tabs/setup_tab.py b/gui/tabs/setup_tab.py index 99115d9..fdb1f52 100644 --- a/gui/tabs/setup_tab.py +++ b/gui/tabs/setup_tab.py @@ -10,6 +10,7 @@ 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 +from core.icons import icon_text from core.logger import log @@ -70,7 +71,7 @@ class SetupTab(ctk.CTkFrame): btn_frame.pack(fill="x", padx=20, pady=15) self.install_all_btn = ctk.CTkButton( - btn_frame, text=t("install_everything"), + btn_frame, text=icon_text("confirm", t("install_everything")), font=ctk.CTkFont(size=14, weight="bold"), height=40, fg_color="#22c55e", hover_color="#16a34a", command=self._install_all @@ -81,16 +82,16 @@ class SetupTab(ctk.CTkFrame): ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent") ind_frame.pack(fill="x") - self.ssh_py_btn = ctk.CTkButton(ind_frame, text=t("install_ssh_py"), width=100, fg_color="#6b7280", + self.ssh_py_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_py")), width=110, 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", + self.skill_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_skill")), width=110, 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", + self.ssh_key_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_key")), width=110, 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", + self.refresh_btn = ctk.CTkButton(ind_frame, text=icon_text("refresh", t("refresh")), width=90, fg_color="#3b82f6", command=self._refresh_status) self.refresh_btn.pack(side="right") @@ -146,7 +147,7 @@ class SetupTab(ctk.CTkFrame): ) 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", + path_row, text=icon_text("folder", t("change_path")), width=120, fg_color="#6b7280", command=self._change_config_path ) self.change_path_btn.pack(side="right") @@ -156,7 +157,7 @@ class SetupTab(ctk.CTkFrame): 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", + backup_row, text=icon_text("save", t("backup_now")), width=120, fg_color="#3b82f6", command=self._backup_now ) self.backup_btn.pack(side="left", padx=(0, 10)) @@ -171,7 +172,7 @@ class SetupTab(ctk.CTkFrame): 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", + backup_row, text=icon_text("refresh", t("restore")), width=100, fg_color="#ef4444", hover_color="#dc2626", command=self._restore_backup ) self.restore_btn.pack(side="left") @@ -181,25 +182,25 @@ class SetupTab(ctk.CTkFrame): ie_row.pack(fill="x", padx=15, pady=(0, 10)) self.export_config_btn = ctk.CTkButton( - ie_row, text=t("export_config"), width=120, fg_color="#6b7280", + ie_row, text=icon_text("upload", t("export_config")), width=130, fg_color="#6b7280", command=self._export_config ) self.export_config_btn.pack(side="left", padx=(0, 5)) self.import_config_btn = ctk.CTkButton( - ie_row, text=t("import_config"), width=120, fg_color="#6b7280", + ie_row, text=icon_text("download", t("import_config")), width=130, fg_color="#6b7280", command=self._import_config ) self.import_config_btn.pack(side="left", padx=5) self.export_backup_btn = ctk.CTkButton( - ie_row, text=t("export_backup"), width=120, fg_color="#6b7280", + ie_row, text=icon_text("upload", t("export_backup")), width=130, fg_color="#6b7280", command=self._export_backup ) self.export_backup_btn.pack(side="left", padx=5) self.import_backup_btn = ctk.CTkButton( - ie_row, text=t("import_backup"), width=120, fg_color="#6b7280", + ie_row, text=icon_text("download", t("import_backup")), width=130, fg_color="#6b7280", command=self._import_backup ) self.import_backup_btn.pack(side="left", padx=5) diff --git a/gui/tabs/totp_tab.py b/gui/tabs/totp_tab.py index 703662f..ff357e6 100644 --- a/gui/tabs/totp_tab.py +++ b/gui/tabs/totp_tab.py @@ -6,6 +6,7 @@ Live countdown, one-click copy, per-server secrets. import threading import customtkinter as ctk from core.i18n import t +from core.icons import icon_text class TOTPTab(ctk.CTkFrame): @@ -81,7 +82,7 @@ class TOTPTab(ctk.CTkFrame): # Copy button self.copy_btn = ctk.CTkButton( - self, text=t("totp_copy"), width=200, height=40, + self, text=icon_text("copy", t("totp_copy")), width=200, height=40, font=ctk.CTkFont(size=14), fg_color="#22c55e", hover_color="#16a34a", command=self._copy_code @@ -108,7 +109,7 @@ class TOTPTab(ctk.CTkFrame): 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, + entry_row, text=icon_text("eye", t("show")), width=80, fg_color="#6b7280", hover_color="#4b5563", command=self._toggle_secret ) @@ -116,13 +117,13 @@ class TOTPTab(ctk.CTkFrame): self._secret_visible = False self.save_secret_btn = ctk.CTkButton( - entry_row, text=t("totp_save_secret"), width=100, + entry_row, text=icon_text("confirm", t("totp_save_secret")), width=110, 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, + entry_row, text=icon_text("delete", t("totp_remove_secret")), width=110, fg_color="#ef4444", hover_color="#dc2626", command=self._remove_secret ) @@ -130,7 +131,7 @@ class TOTPTab(ctk.CTkFrame): # Generate random secret button self.gen_secret_btn = ctk.CTkButton( - secret_frame, text=t("totp_generate_secret"), width=180, + secret_frame, text=icon_text("key", t("totp_generate_secret")), width=200, fg_color="#6b7280", hover_color="#4b5563", command=self._generate_secret ) @@ -266,7 +267,9 @@ class TOTPTab(ctk.CTkFrame): 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")) + self.show_secret_btn.configure( + text=icon_text("eye", t("hide") if self._secret_visible else t("show")) + ) def _save_secret(self): if not self._current_alias: @@ -328,12 +331,12 @@ class TOTPTab(ctk.CTkFrame): 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.copy_btn.configure(text=icon_text("copy", t("totp_copy"))) + self.save_secret_btn.configure(text=icon_text("confirm", t("totp_save_secret"))) + self.remove_secret_btn.configure(text=icon_text("delete", t("totp_remove_secret"))) + self.gen_secret_btn.configure(text=icon_text("key", t("totp_generate_secret"))) self.show_secret_btn.configure( - text=t("hide") if self._secret_visible else t("show") + text=icon_text("eye", t("hide") if self._secret_visible else t("show")) ) if not self._current_alias: self.server_label.configure(text=t("no_server_selected")) diff --git a/gui/widgets/status_badge.py b/gui/widgets/status_badge.py index c7c63b3..0aa415c 100644 --- a/gui/widgets/status_badge.py +++ b/gui/widgets/status_badge.py @@ -9,6 +9,7 @@ COLORS = { "offline": "#ef4444", # red "unknown": "#6b7280", # gray "disabled": "#9ca3af", # light gray + "checking": "#f59e0b", # yellow } @@ -24,5 +25,10 @@ class StatusBadge(ctk.CTkLabel): def _update_color(self): color = COLORS.get(self._status, COLORS["unknown"]) - symbol = "\u2014" if self._status == "disabled" else "\u25cf" + if self._status == "disabled": + symbol = "\u2014" # — + elif self._status == "checking": + symbol = "\u25d0" # ◐ + else: + symbol = "\u25cf" # ● self.configure(text=symbol, text_color=color, font=("", 14)) diff --git a/releases/ServerManager-v1.8.33-win-x64.exe b/releases/ServerManager-v1.8.48-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.33-win-x64.exe rename to releases/ServerManager-v1.8.48-win-x64.exe index 3296b0e..5675d57 100644 Binary files a/releases/ServerManager-v1.8.33-win-x64.exe and b/releases/ServerManager-v1.8.48-win-x64.exe differ diff --git a/releases/ServerManager-v1.8.35-win-x64.exe b/releases/ServerManager-v1.8.49-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.35-win-x64.exe rename to releases/ServerManager-v1.8.49-win-x64.exe index 2b50044..1ba7dc4 100644 Binary files a/releases/ServerManager-v1.8.35-win-x64.exe and b/releases/ServerManager-v1.8.49-win-x64.exe differ diff --git a/releases/ServerManager-v1.8.34-win-x64.exe b/releases/ServerManager-v1.8.50-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.34-win-x64.exe rename to releases/ServerManager-v1.8.50-win-x64.exe index afd2619..2367847 100644 Binary files a/releases/ServerManager-v1.8.34-win-x64.exe and b/releases/ServerManager-v1.8.50-win-x64.exe differ diff --git a/releases/ServerManager-v1.8.32-win-x64.exe b/releases/ServerManager-v1.8.51-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.32-win-x64.exe rename to releases/ServerManager-v1.8.51-win-x64.exe index c659a1f..f24a9f4 100644 Binary files a/releases/ServerManager-v1.8.32-win-x64.exe and b/releases/ServerManager-v1.8.51-win-x64.exe differ diff --git a/releases/ServerManager-v1.8.36-win-x64.exe b/releases/ServerManager-v1.8.52-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.36-win-x64.exe rename to releases/ServerManager-v1.8.52-win-x64.exe index 47456bb..f166ace 100644 Binary files a/releases/ServerManager-v1.8.36-win-x64.exe and b/releases/ServerManager-v1.8.52-win-x64.exe differ diff --git a/screenshot_2fa.png b/screenshot_2fa.png deleted file mode 100644 index be865b7..0000000 Binary files a/screenshot_2fa.png and /dev/null differ diff --git a/tools/setup_openssh.bat b/tools/setup_openssh.bat new file mode 100644 index 0000000..f4f16a3 --- /dev/null +++ b/tools/setup_openssh.bat @@ -0,0 +1,30 @@ +@echo off +:: Setup OpenSSH Server on port 61374 +:: Run as Administrator! + +echo === Installing OpenSSH Server === +powershell -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0" + +echo === Starting sshd to generate config === +powershell -Command "Start-Service sshd; Stop-Service sshd" + +set CFG=C:\ProgramData\ssh\sshd_config + +echo === Setting port 61374 === +powershell -Command "(Get-Content '%CFG%') -replace '^#?Port\s+\d+', 'Port 61374' | Set-Content '%CFG%'" + +echo === Enabling password auth === +powershell -Command "(Get-Content '%CFG%') -replace '^#?PasswordAuthentication\s+\w+', 'PasswordAuthentication yes' | Set-Content '%CFG%'" + +echo === Opening firewall port 61374 === +powershell -Command "New-NetFirewallRule -Name 'OpenSSH-Server-61374' -DisplayName 'OpenSSH Server (port 61374)' -Direction Inbound -Protocol TCP -LocalPort 61374 -Action Allow" + +echo === Starting sshd + autostart === +powershell -Command "Start-Service sshd; Set-Service -Name sshd -StartupType Automatic" + +echo === Verifying === +powershell -Command "netstat -an | Select-String '61374'" + +echo. +echo Done! SSH listening on port 61374 +pause diff --git a/tools/skill-ssh.md b/tools/skill-ssh.md index f431a6f..f15b647 100644 --- a/tools/skill-ssh.md +++ b/tools/skill-ssh.md @@ -170,6 +170,7 @@ unset SSH_ASKPASS && unset DISPLAY && ssh ALIAS "command" - **Auto-sudo** (SSH): если user на сервере не root — команды автоматически оборачиваются в `sudo -S`, пароль подаётся через stdin. Тебе НЕ нужно добавлять `sudo` в команду - **--no-sudo** (SSH): если команда не требует root (например `ls`, `cat`), используй `--no-sudo` для скорости +- **Windows SSH — автосанитизация**: при подключении к Windows серверам (определяется по alias/notes/os) Linux-команды автоматически транслируются в Windows-эквиваленты. Можно спокойно писать `ls`, `cat`, `grep`, `ps`, `df` — скрипт сам переведёт в `dir`, `type`, `Select-String`, `Get-Process`, и т.д. Pipe-цепочки (`cat file | grep error`) автоматически оборачиваются в PowerShell. Кодировка принудительно UTF-8 (`chcp 65001`). Команды `powershell ...`, `pwsh ...`, `cmd /c ...` проходят без изменений (passthrough). `sudo` автоматически удаляется, `chmod`/`chown` пропускаются с предупреждением - **Timeout**: 120 секунд на SSH-команду, 10 секунд на SQL/Redis/HTTP-запросы, 15 секунд на подключение - **SSH-ключ**: пробуется первым, fallback на пароль если ключ не подходит - **Прогресс**: upload/download файлов >=1MB показывают 25/50/75% milestone, итог с размером/временем/скоростью diff --git a/version.py b/version.py index a7797a9..fcba132 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.8.36" +__version__ = "1.8.52" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"