v1.8.52: icons module, Windows SSH sanitization, embedded RDP improvements, UI polish

- Add core/icons.py — centralized icon text helper with emoji/symbol support
- Add Windows SSH command sanitization in ssh.py (Linux→Windows auto-translation)
- Improve embedded RDP: launch tab connect/disconnect, fullscreen toggle
- Refactor sidebar: cleaner server type badges
- Update server_dialog: adaptive fields per server type
- Add setup_openssh.bat tool
- Update skill-ssh.md and CLAUDE.md docs for Windows SSH support
- Cleanup old releases, add v1.8.48-v1.8.52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 14:37:37 -05:00
parent 142b68515c
commit 4959004a3f
30 changed files with 596 additions and 134 deletions

View File

@@ -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": "共享打印机",

176
core/icons.py Normal file
View File

@@ -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",
}

View File

@@ -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:

View File

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