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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user