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 });
+ }
+ }
+}