Files
server-manager/gui/tabs/launch_tab.py
chrome-storm-c442 68f2d7eae8 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>
2026-02-24 12:45:05 -05:00

414 lines
16 KiB
Python

"""
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):
"""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
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):
# ── Toolbar (shown when RDP connected) ──
self._toolbar = ctk.CTkFrame(self, height=36, fg_color="transparent")
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._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=15)
self._connect_btn.configure(state="disabled")
# Status label
self._status_label = ctk.CTkLabel(
self._settings_panel, text="", font=ctk.CTkFont(size=13),
text_color="#888888", wraplength=400,
)
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")
if alias is None:
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)
if not server:
self._info_label.configure(text=t("server_not_found").format(alias=alias))
self._connect_btn.configure(state="disabled")
self._server_type = None
return
stype = server.get("type", "").lower()
self._server_type = stype
if stype == "rdp":
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":
self._info_label.configure(text=t("launch_vnc_info").format(alias=alias))
self._settings_card.pack_forget() # VNC has no settings
else:
self._info_label.configure(text=f"{alias} ({stype.upper()})")
self._settings_card.pack_forget()
self._connect_btn.configure(state="normal")
# ── Connect / Disconnect ──────────────────────────────────────
def _on_connect(self):
if not self._current_alias or not self._server_type:
return
server = self.store.get_server(self._current_alias)
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",
)
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",
))
except Exception as exc:
self.after(0, lambda: self._status_label.configure(
text=t("launch_error").format(error=str(exc)),
text_color="#ff4444",
))
finally:
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")