Files
server-manager/gui/tabs/launch_tab.py
chrome-storm-c442 1e729fcf3a v1.9.1: PNG Material Design icons — 28 icons, dark/light theme, HiDPI, graceful Unicode fallback
- 56 PNG icons (28 unique × 2 color variants) from Material Design Icons (round style, 96×96px)
- core/icons.py: ctk_icon(), make_icon_button(), reconfigure_icon_button() with CTkImage cache
- Updated 15 GUI files: app.py, sidebar.py, server_dialog.py, all tabs
- build.py: auto-include assets/icons/ in PyInstaller bundle, patch rollover at 99→minor+1
- tools/download_icons.py: icon download script
- Automatic dark↔light theme switching via CTkImage dual-image support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:27:49 -05:00

466 lines
18 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, make_icon_button, reconfigure_icon_button
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 = make_icon_button(
self._toolbar, "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 = make_icon_button(
self._toolbar, "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))
# 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 = make_icon_button(
self._settings_panel, "execute", t("launch_connect"),
icon_size=20,
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
reconfigure_icon_button(self._fullscreen_btn, "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
reconfigure_icon_button(self._fullscreen_btn, "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")