""" 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("", self._on_rdp_resize) self._rdp_frame.bind("", 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")