v1.8.35: fix embedded RDP auto-recovery on reconnect + release cleanup
- try_reembed() now handles same-HWND reparent scenario (mstsc reconnect resets parent) - is_embedded() checks GetParent(hwnd) == parent_hwnd every 500ms - _monitor_tick() two-stage: is_alive() for process death, is_embedded() for window loss - build.py auto-cleans old releases (keep first + last 5) - Cleaned old releases from git Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
78
core/i18n.py
78
core/i18n.py
@@ -29,7 +29,7 @@ _EN = {
|
||||
# Sidebar
|
||||
"servers": "Servers",
|
||||
"search": "Search...",
|
||||
"add": "+ Add",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -330,6 +330,7 @@ _EN = {
|
||||
"query_exported": "Exported to {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "Clear",
|
||||
"redis_execute": "Execute",
|
||||
"redis_db": "DB:",
|
||||
"redis_keys_count": "Keys: {count}",
|
||||
@@ -341,6 +342,7 @@ _EN = {
|
||||
"redis_error": "Error: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "Refresh",
|
||||
"grafana_dashboards": "Dashboards",
|
||||
"grafana_alerts": "Alerts",
|
||||
"grafana_uid": "UID",
|
||||
@@ -354,6 +356,7 @@ _EN = {
|
||||
"grafana_no_alerts": "No alerts",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "Refresh",
|
||||
"prom_query": "PromQL Query",
|
||||
"prom_execute": "Execute",
|
||||
"prom_targets": "Targets",
|
||||
@@ -392,6 +395,27 @@ _EN = {
|
||||
"launch_error": "Launch failed: {error}",
|
||||
"launch_no_server": "Select a server to connect",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "RDP Settings",
|
||||
"rdp_quality": "Quality",
|
||||
"rdp_quality_auto": "Auto Detect",
|
||||
"rdp_quality_lan": "LAN (Best)",
|
||||
"rdp_quality_broadband": "Broadband",
|
||||
"rdp_quality_modem": "Low Bandwidth",
|
||||
"rdp_clipboard": "Share Clipboard",
|
||||
"rdp_drives": "Share Drives (Files)",
|
||||
"rdp_printers": "Share Printers",
|
||||
"rdp_connecting": "Connecting to {alias}...",
|
||||
"rdp_embedding": "Embedding RDP window...",
|
||||
"rdp_connected": "Connected to {alias}",
|
||||
"rdp_reconnected": "Reconnected to {alias}",
|
||||
"rdp_disconnected": "Disconnected",
|
||||
"rdp_disconnect": "Disconnect",
|
||||
"rdp_fullscreen": "Fullscreen",
|
||||
"rdp_exit_fullscreen": "Exit Fullscreen",
|
||||
"rdp_error_embed": "Failed to embed: {error}",
|
||||
"rdp_error_timeout": "Timed out waiting for RDP window",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "Database:",
|
||||
"info_ssl": "SSL:",
|
||||
@@ -415,7 +439,7 @@ _RU = {
|
||||
# Sidebar
|
||||
"servers": "Серверы",
|
||||
"search": "Поиск...",
|
||||
"add": "+ Добавить",
|
||||
"add": "Добавить",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
|
||||
@@ -716,6 +740,7 @@ _RU = {
|
||||
"query_exported": "Экспортировано в {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "Очистить",
|
||||
"redis_execute": "Выполнить",
|
||||
"redis_db": "БД:",
|
||||
"redis_keys_count": "Ключей: {count}",
|
||||
@@ -727,6 +752,7 @@ _RU = {
|
||||
"redis_error": "Ошибка: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "Обновить",
|
||||
"grafana_dashboards": "Дашборды",
|
||||
"grafana_alerts": "Оповещения",
|
||||
"grafana_uid": "UID",
|
||||
@@ -740,6 +766,7 @@ _RU = {
|
||||
"grafana_no_alerts": "Нет оповещений",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "Обновить",
|
||||
"prom_query": "PromQL запрос",
|
||||
"prom_execute": "Выполнить",
|
||||
"prom_targets": "Цели",
|
||||
@@ -778,6 +805,27 @@ _RU = {
|
||||
"launch_error": "Ошибка запуска: {error}",
|
||||
"launch_no_server": "Выберите сервер для подключения",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "Настройки RDP",
|
||||
"rdp_quality": "Качество",
|
||||
"rdp_quality_auto": "Авто",
|
||||
"rdp_quality_lan": "LAN (лучшее)",
|
||||
"rdp_quality_broadband": "Broadband",
|
||||
"rdp_quality_modem": "Низкое",
|
||||
"rdp_clipboard": "Буфер обмена",
|
||||
"rdp_drives": "Проброс дисков (файлы)",
|
||||
"rdp_printers": "Принтеры",
|
||||
"rdp_connecting": "Подключение к {alias}...",
|
||||
"rdp_embedding": "Встраивание RDP окна...",
|
||||
"rdp_connected": "Подключено к {alias}",
|
||||
"rdp_reconnected": "Переподключено к {alias}",
|
||||
"rdp_disconnected": "Отключено",
|
||||
"rdp_disconnect": "Отключить",
|
||||
"rdp_fullscreen": "Во весь экран",
|
||||
"rdp_exit_fullscreen": "Выход из полного экрана",
|
||||
"rdp_error_embed": "Ошибка встраивания: {error}",
|
||||
"rdp_error_timeout": "Таймаут ожидания RDP окна",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "База данных:",
|
||||
"info_ssl": "SSL:",
|
||||
@@ -801,7 +849,7 @@ _ZH = {
|
||||
# Sidebar
|
||||
"servers": "服务器",
|
||||
"search": "搜索...",
|
||||
"add": "+ 添加",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
|
||||
@@ -1102,6 +1150,7 @@ _ZH = {
|
||||
"query_exported": "已导出到 {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "清除",
|
||||
"redis_execute": "执行",
|
||||
"redis_db": "数据库:",
|
||||
"redis_keys_count": "键数: {count}",
|
||||
@@ -1113,6 +1162,7 @@ _ZH = {
|
||||
"redis_error": "错误: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "刷新",
|
||||
"grafana_dashboards": "仪表盘",
|
||||
"grafana_alerts": "告警",
|
||||
"grafana_uid": "UID",
|
||||
@@ -1126,6 +1176,7 @@ _ZH = {
|
||||
"grafana_no_alerts": "无告警",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "刷新",
|
||||
"prom_query": "PromQL查询",
|
||||
"prom_execute": "执行",
|
||||
"prom_targets": "目标",
|
||||
@@ -1164,6 +1215,27 @@ _ZH = {
|
||||
"launch_error": "启动失败: {error}",
|
||||
"launch_no_server": "选择服务器以连接",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "RDP设置",
|
||||
"rdp_quality": "质量",
|
||||
"rdp_quality_auto": "自动检测",
|
||||
"rdp_quality_lan": "局域网 (最佳)",
|
||||
"rdp_quality_broadband": "宽带",
|
||||
"rdp_quality_modem": "低带宽",
|
||||
"rdp_clipboard": "共享剪贴板",
|
||||
"rdp_drives": "共享驱动器 (文件)",
|
||||
"rdp_printers": "共享打印机",
|
||||
"rdp_connecting": "正在连接 {alias}...",
|
||||
"rdp_embedding": "嵌入RDP窗口...",
|
||||
"rdp_connected": "已连接 {alias}",
|
||||
"rdp_reconnected": "已重新连接到 {alias}",
|
||||
"rdp_disconnected": "已断开",
|
||||
"rdp_disconnect": "断开连接",
|
||||
"rdp_fullscreen": "全屏",
|
||||
"rdp_exit_fullscreen": "退出全屏",
|
||||
"rdp_error_embed": "嵌入失败: {error}",
|
||||
"rdp_error_timeout": "等待RDP窗口超时",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "数据库:",
|
||||
"info_ssl": "SSL:",
|
||||
|
||||
@@ -507,6 +507,8 @@ class EmbeddedRDP:
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
self._parent_hwnd = parent_hwnd
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
hwnd = self._mstsc_hwnd
|
||||
@@ -684,6 +686,104 @@ class EmbeddedRDP:
|
||||
|
||||
log.info("EmbeddedRDP disconnected")
|
||||
|
||||
def try_reembed(self, parent_hwnd: int, width: int, height: int) -> bool:
|
||||
"""Try to re-embed the mstsc window after reconnect.
|
||||
|
||||
Handles two scenarios:
|
||||
1. Same HWND got reparented to desktop (mstsc reconnect resets parent)
|
||||
2. New HWND created (mstsc spawned a new window)
|
||||
|
||||
Returns True if a window was found and embedded.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
# Scenario 1: current HWND is still valid but lost its parent
|
||||
if self._mstsc_hwnd and user32.IsWindow(self._mstsc_hwnd):
|
||||
actual_parent = user32.GetParent(self._mstsc_hwnd)
|
||||
if actual_parent != parent_hwnd:
|
||||
log.info(f"Re-embed: same HWND={self._mstsc_hwnd} lost parent "
|
||||
f"(parent={actual_parent}, expected={parent_hwnd})")
|
||||
self._connected = False
|
||||
self._embed(parent_hwnd, width, height)
|
||||
return self._connected
|
||||
|
||||
# Scenario 2: old HWND invalid, search for new mstsc window
|
||||
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
|
||||
|
||||
hostname = self.server["ip"]
|
||||
mstsc_pids = set(_find_mstsc_pids(self._process.pid)) if self._process else set()
|
||||
|
||||
found_windows = []
|
||||
|
||||
def enum_callback(hwnd, _lparam):
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
class_buf = ctypes.create_unicode_buffer(256)
|
||||
user32.GetClassNameW(hwnd, class_buf, 256)
|
||||
class_name = class_buf.value
|
||||
|
||||
title_buf = ctypes.create_unicode_buffer(512)
|
||||
user32.GetWindowTextW(hwnd, title_buf, 512)
|
||||
title = title_buf.value
|
||||
|
||||
pid = ctypes.wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
win_pid = pid.value
|
||||
|
||||
is_mstsc_class = class_name in ("TscShellContainerClass", "RAIL_WINDOW", "IHWindowClass")
|
||||
is_dialog = class_name == "#32770"
|
||||
pid_match = win_pid in mstsc_pids
|
||||
title_match = hostname in title
|
||||
|
||||
if (pid_match or title_match) and (is_mstsc_class or is_dialog):
|
||||
if hwnd != self._mstsc_hwnd:
|
||||
found_windows.append((hwnd, class_name, title, win_pid))
|
||||
return True
|
||||
|
||||
user32.EnumWindows(WNDENUMPROC(enum_callback), 0)
|
||||
|
||||
if not found_windows:
|
||||
return False
|
||||
|
||||
# Prefer TscShellContainerClass
|
||||
target = None
|
||||
for hwnd, cls, title, pid in found_windows:
|
||||
if cls == "TscShellContainerClass":
|
||||
target = hwnd
|
||||
break
|
||||
if not target:
|
||||
target = found_windows[0][0]
|
||||
|
||||
log.info(f"Re-embed: found new mstsc window HWND={target}")
|
||||
self._mstsc_hwnd = target
|
||||
self._connected = False
|
||||
self._embed(parent_hwnd, width, height)
|
||||
return self._connected
|
||||
|
||||
def is_embedded(self) -> bool:
|
||||
"""Check if the mstsc HWND is still a child of our parent frame.
|
||||
|
||||
Returns False if:
|
||||
- HWND is invalid (window destroyed)
|
||||
- HWND got reparented away from our frame (reconnect scenario)
|
||||
- No HWND tracked
|
||||
"""
|
||||
if not self._mstsc_hwnd or not self._parent_hwnd:
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
if not user32.IsWindow(self._mstsc_hwnd):
|
||||
return False
|
||||
# Check that it's still parented to our frame
|
||||
actual_parent = user32.GetParent(self._mstsc_hwnd)
|
||||
return actual_parent == self._parent_hwnd
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if mstsc process is still running (parent or children)."""
|
||||
if not self._process:
|
||||
|
||||
Reference in New Issue
Block a user