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

@@ -158,6 +158,34 @@ def build():
print(f"Build output not found: {src}")
sys.exit(1)
# Auto-cleanup: keep first release + last 5 (per CLAUDE.md policy)
cleanup_old_releases()
def cleanup_old_releases():
"""Keep the first release (v1.0.0) and the last 5 releases, delete the rest."""
import glob
pattern = os.path.join(RELEASES_DIR, f"{__app_name__}-v*")
all_exes = sorted(glob.glob(pattern))
if len(all_exes) <= 6: # first + 5 = 6, nothing to clean
return
# First release is always all_exes[0] (sorted, v1.0.0 < v1.8.x)
first = all_exes[0]
last_5 = all_exes[-5:]
keep = set([first] + last_5)
removed = []
for f in all_exes:
if f not in keep:
os.remove(f)
removed.append(os.path.basename(f))
if removed:
print(f"Cleaned {len(removed)} old releases: {', '.join(removed)}")
if __name__ == "__main__":
if "--clean" in sys.argv:

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:

View File

@@ -1,50 +1,165 @@
"""
Launch tab — connect button for RDP/VNC remote desktop sessions.
Launch tab — embedded RDP for Windows, simple launcher for VNC.
RDP sessions are embedded inside the GUI using Win32 SetParent().
VNC sessions launch an external viewer.
"""
import platform
import threading
import customtkinter as ctk
from core.remote_desktop import RemoteDesktopLauncher
from core.i18n import t
from core.icons import icon_text
from core.logger import log
class LaunchTab(ctk.CTkFrame):
"""Minimal tab: server info + big Connect button for RDP/VNC."""
"""Embedded RDP client (Windows) or VNC external launcher."""
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._server_type: str | None = None # "rdp" or "vnc"
self._server_type: str | None = None
self._embedded_rdp = None # EmbeddedRDP instance
self._is_fullscreen = False
self._monitor_id = None # after() id for connection monitoring
self._resize_id = None # after() id for resize debounce
self._build_ui()
def _build_ui(self):
# Server info label
self._info_label = ctk.CTkLabel(
self, text=t("no_server_selected_info"),
font=ctk.CTkFont(size=16), wraplength=400,
)
self._info_label.pack(padx=20, pady=(40, 20))
# ── Toolbar (shown when RDP connected) ──
self._toolbar = ctk.CTkFrame(self, height=36, fg_color="transparent")
# Big connect button
self._disconnect_btn = ctk.CTkButton(
self._toolbar, text=icon_text("delete", t("rdp_disconnect")),
width=120, height=30, fg_color="#ef4444", hover_color="#dc2626",
command=self._disconnect,
)
self._disconnect_btn.pack(side="left", padx=(8, 4))
self._fullscreen_btn = ctk.CTkButton(
self._toolbar, text=icon_text("launch", t("rdp_fullscreen")),
width=130, height=30, fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_fullscreen,
)
self._fullscreen_btn.pack(side="left", padx=4)
self._toolbar_status = ctk.CTkLabel(
self._toolbar, text="", font=ctk.CTkFont(size=11),
text_color="#22c55e", anchor="e",
)
self._toolbar_status.pack(side="right", padx=8)
# ── RDP embed frame (black background) ──
self._rdp_frame = ctk.CTkFrame(self, fg_color="#000000", corner_radius=0)
self._rdp_frame.bind("<Configure>", self._on_rdp_resize)
self._rdp_frame.bind("<Button-1>", lambda e: self._focus_rdp())
# ── Settings panel (shown when disconnected) ──
self._settings_panel = ctk.CTkFrame(self, fg_color="transparent")
self._info_label = ctk.CTkLabel(
self._settings_panel, text=t("no_server_selected_info"),
font=ctk.CTkFont(size=16), wraplength=500,
)
self._info_label.pack(padx=20, pady=(30, 15))
# Settings card
self._settings_card = ctk.CTkFrame(self._settings_panel)
self._settings_card.pack(fill="x", padx=40, pady=(0, 15))
card_title = ctk.CTkLabel(
self._settings_card, text=t("rdp_settings"),
font=ctk.CTkFont(size=14, weight="bold"), anchor="w",
)
card_title.pack(fill="x", padx=15, pady=(12, 8))
# Quality
q_row = ctk.CTkFrame(self._settings_card, fg_color="transparent")
q_row.pack(fill="x", padx=15, pady=3)
ctk.CTkLabel(q_row, text=t("rdp_quality"), width=140, anchor="w").pack(side="left")
self._quality_var = ctk.StringVar(value="auto")
quality_labels = {
"auto": t("rdp_quality_auto"),
"lan": t("rdp_quality_lan"),
"broadband": t("rdp_quality_broadband"),
"modem": t("rdp_quality_modem"),
}
self._quality_labels = quality_labels
self._quality_rmap = {v: k for k, v in quality_labels.items()}
self._quality_menu = ctk.CTkOptionMenu(
q_row, values=list(quality_labels.values()),
variable=self._quality_var, width=180,
)
self._quality_menu.pack(side="left")
self._quality_var.set(quality_labels["auto"])
# Clipboard
self._clip_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(
self._settings_card, text=t("rdp_clipboard"),
variable=self._clip_var,
).pack(fill="x", padx=15, pady=3)
# Drives
self._drives_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
self._settings_card, text=t("rdp_drives"),
variable=self._drives_var,
).pack(fill="x", padx=15, pady=3)
# Printers
self._printers_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
self._settings_card, text=t("rdp_printers"),
variable=self._printers_var,
).pack(fill="x", padx=15, pady=(3, 12))
# Connect button
self._connect_btn = ctk.CTkButton(
self, text=t("launch_connect"),
self._settings_panel, text=icon_text("execute", t("launch_connect")),
font=ctk.CTkFont(size=18, weight="bold"),
width=220, height=50,
command=self._on_connect,
)
self._connect_btn.pack(pady=20)
self._connect_btn.pack(pady=15)
self._connect_btn.configure(state="disabled")
# Status / result label
# Status label
self._status_label = ctk.CTkLabel(
self, text="", font=ctk.CTkFont(size=13),
self._settings_panel, text="", font=ctk.CTkFont(size=13),
text_color="#888888", wraplength=400,
)
self._status_label.pack(padx=20, pady=(10, 0))
self._status_label.pack(padx=20, pady=(5, 0))
# Start in disconnected state
self._show_settings()
# ── State management ──────────────────────────────────────────
def _show_settings(self):
"""Show settings panel, hide toolbar and RDP frame."""
self._toolbar.pack_forget()
self._rdp_frame.pack_forget()
self._settings_panel.pack(fill="both", expand=True)
def _show_rdp(self):
"""Show toolbar and RDP frame, hide settings panel."""
self._settings_panel.pack_forget()
self._toolbar.pack(fill="x", pady=(4, 0))
self._rdp_frame.pack(fill="both", expand=True, padx=4, pady=(2, 4))
# ── Public API ────────────────────────────────────────────────
def set_server(self, alias: str | None):
"""Called when user selects a server in sidebar."""
# Disconnect previous if any
if self._embedded_rdp:
self._disconnect()
self._current_alias = alias
self._status_label.configure(text="", text_color="#888888")
@@ -52,6 +167,7 @@ class LaunchTab(ctk.CTkFrame):
self._info_label.configure(text=t("no_server_selected_info"))
self._connect_btn.configure(state="disabled")
self._server_type = None
self._settings_card.pack(fill="x", padx=40, pady=(0, 15))
return
server = self.store.get_server(alias)
@@ -65,15 +181,26 @@ class LaunchTab(ctk.CTkFrame):
self._server_type = stype
if stype == "rdp":
info_text = t("launch_rdp_info").format(alias=alias)
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
self._quality_var.set(self._quality_labels.get(
server.get("rdp_quality", "auto"), self._quality_labels["auto"]
))
self._clip_var.set(server.get("rdp_clipboard", True))
self._drives_var.set(server.get("rdp_drives", False))
self._printers_var.set(server.get("rdp_printers", False))
elif stype == "vnc":
info_text = t("launch_vnc_info").format(alias=alias)
self._info_label.configure(text=t("launch_vnc_info").format(alias=alias))
self._settings_card.pack_forget() # VNC has no settings
else:
info_text = f"{alias} ({stype.upper()})"
self._info_label.configure(text=f"{alias} ({stype.upper()})")
self._settings_card.pack_forget()
self._info_label.configure(text=info_text)
self._connect_btn.configure(state="normal")
# ── Connect / Disconnect ──────────────────────────────────────
def _on_connect(self):
if not self._current_alias or not self._server_type:
return
@@ -82,20 +209,101 @@ class LaunchTab(ctk.CTkFrame):
if not server:
return
stype = self._server_type
if stype == "rdp" and platform.system() == "Windows":
self._connect_embedded_rdp(server)
else:
# VNC or non-Windows: launch external client
self._launch_external(server, stype)
def _connect_embedded_rdp(self, server: dict):
"""Start embedded RDP session."""
from core.remote_desktop import EmbeddedRDP
self._connect_btn.configure(state="disabled")
self._status_label.configure(
text=t("rdp_connecting").format(alias=self._current_alias),
text_color="#ccaa00",
)
# Gather settings
settings = {
"quality": self._quality_rmap.get(self._quality_var.get(), "auto"),
"clipboard": self._clip_var.get(),
"drives": self._drives_var.get(),
"printers": self._printers_var.get(),
}
self._embedded_rdp = EmbeddedRDP(server, settings)
# Set callbacks
self._embedded_rdp.on_embedded = lambda: self.after(0, self._on_rdp_embedded)
self._embedded_rdp.on_failed = lambda err: self.after(0, lambda: self._on_rdp_failed(err))
# Switch to RDP view
self._show_rdp()
self._toolbar_status.configure(
text=t("rdp_embedding"), text_color="#ccaa00",
)
# Force geometry update so winfo_id works
self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id()
w = max(self._rdp_frame.winfo_width(), 800)
h = max(self._rdp_frame.winfo_height(), 600)
self._embedded_rdp.launch(parent_hwnd, w, h)
def _on_rdp_embedded(self):
"""Called when mstsc window has been embedded successfully."""
self._toolbar_status.configure(
text=t("rdp_connected").format(alias=self._current_alias),
text_color="#22c55e",
)
# Start monitoring
self._start_monitor()
def _on_rdp_failed(self, error: str):
"""Called when embedding failed."""
self._toolbar_status.configure(
text=t("rdp_error_embed").format(error=error),
text_color="#ef4444",
)
# Return to settings after 3 seconds
self.after(3000, self._disconnect)
def _disconnect(self):
"""Disconnect current RDP session."""
self._stop_monitor()
if self._is_fullscreen:
self._is_fullscreen = False
if self._embedded_rdp:
self._embedded_rdp.disconnect()
self._embedded_rdp = None
self._show_settings()
self._status_label.configure(
text=t("rdp_disconnected"), text_color="#888888",
)
self._connect_btn.configure(state="normal")
def _launch_external(self, server: dict, stype: str):
"""Launch external RDP/VNC client (VNC or non-Windows)."""
self._connect_btn.configure(state="disabled")
self._status_label.configure(
text=t("launch_starting"), text_color="#ccaa00",
)
stype = self._server_type
def _do():
try:
if stype == "rdp":
RemoteDesktopLauncher.launch_rdp(server)
elif stype == "vnc":
RemoteDesktopLauncher.launch_vnc(server)
self.after(0, lambda: self._status_label.configure(
text=t("launch_started"), text_color="#44cc44",
))
@@ -108,3 +316,98 @@ class LaunchTab(ctk.CTkFrame):
self.after(0, lambda: self._connect_btn.configure(state="normal"))
threading.Thread(target=_do, daemon=True).start()
# ── Fullscreen toggle ─────────────────────────────────────────
def _toggle_fullscreen(self):
if not self._embedded_rdp or not self._embedded_rdp.connected:
return
if self._is_fullscreen:
# Exit fullscreen — reattach
self._is_fullscreen = False
self._fullscreen_btn.configure(
text=icon_text("launch", t("rdp_fullscreen")),
)
self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id()
w = self._rdp_frame.winfo_width()
h = self._rdp_frame.winfo_height()
self._embedded_rdp.reattach(parent_hwnd, w, h)
else:
# Go fullscreen — detach
self._is_fullscreen = True
self._fullscreen_btn.configure(
text=icon_text("back", t("rdp_exit_fullscreen")),
)
self._embedded_rdp.detach()
# ── Resize handling with debounce ─────────────────────────────
def _on_rdp_resize(self, event):
if not self._embedded_rdp or not self._embedded_rdp.connected:
return
if self._is_fullscreen:
return
if self._resize_id:
self.after_cancel(self._resize_id)
self._resize_id = self.after(100, lambda: self._do_resize(event.width, event.height))
def _do_resize(self, width, height):
self._resize_id = None
if self._embedded_rdp and self._embedded_rdp.connected:
self._embedded_rdp.resize(width, height)
# ── Focus management ──────────────────────────────────────────
def _focus_rdp(self):
if self._embedded_rdp:
self._embedded_rdp.focus()
# ── Connection monitoring ─────────────────────────────────────
def _start_monitor(self):
self._stop_monitor()
self._monitor_tick()
def _stop_monitor(self):
if self._monitor_id:
self.after_cancel(self._monitor_id)
self._monitor_id = None
def _monitor_tick(self):
if self._embedded_rdp:
if not self._embedded_rdp.is_alive():
# Process dead — full disconnect
self._on_rdp_exited()
return
if not self._embedded_rdp.is_embedded():
# HWND invalid or reparented — mstsc reconnected with new window
log.info("RDP window lost, attempting re-embed...")
self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id()
w = max(self._rdp_frame.winfo_width(), 800)
h = max(self._rdp_frame.winfo_height(), 600)
if self._embedded_rdp.try_reembed(parent_hwnd, w, h):
log.info("RDP auto-recovered after reconnect")
self._toolbar_status.configure(
text=t("rdp_reconnected").format(alias=self._current_alias),
text_color="#22c55e",
)
# If re-embed failed, keep trying — process is still alive
self._monitor_id = self.after(500, self._monitor_tick)
def _on_rdp_exited(self):
"""mstsc process exited (user closed session or network error)."""
self._embedded_rdp = None
self._is_fullscreen = False
self._show_settings()
self._status_label.configure(
text=t("rdp_disconnected"), text_color="#888888",
)
self._connect_btn.configure(state="normal")

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.32"
__version__ = "1.8.35"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"