""" 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)) # Resolution r_row = ctk.CTkFrame(self._settings_card, fg_color="transparent") r_row.pack(fill="x", padx=15, pady=3) ctk.CTkLabel(r_row, text=t("rdp_resolution"), width=140, anchor="w").pack(side="left") self._resolution_var = ctk.StringVar(value=t("rdp_resolution_auto")) resolution_values = [ t("rdp_resolution_auto"), "800\u00d7600", "1024\u00d7768", "1280\u00d71024", "1366\u00d7768", "1600\u00d7900", "1920\u00d71080", ] self._resolution_menu = ctk.CTkOptionMenu( r_row, values=resolution_values, variable=self._resolution_var, width=180, ) self._resolution_menu.pack(side="left") # 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 res_raw = server.get("rdp_resolution", "auto") if res_raw == "auto": self._resolution_var.set(t("rdp_resolution_auto")) else: self._resolution_var.set(res_raw.replace("x", "\u00d7")) 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(), } # Parse resolution res = self._resolution_var.get() if res == t("rdp_resolution_auto"): settings["resolution"] = "auto" else: settings["resolution"] = res.replace("\u00d7", "x") 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() # Always use frame size for the mstsc window; # session resolution is handled inside generate_rdp_file via settings 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", ) # Re-normalize position after embed settles self.after(300, self._normalize_rdp_position) self.after(1000, self._normalize_rdp_position) # Start monitoring self._start_monitor() def _normalize_rdp_position(self): """Force mstsc to fill the frame at (0,0) — fixes post-embed offset.""" if not self._embedded_rdp or not self._embedded_rdp.connected: return if self._is_fullscreen: return try: w = self._rdp_frame.winfo_width() h = self._rdp_frame.winfo_height() if w > 10 and h > 10: self._embedded_rdp.resize(w, h) except Exception: pass 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, then maximize after event loop settles self._is_fullscreen = True self._fullscreen_btn.configure( text=icon_text("back", t("rdp_exit_fullscreen")), ) self._embedded_rdp.detach() self.after(300, self._maximize_detached) def _maximize_detached(self): """Called after delay — maximize the detached mstsc window.""" if self._embedded_rdp: self._embedded_rdp.maximize() # ── 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 # Skip re-embed check when intentionally detached (fullscreen) if not self._embedded_rdp._is_detached and 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")