From f152b48b94bff42d51f25b47557e433db63a95fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 08:09:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(rdp):=20wyb=C3=B3r=20rozdzielczo=C5=9Bci?= =?UTF-8?q?=20i=20skalowania=20(DPI)=20sesji=20w=20Ustawieniach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sesja RDP zawsze dostawała natywne piksele panelu przy skali 100/100, więc na ekranach 4K/HiDPI interfejs zdalnego pulpitu wychodził nieczytelny. Ustawienia → Połączenie → Domyślne RDP dostają dwa pokrętła: - Rozdzielczość sesji: „Dopasuj do okna" (jak dotąd) albo stała wartość z listy; stała jest rozciągana do okna SmartSizingiem i nie zmienia jej pełny ekran. - Skalowanie pulpitu zdalnego (DPI): „Jak ekran lokalny" (domyślnie, tak robi mstsc) albo 100–200%. Jedzie w ulDesktopScaleFactor, więc serwer RYSUJE interfejs większy — bez rozmycia. Gdy host odrzuci skalę, ponawiamy bez niej. Przeliczenia siedzą w Core/RdpDisplay (czyste, testowalne); zapis w Ustawieniach działa na żywo na otwartych sesjach — w kartach i w osobnych oknach. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G2BeQc7J3e3NosuC3vvoGi --- README.md | 6 +- docs/ROADMAP.md | 3 +- src/RdpManager/AppSettings.cs | 11 ++ src/RdpManager/Controllers/SessionManager.cs | 5 +- src/RdpManager/Core/RdpDisplay.cs | 86 +++++++++ src/RdpManager/MainWindow.xaml | 39 ++++ src/RdpManager/MainWindow.xaml.cs | 23 +++ src/RdpManager/RdpConnect.cs | 4 +- src/RdpManager/RdpDynamicResolution.cs | 180 +++++++++++++++++-- src/RdpManager/SessionWindow.xaml.cs | 7 +- src/RdpManager/Themes/Strings.en.xaml | 6 + src/RdpManager/Themes/Strings.pl.xaml | 6 + tests/RdpManager.Tests/RdpDisplayTests.cs | 108 +++++++++++ 13 files changed, 460 insertions(+), 24 deletions(-) create mode 100644 src/RdpManager/Core/RdpDisplay.cs create mode 100644 tests/RdpManager.Tests/RdpDisplayTests.cs diff --git a/README.md b/README.md index 1278a8c..79dff6d 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ it's a self-contained single file, no .NET install required (Windows 10/11 x64). ### Protocols, one tree - **RDP** — Microsoft's official ActiveX control (`mstscax`): dynamic resolution that follows the - window/full-screen size (falls back to smart-sizing on older hosts), clipboard/drive/printer/audio - redirection, server-identity verification level, RD Gateway / jump-host and admin session. + window/full-screen size (falls back to smart-sizing on older hosts) or a **fixed resolution** of your + choice, **remote desktop scaling (DPI)** so text stays readable on 4K/HiDPI screens + (*Settings → Connection*), clipboard/drive/printer/audio redirection, server-identity verification + level, RD Gateway / jump-host and admin session. - **SSH** — embedded **xterm.js** terminal: password and private-key auth (including **passphrase-protected keys**), host-key verification (trust-on-first-use with fingerprint prompt), local **port-forwarding tunnels**, copy-on-select, Ctrl+Shift+C/V, Ctrl+wheel font size. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8329209..0ab629c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,8 @@ current priorities. - ✅ **Focus mode** — maximized main window hides all chrome except the tab strip; window controls move onto the strip. Distinct from true full screen (F11). - ✅ **Full-screen** with auto-hiding, draggable, pinnable bar + "other connections" flyout. -- ✅ **Dynamic resolution** — session = window/monitor physical pixels, DPI-safe. +- ✅ **Dynamic resolution** — session = window/monitor physical pixels, DPI-safe; optionally a + fixed resolution, plus remote desktop scaling (DPI) for readable text on 4K/HiDPI screens. - ✅ **Credentials in Windows Credential Manager** (DPAPI); prompt at connect; "connect as". - ✅ **Server tree** — groups, favorites (pinned), collapsible groups, one-click group rename, search, drag-and-drop reorder, background reachability dots. diff --git a/src/RdpManager/AppSettings.cs b/src/RdpManager/AppSettings.cs index b2f7d9c..942b795 100644 --- a/src/RdpManager/AppSettings.cs +++ b/src/RdpManager/AppSettings.cs @@ -49,6 +49,17 @@ public class AppSettings /// Domyślne ustawienia połączeń RDP. public bool AutoReconnect { get; set; } = true; public int ColorDepth { get; set; } = 32; + + /// Rozdzielczość sesji RDP: 0×0 (domyślnie) = „dopasuj do okna", czyli sesja trzyma natywne + /// piksele panelu (obraz 1:1). Wartość stała = pulpit negocjuje ten rozmiar, a SmartSizing rozciąga go + /// do okna. Wspólne dla wszystkich serwerów RDP. Przeliczenia: . + public int RdpDesktopWidth { get; set; } + public int RdpDesktopHeight { get; set; } + + /// Skalowanie (DPI) pulpitu zdalnego w procentach: 0 (domyślnie) = „jak ekran lokalny", + /// inaczej 100..500. Powiększa tekst i ikony W SESJI, bez zmiany rozdzielczości i bez rozmycia — + /// na ekranach 4K/HiDPI sesja 1:1 przy 100% daje interfejs nie do odczytania. + public int RdpScalePercent { get; set; } // Domyślne przekierowania dla NOWO tworzonych serwerów RDP (istniejące zachowują swoje). public bool DefaultRedirectClipboard { get; set; } = true; public bool DefaultRedirectDrives { get; set; } = false; // dyski + drukarki diff --git a/src/RdpManager/Controllers/SessionManager.cs b/src/RdpManager/Controllers/SessionManager.cs index 959fbb3..ecb6b43 100644 --- a/src/RdpManager/Controllers/SessionManager.cs +++ b/src/RdpManager/Controllers/SessionManager.cs @@ -180,7 +180,7 @@ internal void OpenServer(ServerInfo server, bool autoConnect = false, bool force try { ((System.Windows.Forms.Control)rdp).CreateControl(); } catch { } // wymuś utworzenie kontrolki ActiveX session = new Session(server, rdp, host); - session.Resizer = new RdpDynamicResolution(session, host); + session.Resizer = new RdpDynamicResolution(session, host, _owner._settings); WireEvents(session); // W podziale: klik w panel (RDP przejmuje fokus klawiatury) czyni go aktywnym — karta i toolbar podążają. var focusTarget = session; @@ -580,6 +580,9 @@ private void ConnectSession(Session s) } catch { /* starsza kontrolka bez RemoteProgram2 — łączymy jako pełny pulpit */ } + // Rozdzielczość + skala DPI z ustawień — żeby pierwsza klatka sesji była już poprawna. + s.Resizer?.ApplyPreConnect(); + s.Rdp.Connect(); SetSessionStatus(s, string.Format(L("S.st.connecting"), s.Server.Host), StatusKind.Connecting); } diff --git a/src/RdpManager/Core/RdpDisplay.cs b/src/RdpManager/Core/RdpDisplay.cs new file mode 100644 index 0000000..4cb2ef5 --- /dev/null +++ b/src/RdpManager/Core/RdpDisplay.cs @@ -0,0 +1,86 @@ +using System; +using System.Globalization; + +namespace RdpManager.Core +{ + /// + /// Czyste przeliczenia rozdzielczości i skalowania (DPI) sesji RDP — bez WPF/ActiveX, więc testowalne. + /// Na kontrolce stosuje je RdpDynamicResolution. + /// + /// Dwa niezależne pokrętła (Ustawienia → Połączenie → Domyślne RDP): + /// - ROZDZIELCZOŚĆ: 0×0 = „dopasuj do okna" (domyślne — sesja trzyma natywne piksele panelu, obraz 1:1), + /// wartość stała (np. 1600×900) = sesja negocjuje ten rozmiar, a SmartSizing rozciąga go do okna. + /// - SKALOWANIE (DPI): 0 = „jak ekran lokalny" (mnożnik DPI okna — tak robi mstsc), inaczej stałe procenty. + /// Trafia do ulDesktopScaleFactor (UpdateSessionDisplaySettings) i „DesktopScaleFactor" przed Connect, + /// więc zdalny pulpit RYSUJE interfejs większy — bez utraty ostrości (odwrotnie niż SmartSizing). + /// Na ekranie 4K/HiDPI sesja 1:1 przy 100% daje mikroskopijny tekst; to jest na to lekarstwo. + /// + public static class RdpDisplay + { + // Zakresy z dokumentacji IMsRdpClient9::UpdateSessionDisplaySettings: ulDesktopScaleFactor 100..500, + // ulDeviceScaleFactor TYLKO 100/140/180 (inna wartość => E_INVALIDARG, cała zmiana odrzucona). + // Device trzymamy na 100 („bez dodatkowego skalowania urządzenia") — DPI sesji ustala desktopScaleFactor. + public const int MinScale = 100; + public const int MaxScale = 500; + public const uint DeviceScaleFactor = 100u; + + /// Czy ustawienie opisuje STAŁĄ rozdzielczość (a nie „dopasuj do okna"). + public static bool IsFixed(int width, int height) + => width >= RdpUtils.MinDim && height >= RdpUtils.MinDim; + + /// + /// Sprowadza zapisaną parę do postaci używalnej przez kontrolkę: (0, 0) = „dopasuj do okna", + /// inaczej wymiary znormalizowane jak przy Display-Update (parzyste, [200..8192]). + /// Wartości poniżej minimum traktujemy jak brak wyboru — plik settings.json bywa edytowany ręcznie. + /// + public static (int Width, int Height) Normalize(int width, int height) + => IsFixed(width, height) + ? (RdpUtils.NormalizeDim(width), RdpUtils.NormalizeDim(height)) + : (0, 0); + + /// „1920x1080" (albo „1920×1080") → (1920, 1080); „", „Auto", śmieci → (0, 0) = dopasuj do okna. + public static (int Width, int Height) ParseResolution(string tag) + { + string s = (tag ?? "").Trim(); + if (s.Length == 0) return (0, 0); + + int sep = s.IndexOfAny(new[] { 'x', 'X', '×' }); + if (sep <= 0 || sep == s.Length - 1) return (0, 0); + + if (!int.TryParse(s.Substring(0, sep).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var w)) return (0, 0); + if (!int.TryParse(s.Substring(sep + 1).Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var h)) return (0, 0); + return Normalize(w, h); + } + + /// Odwrotność — Tag pozycji listy dla zapisanej wartości („" = auto). + public static string FormatResolution(int width, int height) + { + var (w, h) = Normalize(width, height); + return w == 0 ? "" : w.ToString(CultureInfo.InvariantCulture) + "x" + h.ToString(CultureInfo.InvariantCulture); + } + + /// Tag pozycji listy skalowania → wartość do zapisu: „" / „Auto" / śmieci = 0 („jak ekran lokalny"). + public static int ParseScale(string tag) + { + string s = (tag ?? "").Trim().TrimEnd('%').Trim(); + if (s.Length == 0) return 0; + if (!int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pct) || pct <= 0) return 0; + return Math.Clamp(pct, MinScale, MaxScale); + } + + /// + /// Skala DPI wysyłana serwerowi. > 0 = wybór użytkownika (przycięty do + /// [100..500]); 0 = „jak ekran lokalny", czyli mnożnik DPI okna (1.5 → 150%). Zaokrąglamy do 5%, bo + /// mnożnik z VisualTreeHelper.GetDpi bywa niecałkowity (np. 1.4999), a serwer i tak operuje + /// na okrągłych krokach DPI. + /// + public static int EffectiveScale(int configured, double localDpiScale) + { + if (configured > 0) return Math.Clamp(configured, MinScale, MaxScale); + if (double.IsNaN(localDpiScale) || double.IsInfinity(localDpiScale) || localDpiScale <= 0) return MinScale; + + int pct = (int)Math.Round(localDpiScale * 100.0 / 5.0, MidpointRounding.AwayFromZero) * 5; + return Math.Clamp(pct, MinScale, MaxScale); + } + } +} diff --git a/src/RdpManager/MainWindow.xaml b/src/RdpManager/MainWindow.xaml index e6b48f5..53cb0b8 100644 --- a/src/RdpManager/MainWindow.xaml +++ b/src/RdpManager/MainWindow.xaml @@ -820,6 +820,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RdpManager/MainWindow.xaml.cs b/src/RdpManager/MainWindow.xaml.cs index bd63fec..dd91715 100644 --- a/src/RdpManager/MainWindow.xaml.cs +++ b/src/RdpManager/MainWindow.xaml.cs @@ -977,6 +977,8 @@ private void LoadSettingsForm() BuildThemePresets(); BuildAccentSwatches(); SetDefaultPort.Text = _settings.DefaultPort.ToString(); + ComboSelectTag(SetRdpResolution, RdpDisplay.FormatResolution(_settings.RdpDesktopWidth, _settings.RdpDesktopHeight)); + ComboSelectTag(SetRdpScale, _settings.RdpScalePercent > 0 ? _settings.RdpScalePercent.ToString() : ""); SelectColorDepthSeg(_settings.ColorDepth); SetRedirClip.IsChecked = _settings.DefaultRedirectClipboard; SetRedirDrives.IsChecked = _settings.DefaultRedirectDrives; @@ -1228,6 +1230,10 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) _settings.TerminalFontSize = int.TryParse(SetTermFontSize.Text.Trim(), out var tfs) ? Math.Clamp(tfs, 8, 24) : 14; _settings.DefaultPort = int.TryParse(SetDefaultPort.Text.Trim(), out var p) ? Math.Clamp(p, 1, 65535) : 3389; _settings.ColorDepth = ParseColorDepth(); + var (rdpW, rdpH) = RdpDisplay.ParseResolution(ComboTag(SetRdpResolution)); + _settings.RdpDesktopWidth = rdpW; + _settings.RdpDesktopHeight = rdpH; + _settings.RdpScalePercent = RdpDisplay.ParseScale(ComboTag(SetRdpScale)); _settings.DefaultRedirectClipboard = SetRedirClip.IsChecked == true; _settings.DefaultRedirectDrives = SetRedirDrives.IsChecked == true; _settings.ProbeTimeoutSeconds = int.TryParse(SetProbeTimeout.Text.Trim(), out var pt) ? Math.Clamp(pt, 1, 60) : 2; @@ -1261,6 +1267,18 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e) private int ParseColorDepth() => RdpUtils.ParseColorDepth(SegTag(ColorDepthSeg)); + // Listy rozwijane z wartością w Tagu (rozdzielczość i skalowanie RDP) — odpowiednik SegTag/SegSet + // dla ComboBoxa. Brak dopasowania → pierwsza pozycja (u nas: „auto"). + private static string ComboTag(ComboBox combo) => (combo?.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; + + private static void ComboSelectTag(ComboBox combo, string tag) + { + if (combo == null) return; + foreach (var item in combo.Items.OfType()) + if ((item.Tag as string ?? "") == (tag ?? "")) { combo.SelectedItem = item; return; } + combo.SelectedIndex = 0; + } + // Zaznacza segment głębi kolorów (16/24/32) wg wartości z ustawień. private void SelectColorDepthSeg(int depth) { @@ -1277,6 +1295,11 @@ private void ApplySettings() _fs.ApplyBarDelay(); // opóźnienie paska pełnoekranowego + peeku wg ustawień (PR 5) _reach?.ApplySettings(); // limit czasu sondy + interwał + wł/wył cyklu wg ustawień + // Rozdzielczość / skalowanie RDP: renegocjuj w już otwartych sesjach (karty i osobne okna), + // żeby zmiana działała od razu, bez ponownego łączenia. + foreach (var s in _sessions) s.Resizer?.ApplyDisplaySettings(); + foreach (var w in _sessionWindows) w.ApplyDisplaySettings(); + // Cykliczne sprawdzanie aktualizacji wg tego samego przełącznika co start. _update?.ApplyCheckUpdatesSetting(); diff --git a/src/RdpManager/RdpConnect.cs b/src/RdpManager/RdpConnect.cs index 27c0902..7328ac3 100644 --- a/src/RdpManager/RdpConnect.cs +++ b/src/RdpManager/RdpConnect.cs @@ -23,7 +23,9 @@ public static void Apply(AxMsRdpClient11NotSafeForScripting rdp, ServerInfo serv adv.AuthenticationLevel = RdpConfigurator.ClampAuthLevel(server.AuthenticationLevel); adv.EnableCredSspSupport = true; adv.ConnectToAdministerServer = server.AdminSession; // sesja konsolowa (mstsc /admin) - adv.SmartSizing = false; // dynamiczna rozdzielczość zajmie się dopasowaniem + // Wartość wyjściowa; ostateczną ustala RdpDynamicResolution.ApplyPreConnect wg ustawień + // (stała rozdzielczość => SmartSizing, „dopasuj do okna" => dynamiczna rozdzielczość). + adv.SmartSizing = false; adv.EnableAutoReconnect = settings.AutoReconnect; rdp.ColorDepth = settings.ColorDepth; adv.RedirectClipboard = server.RedirectClipboard; diff --git a/src/RdpManager/RdpDynamicResolution.cs b/src/RdpManager/RdpDynamicResolution.cs index 296b1f3..2af6d2f 100644 --- a/src/RdpManager/RdpDynamicResolution.cs +++ b/src/RdpManager/RdpDynamicResolution.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Forms.Integration; @@ -16,10 +17,17 @@ namespace RdpManager /// /// Kluczowe (z adwersaryjnej weryfikacji): /// - WindowsFormsHost raportuje rozmiar w DIP (96) — trzeba przeliczyć na piksele - /// fizyczne przez VisualTreeHelper.GetDpi i wtedy skala serwera = 100/100. + /// fizyczne przez VisualTreeHelper.GetDpi. /// - rdp.Connected to Int16 (VARIANT_BOOL): porównujemy == 1. /// - wymiary parzyste, zakres [200..8192]; SmartSizing=false na happy-path, /// włączany tylko jako fallback przy COMException (stare hosty). + /// + /// Ustawienia użytkownika ( / , + /// przeliczane przez ) modyfikują oba wymiary tego zachowania: + /// - stała rozdzielczość → nie mierzymy panelu, wysyłamy wybrany rozmiar i włączamy SmartSizing + /// (pulpit i tak wypełnia okno, kosztem skalowania); + /// - skala DPI → jedzie w ulDesktopScaleFactor, więc zdalny pulpit rysuje UI większe zamiast + /// upychać je w natywnych pikselach 4K (to ratunek na „nic nie da się odczytać"). /// public sealed class RdpDynamicResolution : IDisposable { @@ -33,16 +41,21 @@ public sealed class RdpDynamicResolution : IDisposable private readonly Session _session; private readonly WindowsFormsHost _host; + private readonly AppSettings _settings; private readonly DispatcherTimer _debounce; private int _lastW = -1, _lastH = -1; private int _retries; private bool _disposed; - public RdpDynamicResolution(Session session, WindowsFormsHost host) + /// Ustawienia aplikacji (rozdzielczość + skala DPI). Null = domyślne + /// „dopasuj do okna" / „jak ekran lokalny" — czytamy je przy każdym użyciu, więc zmiana + /// w Ustawieniach działa na żywo (patrz ). + public RdpDynamicResolution(Session session, WindowsFormsHost host, AppSettings settings) { _session = session ?? throw new ArgumentNullException(nameof(session)); _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings; _debounce = new DispatcherTimer(DispatcherPriority.Normal, _host.Dispatcher) { @@ -59,6 +72,25 @@ public RdpDynamicResolution(Session session, WindowsFormsHost host) /// Wołać z OnLoginComplete/OnConnected — pierwszy legalny moment na resize. public void ApplyInitial() => Kick(); + /// Stała rozdzielczość z ustawień, albo (0, 0) gdy „dopasuj do okna" (domyślnie). + private (int W, int H) FixedRes() + { + if (_settings == null) return (0, 0); + var (w, h) = RdpDisplay.Normalize(_settings.RdpDesktopWidth, _settings.RdpDesktopHeight); + return (w, h); + } + + /// Skala DPI dla serwera: wybór użytkownika, a przy „auto" mnożnik DPI okna (jak mstsc). + private uint EffectiveScale() + { + double dpi = TryGetDpiScale(out var sx, out _) ? sx : 1.0; + return (uint)RdpDisplay.EffectiveScale(_settings?.RdpScalePercent ?? 0, dpi); + } + + /// Przy stałej rozdzielczości SmartSizing musi zostać włączony niezależnie od trybu podziału — + /// inaczej wybrany pulpit siedziałby w letterboxie zamiast wypełniać okno. + private bool WantSmartSizing(bool hasFixedRes) => _fit || hasFixedRes; + private bool _fit; /// „Dopasuj do okna": host skaluje pulpit do swojego rozmiaru (SmartSizing), więc pulpit zawsze /// się mieści — także gdy serwer nie renegocjuje rozdzielczości (wąski panel podziału ekranu). Gdy @@ -71,21 +103,69 @@ public bool FitToWindow { if (_fit == value) return; _fit = value; - TrySetSmartSizing(_session.Rdp, value); // zastosuj od razu - if (!value) Kick(); // powrót do natywnej rozdzielczości (re-negocjacja) + bool hasFixed = FixedRes().W > 0; + TrySetSmartSizing(_session.Rdp, WantSmartSizing(hasFixed)); // zastosuj od razu + // Powrót do natywnej rozdzielczości (re-negocjacja) — przy stałej nie ma czego negocjować. + if (!value && !hasFixed) Kick(); } } + /// + /// Wołać PRZED Connect(): rozmiar pulpitu i skala DPI dla PIERWSZEJ klatki sesji. Bez tego + /// wybrana rozdzielczość/skala pojawiałaby się dopiero po pierwszym Display-Update (po zalogowaniu), + /// a sesja startowałaby z rozmiarem domyślnym kontrolki. Kasuje też cache ostatnich wymiarów — + /// nowe połączenie nie ma jeszcze nic wynegocjowanego. + /// + public void ApplyPreConnect() + { + if (_disposed) return; + var rdp = _session.Rdp; + if (rdp == null) return; + + _lastW = _lastH = -1; + _debounce.Stop(); + + var fx = FixedRes(); + if (fx.W > 0) + { + // Tylko dla stałej rozdzielczości. W trybie „dopasuj do okna" zostawiamy domyślny rozmiar + // kontrolki i renegocjujemy po zalogowaniu (ApplyInitial) — tak działało to do tej pory. + try { rdp.DesktopWidth = fx.W; rdp.DesktopHeight = fx.H; } + catch (COMException) { } + catch (InvalidComObjectException) { } + } + + TrySetSmartSizing(rdp, WantSmartSizing(fx.W > 0)); + TrySetPreConnectScale(rdp, EffectiveScale()); + } + + /// + /// Ustawienia rozdzielczości/skali zmieniły się w trakcie sesji (zapis w Ustawieniach) — zastosuj bez + /// ponownego łączenia. Cache _lastW/_lastH musi polecieć, inaczej ścieżka resize uznałaby, + /// że nie ma czego zmieniać (wymiary te same, zmieniła się tylko skala albo tryb). + /// + public void ApplyDisplaySettings() + { + if (_disposed) return; + _lastW = _lastH = -1; + TrySetSmartSizing(_session.Rdp, WantSmartSizing(FixedRes().W > 0)); + Kick(); + } + /// /// Ustawia rozdzielczość sesji DOKŁADNIE na podane piksele fizyczne, z pominięciem pomiaru /// hosta (DIP×DPI). Używane przy wejściu w pełny ekran, gdzie znamy natywny rozmiar monitora /// z GetMonitorInfo — bez tego przeliczanie DIP→piksele bywa błędne, gdy DPI monitora docelowego /// dochodzi z opóźnieniem (rozjazd skalowania po przeniesieniu na inny ekran). + /// Przy stałej rozdzielczości z ustawień pełny ekran jej NIE zmienia — rozciąga ją SmartSizingiem. /// public void ApplyExact(int physW, int physH) { if (_disposed) return; + var fx = FixedRes(); + if (fx.W > 0) { physW = fx.W; physH = fx.H; } + physW = RdpUtils.NormalizeDim(physW); physH = RdpUtils.NormalizeDim(physH); if (physW < MinDim || physH < MinDim) return; @@ -102,8 +182,8 @@ public void ApplyExact(int physW, int physH) _debounce.Stop(); // ubijemy ewentualny wyścig ze ścieżką SizeChanged try { - rdp.UpdateSessionDisplaySettings((uint)physW, (uint)physH, 0u, 0u, 0u, 100u, 100u); - TrySetSmartSizing(rdp, false); + UpdateDisplay(rdp, physW, physH); + TrySetSmartSizing(rdp, WantSmartSizing(fx.W > 0)); _lastW = physW; _lastH = physH; } catch (COMException) { TrySetSmartSizing(rdp, true); } @@ -136,18 +216,27 @@ private void OnDebounceTick(object sender, EventArgs e) catch (COMException) { return; } if (!live) return; - if (!TryGetPhysicalPixels(out int w, out int h)) return; - - w = RdpUtils.NormalizeDim(w); - h = RdpUtils.NormalizeDim(h); + int w, h; + var fx = FixedRes(); + if (fx.W > 0) + { + w = fx.W; h = fx.H; // stała rozdzielczość: nie mierzymy panelu, tylko wysyłamy wybraną + } + else + { + if (!TryGetPhysicalPixels(out w, out h)) return; + w = RdpUtils.NormalizeDim(w); + h = RdpUtils.NormalizeDim(h); + } if (w < MinDim || h < MinDim) return; if (w == _lastW && h == _lastH) return; try { - // Piksele fizyczne "wbite" w rozdzielczość => serwer bez dodatkowego skalowania (100/100). - rdp.UpdateSessionDisplaySettings((uint)w, (uint)h, 0u, 0u, 0u, 100u, 100u); - TrySetSmartSizing(rdp, _fit); // natywny (ostry); w „dopasuj do okna" SmartSizing dobija resztę + // Piksele fizyczne "wbite" w rozdzielczość => serwer nie skaluje bitmapy; czytelność + // reguluje osobno skala DPI (desktopScaleFactor), nie rozmycie. + UpdateDisplay(rdp, w, h); + TrySetSmartSizing(rdp, WantSmartSizing(fx.W > 0)); // natywny (ostry) albo dobicie do okna _lastW = w; _lastH = h; _retries = 0; } @@ -173,13 +262,46 @@ private void OnDebounceTick(object sender, EventArgs e) } } + /// + /// Jedno miejsce na Display-Update: wymiary + skala DPI z ustawień. Gdy host odrzuci skalę + /// (starsze serwery znają tylko 100/100), ponawiamy bez niej — resize jest ważniejszy niż DPI, + /// a wołający i tak potraktowałby COMException jako „kanał jeszcze nie wstał" i włączył SmartSizing. + /// + private void UpdateDisplay(AxMsRdpClient11NotSafeForScripting rdp, int w, int h) + { + uint scale = EffectiveScale(); + if (scale == 100u) + { + rdp.UpdateSessionDisplaySettings((uint)w, (uint)h, 0u, 0u, 0u, 100u, RdpDisplay.DeviceScaleFactor); + return; + } + try + { + rdp.UpdateSessionDisplaySettings((uint)w, (uint)h, 0u, 0u, 0u, scale, RdpDisplay.DeviceScaleFactor); + } + catch (COMException) + { + rdp.UpdateSessionDisplaySettings((uint)w, (uint)h, 0u, 0u, 0u, 100u, RdpDisplay.DeviceScaleFactor); + } + } + private bool TryGetPhysicalPixels(out int w, out int h) { w = h = 0; double dipW = _host.ActualWidth, dipH = _host.ActualHeight; if (dipW <= 0 || dipH <= 0) return false; + if (!TryGetDpiScale(out double sx, out double sy)) return false; + + w = (int)Math.Round(dipW * sx); + h = (int)Math.Round(dipH * sy); + return true; + } - double sx, sy; + /// Mnożnik DPI okna (1.5 = 150%). Potrzebny i do przeliczenia DIP→piksele, i do skali + /// „jak ekran lokalny". + private bool TryGetDpiScale(out double sx, out double sy) + { + sx = sy = 0; try { DpiScale dpi = VisualTreeHelper.GetDpi(_host); @@ -192,15 +314,37 @@ private bool TryGetPhysicalPixels(out int w, out int h) var m = src.CompositionTarget.TransformToDevice; sx = m.M11; sy = m.M22; } - if (sx <= 0 || sy <= 0) return false; + return sx > 0 && sy > 0; + } - w = (int)Math.Round(dipW * sx); - h = (int)Math.Round(dipH * sy); - return true; + /// + /// Skala DPI PRZED połączeniem — kontrolka czyta ją z IMsRdpExtendedSettings.set_Property pod + /// tymi samymi nazwami, co klucze desktopscalefactor/devicescalefactor w plikach .rdp + /// mstsc. Po zalogowaniu DPI zmienia już Display-Update; tu chodzi o pierwszą klatkę sesji. + /// + /// Wołamy późno-wiążąco (IDispatch), a nie przez rzutowanie na interfejs interopu: w wygenerowanym + /// MSTSCLib para get_Property/set_Property wychodzi jako akcesory WŁAŚCIWOŚCI, a tych C# nie pozwala + /// wywołać wprost (CS0571). Jedno wywołanie na połączenie, więc koszt refleksji bez znaczenia. + /// + private static void TrySetPreConnectScale(AxMsRdpClient11NotSafeForScripting rdp, uint scale) + { + try + { + object ocx = rdp.GetOcx(); + if (ocx == null) return; + SetExtendedProperty(ocx, "DesktopScaleFactor", scale); + SetExtendedProperty(ocx, "DeviceScaleFactor", RdpDisplay.DeviceScaleFactor); + } + catch (Exception) { /* kontrolka bez rozszerzonych ustawień — DPI dobije Display-Update */ } } + private static void SetExtendedProperty(object ocx, string name, uint value) + => ocx.GetType().InvokeMember("set_Property", BindingFlags.InvokeMethod, null, ocx, + new object[] { name, value }); + private static void TrySetSmartSizing(AxMsRdpClient11NotSafeForScripting rdp, bool on) { + if (rdp == null) return; try { rdp.AdvancedSettings9.SmartSizing = on; } catch (COMException) { } catch (InvalidComObjectException) { } diff --git a/src/RdpManager/SessionWindow.xaml.cs b/src/RdpManager/SessionWindow.xaml.cs index e115186..bd195f5 100644 --- a/src/RdpManager/SessionWindow.xaml.cs +++ b/src/RdpManager/SessionWindow.xaml.cs @@ -38,6 +38,10 @@ public partial class SessionWindow /// Czy sesja jest aktualnie połączona (do potwierdzenia zamknięcia aplikacji). public bool IsConnected => _session != null && _session.Connected; + /// Zmiana rozdzielczości/skalowania w Ustawieniach managera dotyczy też sesji w tym oknie + /// (AppSettings to ten sam obiekt) — wystarczy poprosić o renegocjację, bez ponownego łączenia. + public void ApplyDisplaySettings() => _resizer?.ApplyDisplaySettings(); + // Pełny ekran (bezramkowy na bieżącym monitorze) + auto-chowany pasek. private bool _fs; private bool _fsPinned; // pasek „przypięty" — nie chowa się automatycznie @@ -72,7 +76,7 @@ public SessionWindow(ServerInfo server, AppSettings settings, string password, s try { ((System.Windows.Forms.Control)_rdp).CreateControl(); } catch { } _session = new Session(server, _rdp, _host) { Password = _password }; - _resizer = new RdpDynamicResolution(_session, _host); + _resizer = new RdpDynamicResolution(_session, _host, settings); WireEvents(); _fsDelay = new DispatcherTimer(DispatcherPriority.Normal, Dispatcher) @@ -134,6 +138,7 @@ private void Connect() try { RdpConnect.Apply(_rdp, _server, _settings, _username, _domain, _password); + _resizer?.ApplyPreConnect(); // rozdzielczość + skala DPI z ustawień już na pierwszą klatkę _rdp.Connect(); SetStatus(string.Format(L("S.st.connecting"), _server.Host), StatusKind.Connecting, false); } diff --git a/src/RdpManager/Themes/Strings.en.xaml b/src/RdpManager/Themes/Strings.en.xaml index abeaba1..692cae0 100644 --- a/src/RdpManager/Themes/Strings.en.xaml +++ b/src/RdpManager/Themes/Strings.en.xaml @@ -107,6 +107,12 @@ Connection Default RDP port Default color depth (bit) + RDP session resolution + “Fit to window” keeps the resolution equal to the window (crisp 1:1 image). A fixed value is stretched to the window. + Fit to window + Remote desktop scaling (DPI) + Enlarges text and icons inside the session without blurring. Helps on 4K/HiDPI displays; requires Windows 10 / Server 2016 or newer. + Match local display Automatically resume dropped connections Behavior Check server reachability in the background diff --git a/src/RdpManager/Themes/Strings.pl.xaml b/src/RdpManager/Themes/Strings.pl.xaml index 2b2bdc0..5f7b6a2 100644 --- a/src/RdpManager/Themes/Strings.pl.xaml +++ b/src/RdpManager/Themes/Strings.pl.xaml @@ -107,6 +107,12 @@ Połączenie Domyślny port RDP Domyślna głębia kolorów (bit) + Rozdzielczość sesji RDP + „Dopasuj do okna" trzyma rozdzielczość równą oknu (ostry obraz 1:1). Stała wartość jest rozciągana do okna. + Dopasuj do okna + Skalowanie pulpitu zdalnego (DPI) + Powiększa tekst i ikony w sesji bez rozmycia. Pomaga na ekranach 4K/HiDPI; wymaga serwera Windows 10 / Server 2016 lub nowszego. + Jak ekran lokalny Automatycznie wznawiaj zerwane połączenie Zachowanie Sprawdzaj osiągalność serwerów w tle diff --git a/tests/RdpManager.Tests/RdpDisplayTests.cs b/tests/RdpManager.Tests/RdpDisplayTests.cs new file mode 100644 index 0000000..bbf9ab9 --- /dev/null +++ b/tests/RdpManager.Tests/RdpDisplayTests.cs @@ -0,0 +1,108 @@ +using RdpManager.Core; +using Xunit; + +namespace RdpManager.Tests +{ + public class RdpDisplayTests + { + // ---------- rozdzielczość ---------- + + [Theory] + [InlineData("1920x1080", 1920, 1080)] + [InlineData("1920X1080", 1920, 1080)] + [InlineData("1920×1080", 1920, 1080)] // znak „×" z etykiety w UI + [InlineData(" 1280 x 720 ", 1280, 720)] + public void ParseResolution_ReadsFixedSize(string tag, int w, int h) + => Assert.Equal((w, h), RdpDisplay.ParseResolution(tag)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Auto")] + [InlineData("1920")] + [InlineData("1920x")] + [InlineData("x1080")] + [InlineData("axb")] + public void ParseResolution_UnknownMeansFitToWindow(string tag) + => Assert.Equal((0, 0), RdpDisplay.ParseResolution(tag)); + + [Fact] + public void ParseResolution_NormalizesToEvenAndRange() + { + Assert.Equal((1280, 720), RdpDisplay.ParseResolution("1281x721")); // nieparzyste → parzyste + Assert.Equal((RdpUtils.MaxDim, RdpUtils.MaxDim), RdpDisplay.ParseResolution("99999x99999")); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(-1, -1)] + [InlineData(100, 100)] // poniżej MinDim (200) — traktujemy jak brak wyboru + [InlineData(1920, 100)] // jeden wymiar za mały → też brak wyboru + public void Normalize_BelowMinimumMeansFitToWindow(int w, int h) + { + Assert.Equal((0, 0), RdpDisplay.Normalize(w, h)); + Assert.False(RdpDisplay.IsFixed(w, h)); + Assert.Equal("", RdpDisplay.FormatResolution(w, h)); + } + + [Fact] + public void FormatResolution_RoundTripsWithParse() + { + string tag = RdpDisplay.FormatResolution(1600, 900); + Assert.Equal("1600x900", tag); + Assert.Equal((1600, 900), RdpDisplay.ParseResolution(tag)); + } + + // ---------- skalowanie (DPI) ---------- + + [Theory] + [InlineData(null, 0)] + [InlineData("", 0)] + [InlineData("Auto", 0)] + [InlineData("0", 0)] + [InlineData("-50", 0)] + [InlineData("100", 100)] + [InlineData("150", 150)] + [InlineData("175%", 175)] // tolerujemy „%" z etykiety + [InlineData("9000", 500)] // powyżej MaxScale + [InlineData("50", 100)] // poniżej MinScale + public void ParseScale_ReadsPercentOrAuto(string tag, int expected) + => Assert.Equal(expected, RdpDisplay.ParseScale(tag)); + + [Theory] + [InlineData(125, 125)] + [InlineData(200, 200)] + [InlineData(600, 500)] // przycięcie do MaxScale + [InlineData(10, 100)] // przycięcie do MinScale + public void EffectiveScale_ConfiguredWins(int configured, int expected) + => Assert.Equal(expected, RdpDisplay.EffectiveScale(configured, 1.0)); + + [Theory] + [InlineData(1.0, 100)] + [InlineData(1.25, 125)] + [InlineData(1.5, 150)] + [InlineData(1.4999, 150)] // mnożnik z GetDpi bywa niecałkowity → zaokrąglenie do 5% + [InlineData(1.75, 175)] + [InlineData(2.0, 200)] + [InlineData(0.5, 100)] // ekran „poniżej 100%" — serwer i tak nie przyjmie < 100 + [InlineData(9.0, 500)] + public void EffectiveScale_AutoFollowsLocalDpi(double dpiScale, int expected) + => Assert.Equal(expected, RdpDisplay.EffectiveScale(0, dpiScale)); + + [Theory] + [InlineData(0.0)] + [InlineData(-1.0)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void EffectiveScale_AutoWithBogusDpiFallsBackTo100(double dpiScale) + => Assert.Equal(100, RdpDisplay.EffectiveScale(0, dpiScale)); + + [Fact] + public void DeviceScaleFactor_StaysAtAllowedValue() + { + // ulDeviceScaleFactor przyjmuje wyłącznie 100/140/180 — inna wartość unieważnia całe Display-Update. + Assert.Contains(RdpDisplay.DeviceScaleFactor, new uint[] { 100u, 140u, 180u }); + } + } +}