diff --git a/core/remote_desktop.py b/core/remote_desktop.py index 513bf5e..e77f75b 100644 --- a/core/remote_desktop.py +++ b/core/remote_desktop.py @@ -243,6 +243,7 @@ class EmbeddedRDP: self._rdp_file: str | None = None self._connected = False self._parent_hwnd: int | None = None + self._is_detached = False # True when intentionally fullscreen # Callbacks (set by GUI) self.on_embedded = None # called when window is embedded @@ -612,6 +613,7 @@ class EmbeddedRDP: """Detach mstsc from parent — for fullscreen.""" if not self._mstsc_hwnd or not self._connected: return + self._is_detached = True try: import ctypes import ctypes.wintypes @@ -644,6 +646,7 @@ class EmbeddedRDP: """Re-embed mstsc back into the tkinter frame.""" if not self._mstsc_hwnd: return + self._is_detached = False self._embed(parent_hwnd, width, height) log.info("mstsc reattached") diff --git a/gui/tabs/launch_tab.py b/gui/tabs/launch_tab.py index f0ace48..4944c09 100644 --- a/gui/tabs/launch_tab.py +++ b/gui/tabs/launch_tab.py @@ -384,7 +384,8 @@ class LaunchTab(ctk.CTkFrame): self._on_rdp_exited() return - if not self._embedded_rdp.is_embedded(): + # 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() diff --git a/plans/test-rdp.md b/plans/test-rdp.md new file mode 100644 index 0000000..db0c993 --- /dev/null +++ b/plans/test-rdp.md @@ -0,0 +1,453 @@ +# Ручное тестирование Embedded RDP + +Практическое руководство по тестированию встроенного RDP-клиента в ServerManager. + +## Общие сведения + +- **Платформа:** только Windows (на Linux/macOS — fallback на внешний клиент) +- **Лог-файл:** `~/.server-connections/app.log` (ротация 5 МБ, 3 бэкапа) +- **Формат логов:** `YYYY-MM-DD HH:MM:SS [LEVEL] servermanager: message` +- **Ключевые файлы:** + - `core/remote_desktop.py` — класс `EmbeddedRDP`, Win32 embedding + - `gui/tabs/launch_tab.py` — UI таба Launch, управление состоянием + - `core/logger.py` — настройки логгера + +--- + +## Тест 1: Первичное подключение и встраивание + +### Предусловия +- В ServerManager добавлен сервер типа `rdp` с валидными credentials +- Сервер доступен по сети (порт 3389 открыт) +- Запущен `python main.py` или exe из `releases/` + +### Шаги +1. Выбрать RDP-сервер в боковой панели (sidebar) +2. Перейти на вкладку **Launch** +3. Убедиться, что видна панель настроек: Quality, Clipboard, Drives, Printers +4. Оставить настройки по умолчанию (Auto, Clipboard ON, остальное OFF) +5. Нажать кнопку **Connect** + +### Ожидаемый результат +- Кнопка Connect блокируется (state=disabled) +- Статус меняется на "Connecting..." (жёлтый цвет) +- Панель настроек скрывается, появляется тулбар с кнопками Disconnect/Fullscreen +- В тулбаре статус "Embedding..." (жёлтый) +- Через 1-5 секунд окно mstsc.exe встраивается в чёрный фрейм +- Статус меняется на "Connected: {alias}" (зелёный) +- Окно mstsc заполняет весь доступный фрейм + +### Что проверить в логах +``` +grep -i "mstsc\|rdp\|embed\|SetParent\|AttachThread" ~/.server-connections/app.log +``` +Должны быть строки: +- `Embedded RDP file: C:\...\sm_embed_{alias}.rdp` — .rdp файл создан +- `RDP server pre-trusted in registry: {host}` — сертификат доверен +- `mstsc.exe launched, PID=XXXX` — процесс запущен +- `Embed attempt N: found M mstsc windows` — окно найдено +- `HWND=XXXX class='TscShellContainerClass'` — правильный класс окна +- `AttachThreadInput(our, target): True` — потоки привязаны +- `SetParent(hwnd=XXXX, parent=YYYY) = ZZZZ` — встраивание выполнено +- `mstsc embedded: HWND=XXXX into parent=YYYY` — успех + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| Окно mstsc открылось отдельно, не встроилось | `SetParent` вернул 0 | Проверить `GetLastError` в логах, возможно UIPI (запуск от другого пользователя) | +| Появился диалог сертификата | Сервер не в реестре | `_trust_rdp_server` должен был подавить, проверить реестр `HKCU\Software\Microsoft\Terminal Server Client` | +| Timeout 20 секунд | mstsc не стартовал | Проверить что mstsc.exe доступен в PATH, нет ли блокировки антивирусом | +| Встроился диалог вместо основного окна | Фаза 1 нашла только `#32770` | Фаза 2 должна дождаться `TscShellContainerClass` (до 30 сек) | + +--- + +## Тест 2: Fullscreen (detach / reattach) + +### Предусловия +- RDP-сессия активна и встроена (тест 1 пройден) +- Статус "Connected" (зелёный) + +### Шаги +1. Нажать кнопку **Fullscreen** в тулбаре +2. Убедиться что окно mstsc развернулось на весь экран +3. Поработать в RDP-сессии (открыть Explorer, cmd и т.п.) +4. Переключиться обратно в ServerManager (Alt+Tab) +5. Нажать кнопку **Exit Fullscreen** (она заменила Fullscreen) + +### Ожидаемый результат +- **При нажатии Fullscreen:** + - Кнопка меняет текст на "Exit Fullscreen" + - Окно mstsc отсоединяется от фрейма (`SetParent(hwnd, 0)`) + - Стиль окна восстанавливается до `WS_OVERLAPPEDWINDOW` + - Окно максимизируется (`SW_MAXIMIZE`) + - Фокус на mstsc (`SetForegroundWindow`) +- **При нажатии Exit Fullscreen:** + - Окно снова встраивается в фрейм (вызов `_embed`) + - Кнопка возвращает текст "Fullscreen" + - Размер окна подгоняется под фрейм + +### Что проверить в логах +``` +mstsc detached to fullscreen +mstsc reattached +AttachThreadInput(...) +SetParent(hwnd=XXXX, parent=YYYY) +``` + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| После reattach чёрный экран | mstsc потерял rendering | Кликнуть в фрейм, фокус восстановит отрисовку | +| Окно mstsc не максимизируется | `ShowWindow(hwnd, SW_MAXIMIZE)` не сработал | Проверить что detach убрал `WS_CHILD` перед `SetParent(0)` | +| После Exit Fullscreen мониторинг не видит окно | `is_embedded()` возвращает False | `try_reembed` должен подхватить | + +--- + +## Тест 3: Reconnect recovery (имитация разрыва сети) + +### Предусловия +- RDP-сессия активна и встроена +- Доступ к сетевым настройкам (для имитации разрыва) + +### Шаги +1. При активной RDP-сессии отключить сеть (Wi-Fi off, или вытащить кабель) +2. Подождать 5-15 секунд — mstsc покажет "Reconnecting..." +3. Включить сеть обратно +4. Подождать до 30 секунд пока mstsc переподключится + +### Ожидаемый результат +- mstsc автоматически переподключается (параметр `autoreconnection enabled:i:1` в .rdp) +- При переподключении mstsc может создать новое окно или перепривязать HWND +- Мониторинг (`_monitor_tick`, каждые 500ms) детектирует потерю HWND: + - `is_embedded()` возвращает False (HWND перепривязан к десктопу) + - Вызывается `try_reembed()` +- Окно автоматически повторно встраивается в фрейм +- Статус меняется на "Reconnected: {alias}" (зелёный) + +### Что проверить в логах +``` +RDP window lost, attempting re-embed... +Re-embed: same HWND=XXXX lost parent (parent=0, expected=YYYY) +# или +Re-embed: found new mstsc window HWND=ZZZZ +RDP auto-recovered after reconnect +``` + +### Сценарии `try_reembed` +1. **Тот же HWND, другой parent** — mstsc reconnect сбросил parent на desktop → `_embed()` повторно +2. **Новый HWND** — mstsc создал новое окно → `EnumWindows` находит по PID или hostname в title + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| mstsc закрылся совсем | Сервер разорвал сессию | `is_alive()` вернёт False, `_on_rdp_exited()` покажет панель настроек | +| Повторное встраивание не сработало | Новое окно ещё не появилось | Мониторинг продолжит попытки каждые 500ms пока процесс жив | +| Зависание мониторинга | `after()` callback не вызвался | Проверить что GUI main loop не заблокирован | + +--- + +## Тест 4: Disconnect + +### Предусловия +- RDP-сессия активна + +### Шаги +1. Нажать красную кнопку **Disconnect** в тулбаре +2. Дождаться возврата на панель настроек + +### Ожидаемый результат +- Мониторинг останавливается (`_stop_monitor`) +- Если был fullscreen — сбрасывается флаг `_is_fullscreen` +- `EmbeddedRDP.disconnect()` вызывается: + - Все mstsc-процессы (parent + children) убиваются через psutil + - Основной процесс terminate → wait(3s) → kill + - Временный .rdp файл удаляется +- Тулбар и фрейм скрываются +- Панель настроек показывается +- Статус "Disconnected" (серый) +- Кнопка Connect разблокируется + +### Что проверить в логах +``` +EmbeddedRDP disconnected +``` + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| mstsc процесс остался висеть | psutil не нашёл children | Проверить Task Manager, вручную убить | +| Файл sm_embed_*.rdp не удалён | Файл заблокирован mstsc | Удалится при следующем подключении (перезапись) | + +--- + +## Тест 5: Переключение сервера при активном RDP + +### Предусловия +- RDP-сессия активна к серверу A +- В sidebar есть второй сервер B (любого типа) + +### Шаги +1. При активной RDP-сессии кликнуть на другой сервер в sidebar +2. Наблюдать за поведением + +### Ожидаемый результат +- `set_server()` вызывается с новым alias +- Текущая RDP-сессия автоматически отключается (`_disconnect()`) +- mstsc убивается, фрейм очищается +- Загружается информация о новом сервере +- Если новый сервер тоже RDP — показываются его настройки и кнопка Connect +- Если новый сервер другого типа — таб Launch может скрыться (зависит от TAB_REGISTRY) + +### Что проверить в логах +``` +EmbeddedRDP disconnected +# Далее — логи для нового сервера если подключиться +``` + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| Старая сессия не отключилась | `_disconnect()` не вызвался | Баг в `set_server()`, проверить что `self._embedded_rdp` был не None | +| Crash при быстром переключении | Race condition в мониторинге | `_stop_monitor()` должен отменить `after()` callback | + +--- + +## Тест 6: Resize окна при активной сессии + +### Предусловия +- RDP-сессия активна и встроена + +### Шаги +1. Изменить размер окна ServerManager (потянуть за край) +2. Быстро менять размер несколько раз подряд +3. Максимизировать окно ServerManager +4. Восстановить размер окна + +### Ожидаемый результат +- При изменении размера фрейма срабатывает `` event +- Debounce 100ms — `MoveWindow` вызывается не чаще раза в 100ms +- Окно mstsc масштабируется (`smart sizing:i:1` в .rdp) +- Содержимое RDP-сессии растягивается под новый размер + +### Что проверить в логах +- Обычно resize не логируется (слишком частая операция) +- Проверить что нет ошибок `Exception` в логах после resize + +### Частые проблемы +| Симптом | Причина | Решение | +|---------|---------|---------| +| Мерцание при быстром resize | Слишком частые `MoveWindow` | Debounce 100ms должен помогать, можно увеличить до 150-200ms | +| Чёрные полосы по краям | `MoveWindow` размеры не совпадают с фреймом | Проверить что `winfo_width/height` возвращает правильные значения | +| При fullscreen resize не работает | `_on_rdp_resize` выходит при `_is_fullscreen=True` | Это правильное поведение — в fullscreen mstsc сам управляет размером | + +--- + +## Автоматическое тестирование (auto_test подход) + +### Принцип работы + +Автоматический тест GUI использует Win32 API для программного управления окном ServerManager: +- Поиск окна SM по заголовку/классу через `EnumWindows` +- Клик по элементам интерфейса через `SendMessage` / `mouse_event` +- Скриншоты для верификации через `BitBlt` или PIL + +### Маппинг координат (DPI scaling) + +На системах с высоким DPI (2x масштабирование) необходимо учитывать разницу между логическими и физическими пикселями: + +```python +# Получение DPI scale factor +import ctypes +user32 = ctypes.windll.user32 +user32.SetProcessDPIAware() # Важно! Без этого координаты будут неверны + +# Логические координаты (возвращает tkinter/winfo): +# x=100, y=200 + +# Физические координаты (нужны для mouse_event/SetCursorPos): +# x=200, y=400 (при 2x scaling) + +# Формула: +dpi = user32.GetDpiForWindow(hwnd) # 192 для 2x +scale = dpi / 96 # 2.0 +phys_x = int(log_x * scale) +phys_y = int(log_y * scale) +``` + +На данной системе (Windows 10 Pro for Workstations) используется 2x логический масштаб. Все координаты для `SetCursorPos` / `mouse_event` нужно умножать на 2. + +### Поиск окна ServerManager + +```python +import ctypes +import ctypes.wintypes + +user32 = ctypes.windll.user32 +WNDENUMPROC = ctypes.WINFUNCTYPE( + ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM +) + +sm_hwnd = None + +def find_sm(hwnd, _): + global sm_hwnd + buf = ctypes.create_unicode_buffer(256) + user32.GetWindowTextW(hwnd, buf, 256) + if "ServerManager" in buf.value or "Server Manager" in buf.value: + sm_hwnd = hwnd + return False + return True + +user32.EnumWindows(WNDENUMPROC(find_sm), 0) +``` + +### Клик по элементам + +```python +import ctypes + +def click_at(x, y): + """Клик по абсолютным координатам экрана (физическим).""" + ctypes.windll.user32.SetCursorPos(x, y) + # Left button down + up + ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN + ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP + +def click_in_window(hwnd, rel_x, rel_y, scale=2.0): + """Клик относительно окна (логические координаты).""" + rect = ctypes.wintypes.RECT() + ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)) + abs_x = rect.left + int(rel_x * scale) + abs_y = rect.top + int(rel_y * scale) + click_at(abs_x, abs_y) +``` + +### Очистка (atexit + PowerShell wildcard kill) + +Автотесты должны гарантировать что после завершения (в т.ч. аварийного) не останется зависших процессов: + +```python +import atexit +import subprocess + +def cleanup(): + """Убить все связанные процессы при выходе.""" + # Убить все mstsc процессы, запущенные тестом + subprocess.run( + ["powershell", "-Command", + "Get-Process mstsc -ErrorAction SilentlyContinue | Stop-Process -Force"], + capture_output=True + ) + # Убить ServerManager если запущен тестом + subprocess.run( + ["powershell", "-Command", + "Get-Process -Name 'ServerManager*' -ErrorAction SilentlyContinue | Stop-Process -Force"], + capture_output=True + ) + # Удалить временные .rdp файлы + subprocess.run( + ["powershell", "-Command", + "Remove-Item $env:TEMP\\sm_embed_*.rdp -Force -ErrorAction SilentlyContinue"], + capture_output=True + ) + +atexit.register(cleanup) +``` + +PowerShell wildcard `ServerManager*` ловит и `ServerManager.exe`, и `ServerManager-vX.Y.Z-win-x64.exe`. + +### Структура автотеста + +```python +import time +import atexit +import subprocess + +atexit.register(cleanup) + +# 1. Запустить SM +proc = subprocess.Popen(["python", "main.py"]) +time.sleep(3) # Дождаться загрузки GUI + +# 2. Найти окно SM +find_sm_window() + +# 3. Кликнуть на RDP-сервер в sidebar +click_in_window(sm_hwnd, sidebar_x, server_y) +time.sleep(0.5) + +# 4. Переключиться на таб Launch +click_in_window(sm_hwnd, launch_tab_x, tab_y) +time.sleep(0.5) + +# 5. Нажать Connect +click_in_window(sm_hwnd, connect_btn_x, connect_btn_y) +time.sleep(5) # Дождаться embed + +# 6. Сделать скриншот для верификации +take_screenshot("test_rdp_connected.png") + +# 7. Нажать Disconnect +click_in_window(sm_hwnd, disconnect_btn_x, disconnect_btn_y) +time.sleep(1) + +# 8. Верификация +take_screenshot("test_rdp_disconnected.png") +``` + +### Верификация по скриншотам + +Скриншоты сохраняются в корень проекта для ручного визуального сравнения. Можно автоматизировать через PIL: + +```python +from PIL import Image + +def check_pixel_color(screenshot_path, x, y, expected_rgb, tolerance=30): + """Проверить цвет пикселя (для индикаторов статуса).""" + img = Image.open(screenshot_path) + actual = img.getpixel((x, y))[:3] + for a, e in zip(actual, expected_rgb): + if abs(a - e) > tolerance: + return False + return True + +# Пример: проверить что статус зелёный (Connected) +assert check_pixel_color("test_rdp_connected.png", status_x, status_y, (34, 197, 94)) +``` + +--- + +## Краткий чеклист + +| # | Тест | Критерий прохождения | +|---|------|---------------------| +| 1 | Connect + Embed | Окно mstsc внутри фрейма, статус зелёный | +| 2 | Fullscreen toggle | Detach на весь экран → reattach обратно без потери сессии | +| 3 | Reconnect recovery | После разрыва сети mstsc переподключается и автоматически re-embed | +| 4 | Disconnect | mstsc убит, .rdp удалён, UI вернулся к настройкам | +| 5 | Server switch | Старая сессия отключена, новый сервер готов к подключению | +| 6 | Resize | Окно mstsc масштабируется плавно, без артефактов | + +--- + +## Полезные команды для отладки + +```bash +# Посмотреть последние RDP-логи +tail -50 ~/.server-connections/app.log | grep -i "rdp\|mstsc\|embed" + +# Найти зависшие mstsc процессы +tasklist | findstr mstsc + +# Убить все mstsc +taskkill /f /im mstsc.exe + +# Проверить временные .rdp файлы +dir %TEMP%\sm_embed_*.rdp + +# Проверить реестр доверенных серверов +reg query "HKCU\Software\Microsoft\Terminal Server Client\Servers" + +# Проверить AuthenticationLevelOverride +reg query "HKCU\Software\Microsoft\Terminal Server Client" /v AuthenticationLevelOverride +``` diff --git a/releases/ServerManager-v1.8.31-win-x64.exe b/releases/ServerManager-v1.8.36-win-x64.exe similarity index 97% rename from releases/ServerManager-v1.8.31-win-x64.exe rename to releases/ServerManager-v1.8.36-win-x64.exe index 804843e..47456bb 100644 Binary files a/releases/ServerManager-v1.8.31-win-x64.exe and b/releases/ServerManager-v1.8.36-win-x64.exe differ diff --git a/version.py b/version.py index fd8d154..a7797a9 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.8.35" +__version__ = "1.8.36" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"