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:
28
build.py
28
build.py
@@ -158,6 +158,34 @@ def build():
|
||||
print(f"Build output not found: {src}")
|
||||
sys.exit(1)
|
||||
|
||||
# Auto-cleanup: keep first release + last 5 (per CLAUDE.md policy)
|
||||
cleanup_old_releases()
|
||||
|
||||
|
||||
def cleanup_old_releases():
|
||||
"""Keep the first release (v1.0.0) and the last 5 releases, delete the rest."""
|
||||
import glob
|
||||
|
||||
pattern = os.path.join(RELEASES_DIR, f"{__app_name__}-v*")
|
||||
all_exes = sorted(glob.glob(pattern))
|
||||
|
||||
if len(all_exes) <= 6: # first + 5 = 6, nothing to clean
|
||||
return
|
||||
|
||||
# First release is always all_exes[0] (sorted, v1.0.0 < v1.8.x)
|
||||
first = all_exes[0]
|
||||
last_5 = all_exes[-5:]
|
||||
keep = set([first] + last_5)
|
||||
|
||||
removed = []
|
||||
for f in all_exes:
|
||||
if f not in keep:
|
||||
os.remove(f)
|
||||
removed.append(os.path.basename(f))
|
||||
|
||||
if removed:
|
||||
print(f"Cleaned {len(removed)} old releases: {', '.join(removed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--clean" in sys.argv:
|
||||
|
||||
78
core/i18n.py
78
core/i18n.py
@@ -29,7 +29,7 @@ _EN = {
|
||||
# Sidebar
|
||||
"servers": "Servers",
|
||||
"search": "Search...",
|
||||
"add": "+ Add",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -330,6 +330,7 @@ _EN = {
|
||||
"query_exported": "Exported to {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "Clear",
|
||||
"redis_execute": "Execute",
|
||||
"redis_db": "DB:",
|
||||
"redis_keys_count": "Keys: {count}",
|
||||
@@ -341,6 +342,7 @@ _EN = {
|
||||
"redis_error": "Error: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "Refresh",
|
||||
"grafana_dashboards": "Dashboards",
|
||||
"grafana_alerts": "Alerts",
|
||||
"grafana_uid": "UID",
|
||||
@@ -354,6 +356,7 @@ _EN = {
|
||||
"grafana_no_alerts": "No alerts",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "Refresh",
|
||||
"prom_query": "PromQL Query",
|
||||
"prom_execute": "Execute",
|
||||
"prom_targets": "Targets",
|
||||
@@ -392,6 +395,27 @@ _EN = {
|
||||
"launch_error": "Launch failed: {error}",
|
||||
"launch_no_server": "Select a server to connect",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "RDP Settings",
|
||||
"rdp_quality": "Quality",
|
||||
"rdp_quality_auto": "Auto Detect",
|
||||
"rdp_quality_lan": "LAN (Best)",
|
||||
"rdp_quality_broadband": "Broadband",
|
||||
"rdp_quality_modem": "Low Bandwidth",
|
||||
"rdp_clipboard": "Share Clipboard",
|
||||
"rdp_drives": "Share Drives (Files)",
|
||||
"rdp_printers": "Share Printers",
|
||||
"rdp_connecting": "Connecting to {alias}...",
|
||||
"rdp_embedding": "Embedding RDP window...",
|
||||
"rdp_connected": "Connected to {alias}",
|
||||
"rdp_reconnected": "Reconnected to {alias}",
|
||||
"rdp_disconnected": "Disconnected",
|
||||
"rdp_disconnect": "Disconnect",
|
||||
"rdp_fullscreen": "Fullscreen",
|
||||
"rdp_exit_fullscreen": "Exit Fullscreen",
|
||||
"rdp_error_embed": "Failed to embed: {error}",
|
||||
"rdp_error_timeout": "Timed out waiting for RDP window",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "Database:",
|
||||
"info_ssl": "SSL:",
|
||||
@@ -415,7 +439,7 @@ _RU = {
|
||||
# Sidebar
|
||||
"servers": "Серверы",
|
||||
"search": "Поиск...",
|
||||
"add": "+ Добавить",
|
||||
"add": "Добавить",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
|
||||
@@ -716,6 +740,7 @@ _RU = {
|
||||
"query_exported": "Экспортировано в {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "Очистить",
|
||||
"redis_execute": "Выполнить",
|
||||
"redis_db": "БД:",
|
||||
"redis_keys_count": "Ключей: {count}",
|
||||
@@ -727,6 +752,7 @@ _RU = {
|
||||
"redis_error": "Ошибка: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "Обновить",
|
||||
"grafana_dashboards": "Дашборды",
|
||||
"grafana_alerts": "Оповещения",
|
||||
"grafana_uid": "UID",
|
||||
@@ -740,6 +766,7 @@ _RU = {
|
||||
"grafana_no_alerts": "Нет оповещений",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "Обновить",
|
||||
"prom_query": "PromQL запрос",
|
||||
"prom_execute": "Выполнить",
|
||||
"prom_targets": "Цели",
|
||||
@@ -778,6 +805,27 @@ _RU = {
|
||||
"launch_error": "Ошибка запуска: {error}",
|
||||
"launch_no_server": "Выберите сервер для подключения",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "Настройки RDP",
|
||||
"rdp_quality": "Качество",
|
||||
"rdp_quality_auto": "Авто",
|
||||
"rdp_quality_lan": "LAN (лучшее)",
|
||||
"rdp_quality_broadband": "Broadband",
|
||||
"rdp_quality_modem": "Низкое",
|
||||
"rdp_clipboard": "Буфер обмена",
|
||||
"rdp_drives": "Проброс дисков (файлы)",
|
||||
"rdp_printers": "Принтеры",
|
||||
"rdp_connecting": "Подключение к {alias}...",
|
||||
"rdp_embedding": "Встраивание RDP окна...",
|
||||
"rdp_connected": "Подключено к {alias}",
|
||||
"rdp_reconnected": "Переподключено к {alias}",
|
||||
"rdp_disconnected": "Отключено",
|
||||
"rdp_disconnect": "Отключить",
|
||||
"rdp_fullscreen": "Во весь экран",
|
||||
"rdp_exit_fullscreen": "Выход из полного экрана",
|
||||
"rdp_error_embed": "Ошибка встраивания: {error}",
|
||||
"rdp_error_timeout": "Таймаут ожидания RDP окна",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "База данных:",
|
||||
"info_ssl": "SSL:",
|
||||
@@ -801,7 +849,7 @@ _ZH = {
|
||||
# Sidebar
|
||||
"servers": "服务器",
|
||||
"search": "搜索...",
|
||||
"add": "+ 添加",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
|
||||
@@ -1102,6 +1150,7 @@ _ZH = {
|
||||
"query_exported": "已导出到 {path}",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "清除",
|
||||
"redis_execute": "执行",
|
||||
"redis_db": "数据库:",
|
||||
"redis_keys_count": "键数: {count}",
|
||||
@@ -1113,6 +1162,7 @@ _ZH = {
|
||||
"redis_error": "错误: {error}",
|
||||
|
||||
# Grafana tab
|
||||
"grafana_refresh": "刷新",
|
||||
"grafana_dashboards": "仪表盘",
|
||||
"grafana_alerts": "告警",
|
||||
"grafana_uid": "UID",
|
||||
@@ -1126,6 +1176,7 @@ _ZH = {
|
||||
"grafana_no_alerts": "无告警",
|
||||
|
||||
# Prometheus tab
|
||||
"prom_refresh": "刷新",
|
||||
"prom_query": "PromQL查询",
|
||||
"prom_execute": "执行",
|
||||
"prom_targets": "目标",
|
||||
@@ -1164,6 +1215,27 @@ _ZH = {
|
||||
"launch_error": "启动失败: {error}",
|
||||
"launch_no_server": "选择服务器以连接",
|
||||
|
||||
# Embedded RDP
|
||||
"rdp_settings": "RDP设置",
|
||||
"rdp_quality": "质量",
|
||||
"rdp_quality_auto": "自动检测",
|
||||
"rdp_quality_lan": "局域网 (最佳)",
|
||||
"rdp_quality_broadband": "宽带",
|
||||
"rdp_quality_modem": "低带宽",
|
||||
"rdp_clipboard": "共享剪贴板",
|
||||
"rdp_drives": "共享驱动器 (文件)",
|
||||
"rdp_printers": "共享打印机",
|
||||
"rdp_connecting": "正在连接 {alias}...",
|
||||
"rdp_embedding": "嵌入RDP窗口...",
|
||||
"rdp_connected": "已连接 {alias}",
|
||||
"rdp_reconnected": "已重新连接到 {alias}",
|
||||
"rdp_disconnected": "已断开",
|
||||
"rdp_disconnect": "断开连接",
|
||||
"rdp_fullscreen": "全屏",
|
||||
"rdp_exit_fullscreen": "退出全屏",
|
||||
"rdp_error_embed": "嵌入失败: {error}",
|
||||
"rdp_error_timeout": "等待RDP窗口超时",
|
||||
|
||||
# Info tab type-specific
|
||||
"info_database": "数据库:",
|
||||
"info_ssl": "SSL:",
|
||||
|
||||
@@ -507,6 +507,8 @@ class EmbeddedRDP:
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
self._parent_hwnd = parent_hwnd
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
hwnd = self._mstsc_hwnd
|
||||
@@ -684,6 +686,104 @@ class EmbeddedRDP:
|
||||
|
||||
log.info("EmbeddedRDP disconnected")
|
||||
|
||||
def try_reembed(self, parent_hwnd: int, width: int, height: int) -> bool:
|
||||
"""Try to re-embed the mstsc window after reconnect.
|
||||
|
||||
Handles two scenarios:
|
||||
1. Same HWND got reparented to desktop (mstsc reconnect resets parent)
|
||||
2. New HWND created (mstsc spawned a new window)
|
||||
|
||||
Returns True if a window was found and embedded.
|
||||
"""
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
# Scenario 1: current HWND is still valid but lost its parent
|
||||
if self._mstsc_hwnd and user32.IsWindow(self._mstsc_hwnd):
|
||||
actual_parent = user32.GetParent(self._mstsc_hwnd)
|
||||
if actual_parent != parent_hwnd:
|
||||
log.info(f"Re-embed: same HWND={self._mstsc_hwnd} lost parent "
|
||||
f"(parent={actual_parent}, expected={parent_hwnd})")
|
||||
self._connected = False
|
||||
self._embed(parent_hwnd, width, height)
|
||||
return self._connected
|
||||
|
||||
# Scenario 2: old HWND invalid, search for new mstsc window
|
||||
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
|
||||
|
||||
hostname = self.server["ip"]
|
||||
mstsc_pids = set(_find_mstsc_pids(self._process.pid)) if self._process else set()
|
||||
|
||||
found_windows = []
|
||||
|
||||
def enum_callback(hwnd, _lparam):
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
class_buf = ctypes.create_unicode_buffer(256)
|
||||
user32.GetClassNameW(hwnd, class_buf, 256)
|
||||
class_name = class_buf.value
|
||||
|
||||
title_buf = ctypes.create_unicode_buffer(512)
|
||||
user32.GetWindowTextW(hwnd, title_buf, 512)
|
||||
title = title_buf.value
|
||||
|
||||
pid = ctypes.wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
win_pid = pid.value
|
||||
|
||||
is_mstsc_class = class_name in ("TscShellContainerClass", "RAIL_WINDOW", "IHWindowClass")
|
||||
is_dialog = class_name == "#32770"
|
||||
pid_match = win_pid in mstsc_pids
|
||||
title_match = hostname in title
|
||||
|
||||
if (pid_match or title_match) and (is_mstsc_class or is_dialog):
|
||||
if hwnd != self._mstsc_hwnd:
|
||||
found_windows.append((hwnd, class_name, title, win_pid))
|
||||
return True
|
||||
|
||||
user32.EnumWindows(WNDENUMPROC(enum_callback), 0)
|
||||
|
||||
if not found_windows:
|
||||
return False
|
||||
|
||||
# Prefer TscShellContainerClass
|
||||
target = None
|
||||
for hwnd, cls, title, pid in found_windows:
|
||||
if cls == "TscShellContainerClass":
|
||||
target = hwnd
|
||||
break
|
||||
if not target:
|
||||
target = found_windows[0][0]
|
||||
|
||||
log.info(f"Re-embed: found new mstsc window HWND={target}")
|
||||
self._mstsc_hwnd = target
|
||||
self._connected = False
|
||||
self._embed(parent_hwnd, width, height)
|
||||
return self._connected
|
||||
|
||||
def is_embedded(self) -> bool:
|
||||
"""Check if the mstsc HWND is still a child of our parent frame.
|
||||
|
||||
Returns False if:
|
||||
- HWND is invalid (window destroyed)
|
||||
- HWND got reparented away from our frame (reconnect scenario)
|
||||
- No HWND tracked
|
||||
"""
|
||||
if not self._mstsc_hwnd or not self._parent_hwnd:
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
if not user32.IsWindow(self._mstsc_hwnd):
|
||||
return False
|
||||
# Check that it's still parented to our frame
|
||||
actual_parent = user32.GetParent(self._mstsc_hwnd)
|
||||
return actual_parent == self._parent_hwnd
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if mstsc process is still running (parent or children)."""
|
||||
if not self._process:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"""Version info for ServerManager."""
|
||||
|
||||
__version__ = "1.8.32"
|
||||
__version__ = "1.8.35"
|
||||
__app_name__ = "ServerManager"
|
||||
__author__ = "aibot777"
|
||||
__description__ = "Desktop GUI for managing remote servers"
|
||||
|
||||
Reference in New Issue
Block a user