v1.8.36: fix fullscreen toggle — _is_detached flag prevents re-embed conflict

- Added _is_detached flag to EmbeddedRDP (set in detach(), cleared in reattach())
- _monitor_tick() skips try_reembed() when window is intentionally detached
- Prevents race condition: monitor tick no longer re-embeds fullscreen window
- Added plans/test-rdp.md — RDP testing documentation
- build.py auto-cleanup removed v1.8.31

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 12:59:16 -05:00
parent 68f2d7eae8
commit 142b68515c
5 changed files with 459 additions and 2 deletions

View File

@@ -243,6 +243,7 @@ class EmbeddedRDP:
self._rdp_file: str | None = None self._rdp_file: str | None = None
self._connected = False self._connected = False
self._parent_hwnd: int | None = None self._parent_hwnd: int | None = None
self._is_detached = False # True when intentionally fullscreen
# Callbacks (set by GUI) # Callbacks (set by GUI)
self.on_embedded = None # called when window is embedded self.on_embedded = None # called when window is embedded
@@ -612,6 +613,7 @@ class EmbeddedRDP:
"""Detach mstsc from parent — for fullscreen.""" """Detach mstsc from parent — for fullscreen."""
if not self._mstsc_hwnd or not self._connected: if not self._mstsc_hwnd or not self._connected:
return return
self._is_detached = True
try: try:
import ctypes import ctypes
import ctypes.wintypes import ctypes.wintypes
@@ -644,6 +646,7 @@ class EmbeddedRDP:
"""Re-embed mstsc back into the tkinter frame.""" """Re-embed mstsc back into the tkinter frame."""
if not self._mstsc_hwnd: if not self._mstsc_hwnd:
return return
self._is_detached = False
self._embed(parent_hwnd, width, height) self._embed(parent_hwnd, width, height)
log.info("mstsc reattached") log.info("mstsc reattached")

View File

@@ -384,7 +384,8 @@ class LaunchTab(ctk.CTkFrame):
self._on_rdp_exited() self._on_rdp_exited()
return 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 # HWND invalid or reparented — mstsc reconnected with new window
log.info("RDP window lost, attempting re-embed...") log.info("RDP window lost, attempting re-embed...")
self._rdp_frame.update_idletasks() self._rdp_frame.update_idletasks()

453
plans/test-rdp.md Normal file
View File

@@ -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. Восстановить размер окна
### Ожидаемый результат
- При изменении размера фрейма срабатывает `<Configure>` 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
```

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.8.35" __version__ = "1.8.36"
__app_name__ = "ServerManager" __app_name__ = "ServerManager"
__author__ = "aibot777" __author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers" __description__ = "Desktop GUI for managing remote servers"