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:
@@ -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
176
core/icons.py
Normal 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",
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user