- Add core/icons.py — centralized icon text helper with emoji/symbol support - Add Windows SSH command sanitization in ssh.py (Linux→Windows auto-translation) - Improve embedded RDP: launch tab connect/disconnect, fullscreen toggle - Refactor sidebar: cleaner server type badges - Update server_dialog: adaptive fields per server type - Add setup_openssh.bat tool - Update skill-ssh.md and CLAUDE.md docs for Windows SSH support - Cleanup old releases, add v1.8.48-v1.8.52 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
18 KiB
Python
469 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
|
|
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))
|
|
|
|
# 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")
|