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:
chrome-storm-c442
2026-02-24 12:45:05 -05:00
parent 68e94856f6
commit 68f2d7eae8
14 changed files with 529 additions and 26 deletions

View File

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

View File

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