diff --git a/README.md b/README.md
index 79dff6d..7a22c86 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,9 @@ it's a self-contained single file, no .NET install required (Windows 10/11 x64).
- **RDP** — Microsoft's official ActiveX control (`mstscax`): dynamic resolution that follows the
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
+ choice, **remote desktop scaling** from 50% to 200% — larger, crisp text on 4K/HiDPI screens or a
+ smaller UI with more desktop area (*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),
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0ab629c..5c02c70 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -22,7 +22,8 @@ current priorities.
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; optionally a
- fixed resolution, plus remote desktop scaling (DPI) for readable text on 4K/HiDPI screens.
+ fixed resolution, plus remote desktop scaling 50–200% (session DPI above 100%, oversized
+ desktop scaled into the window below it).
- ✅ **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/Core/RdpDisplay.cs b/src/RdpManager/Core/RdpDisplay.cs
index 4cb2ef5..af90512 100644
--- a/src/RdpManager/Core/RdpDisplay.cs
+++ b/src/RdpManager/Core/RdpDisplay.cs
@@ -10,20 +10,30 @@ namespace RdpManager.Core
/// 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.
+ /// - SKALOWANIE: 0 = „jak ekran lokalny" (mnożnik DPI okna — tak robi mstsc), inaczej stałe procenty.
+ /// Dwa mechanizmy, bo protokół zna DPI tylko w zakresie 100..500%:
+ /// • >= 100% → ulDesktopScaleFactor (UpdateSessionDisplaySettings) i „DesktopScaleFactor" przed
+ /// Connect, więc zdalny pulpit RYSUJE interfejs większy — bez utraty ostrości. Na ekranie 4K/HiDPI
+ /// sesja 1:1 przy 100% daje mikroskopijny tekst; to jest na to lekarstwo.
+ /// • < 100% → DPI zostaje 100%, a żądamy PULPITU WIĘKSZEGO niż panel ()
+ /// i zmniejszamy obraz SmartSizingiem: więcej pulpitu, mniejszy UI, kosztem lekkiego rozmycia.
///
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;
+ /// Dół protokołu: DPI sesji nie zejdzie poniżej 100% (mniejsze wartości serwer IGNORUJE).
+ public const int ProtocolMinScale = 100;
+
+ /// Dół naszego suwaka. Poniżej 100% DPI nie wchodzi w grę, więc pomniejszenie robimy drugą
+ /// drogą — patrz . Niżej niż 50% nie ma sensu (rozdzielczość rośnie
+ /// dwukrotnie, tekst i tak nieczytelny).
+ public const int MinScale = 50;
+
/// Czy ustawienie opisuje STAŁĄ rozdzielczość (a nie „dopasuj do okna").
public static bool IsFixed(int width, int height)
=> width >= RdpUtils.MinDim && height >= RdpUtils.MinDim;
@@ -69,18 +79,57 @@ public static int ParseScale(string tag)
}
///
- /// 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
+ /// Skala wybrana przez użytkownika. > 0 = jego wartość (przycięta do
+ /// [50..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.
+ /// na okrągłych krokach DPI. W trybie „auto" nie schodzimy poniżej 100% — ekran lokalny poniżej 100%
+ /// to zwykle artefakt pomiaru, a nie życzenie użytkownika (pomniejszenie wybiera się świadomie).
///
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;
+ if (double.IsNaN(localDpiScale) || double.IsInfinity(localDpiScale) || localDpiScale <= 0) return ProtocolMinScale;
int pct = (int)Math.Round(localDpiScale * 100.0 / 5.0, MidpointRounding.AwayFromZero) * 5;
- return Math.Clamp(pct, MinScale, MaxScale);
+ return Math.Clamp(pct, ProtocolMinScale, MaxScale);
+ }
+
+ ///
+ /// Wartość do ulDesktopScaleFactor: dla skali >= 100% to wprost skala (serwer rysuje UI
+ /// większe, obraz zostaje ostry). Poniżej 100% protokół nie pozwala zejść, więc DPI zostaje na 100%,
+ /// a pomniejszenie realizuje .
+ ///
+ public static uint DesktopScaleFactor(int effectiveScale)
+ => (uint)Math.Clamp(effectiveScale, ProtocolMinScale, MaxScale);
+
+ ///
+ /// Mnożnik ROZDZIELCZOŚCI dla skali poniżej 100%: żądamy pulpitu większego niż panel (100/75 = 1.33×)
+ /// i zmniejszamy obraz SmartSizingiem. Skutek jak DPI < 100% — więcej pulpitu, mniejszy interfejs —
+ /// kosztem lekkiego rozmycia (skalowanie w dół wygląda znacznie lepiej niż w górę). Dla >= 100%
+ /// zwraca 1.0, bo tam pracuje już desktopScaleFactor i obraz ma zostać 1:1.
+ ///
+ public static double ResolutionMultiplier(int effectiveScale)
+ => effectiveScale >= ProtocolMinScale || effectiveScale <= 0
+ ? 1.0
+ : ProtocolMinScale / (double)Math.Max(effectiveScale, MinScale);
+
+ ///
+ /// Nakłada na wymiary bazowe (piksele panelu albo stała
+ /// rozdzielczość) i normalizuje wynik. Gdy po przemnożeniu któryś wymiar wychodzi za limit sesji
+ /// (8192), przycinamy MNOŻNIK, nie pojedynczy wymiar — inaczej pulpit dostałby inne proporcje
+ /// niż okno i obraz byłby rozciągnięty.
+ ///
+ public static (int Width, int Height) ScaleResolution(int width, int height, int effectiveScale)
+ {
+ if (width < RdpUtils.MinDim || height < RdpUtils.MinDim) return (0, 0);
+
+ double mult = ResolutionMultiplier(effectiveScale);
+ if (mult > 1.0)
+ mult = Math.Min(mult, Math.Min(RdpUtils.MaxDim / (double)width, RdpUtils.MaxDim / (double)height));
+ if (mult <= 1.0) return (RdpUtils.NormalizeDim(width), RdpUtils.NormalizeDim(height));
+
+ return (RdpUtils.NormalizeDim((int)Math.Round(width * mult)),
+ RdpUtils.NormalizeDim((int)Math.Round(height * mult)));
}
}
}
diff --git a/src/RdpManager/MainWindow.xaml b/src/RdpManager/MainWindow.xaml
index 53cb0b8..4ee706c 100644
--- a/src/RdpManager/MainWindow.xaml
+++ b/src/RdpManager/MainWindow.xaml
@@ -851,6 +851,11 @@
+
+
+
+
+
diff --git a/src/RdpManager/RdpDynamicResolution.cs b/src/RdpManager/RdpDynamicResolution.cs
index 2af6d2f..99950f3 100644
--- a/src/RdpManager/RdpDynamicResolution.cs
+++ b/src/RdpManager/RdpDynamicResolution.cs
@@ -26,8 +26,10 @@ namespace RdpManager
/// 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ć").
+ /// - skala >= 100% → 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ć");
+ /// - skala < 100% → DPI zostaje 100%, ale żądamy pulpitu WIĘKSZEGO niż panel i wciskamy go
+ /// SmartSizingiem (pomniejszenie — protokół nie zna DPI poniżej 100%).
///
public sealed class RdpDynamicResolution : IDisposable
{
@@ -80,16 +82,27 @@ public RdpDynamicResolution(Session session, WindowsFormsHost host, AppSettings
return (w, h);
}
- /// Skala DPI dla serwera: wybór użytkownika, a przy „auto" mnożnik DPI okna (jak mstsc).
- private uint EffectiveScale()
+ /// Skala wybrana przez użytkownika, a przy „auto" mnożnik DPI okna (jak mstsc). Wartości
+ /// poniżej 100% nie są DPI — realizuje je mnożnik rozdzielczości (patrz ).
+ private int EffectiveScale()
{
double dpi = TryGetDpiScale(out var sx, out _) ? sx : 1.0;
- return (uint)RdpDisplay.EffectiveScale(_settings?.RdpScalePercent ?? 0, dpi);
+ return 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;
+ /// Wymiary do wysłania: baza (piksele panelu albo stała rozdzielczość) po nałożeniu mnożnika
+ /// dla skali poniżej 100%.
+ private (int W, int H) TargetRes(int baseW, int baseH)
+ {
+ var (w, h) = RdpDisplay.ScaleResolution(baseW, baseH, EffectiveScale());
+ return (w, h);
+ }
+
+ /// SmartSizing musi być włączony niezależnie od trybu podziału, gdy: (a) rozdzielczość jest
+ /// stała — inaczej pulpit siedziałby w letterboxie zamiast wypełniać okno; (b) skala jest poniżej 100%
+ /// — tam pomniejszenie POLEGA na wciśnięciu większego pulpitu w panel.
+ private bool WantSmartSizing(bool hasFixedRes)
+ => _fit || hasFixedRes || EffectiveScale() < RdpDisplay.ProtocolMinScale;
private bool _fit;
/// „Dopasuj do okna": host skaluje pulpit do swojego rozmiaru (SmartSizing), więc pulpit zawsze
@@ -130,13 +143,14 @@ public void ApplyPreConnect()
{
// 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; }
+ var target = TargetRes(fx.W, fx.H);
+ try { rdp.DesktopWidth = target.W; rdp.DesktopHeight = target.H; }
catch (COMException) { }
catch (InvalidComObjectException) { }
}
TrySetSmartSizing(rdp, WantSmartSizing(fx.W > 0));
- TrySetPreConnectScale(rdp, EffectiveScale());
+ TrySetPreConnectScale(rdp, RdpDisplay.DesktopScaleFactor(EffectiveScale()));
}
///
@@ -166,9 +180,9 @@ public void ApplyExact(int physW, int physH)
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;
+ var target = TargetRes(physW, physH); // skala < 100% => pulpit większy niż monitor
+ if (target.W < MinDim || target.H < MinDim) return;
+ physW = target.W; physH = target.H; // TargetRes już normalizuje (parzyste, [200..8192])
var rdp = _session.Rdp;
if (rdp == null) return;
@@ -216,18 +230,16 @@ private void OnDebounceTick(object sender, EventArgs e)
catch (COMException) { return; }
if (!live) return;
- int w, h;
+ int baseW, baseH;
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);
+ baseW = fx.W; baseH = fx.H; // stała rozdzielczość: nie mierzymy panelu, tylko wysyłamy wybraną
}
+ else if (!TryGetPhysicalPixels(out baseW, out baseH)) return;
+
+ // Skala < 100% => pulpit większy niż baza, wciśnięty w panel SmartSizingiem (pomniejszenie).
+ var (w, h) = TargetRes(baseW, baseH);
if (w < MinDim || h < MinDim) return;
if (w == _lastW && h == _lastH) return;
@@ -269,7 +281,7 @@ private void OnDebounceTick(object sender, EventArgs e)
///
private void UpdateDisplay(AxMsRdpClient11NotSafeForScripting rdp, int w, int h)
{
- uint scale = EffectiveScale();
+ uint scale = RdpDisplay.DesktopScaleFactor(EffectiveScale());
if (scale == 100u)
{
rdp.UpdateSessionDisplaySettings((uint)w, (uint)h, 0u, 0u, 0u, 100u, RdpDisplay.DeviceScaleFactor);
diff --git a/src/RdpManager/Themes/Strings.en.xaml b/src/RdpManager/Themes/Strings.en.xaml
index 692cae0..95d6a67 100644
--- a/src/RdpManager/Themes/Strings.en.xaml
+++ b/src/RdpManager/Themes/Strings.en.xaml
@@ -110,8 +110,8 @@
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.
+ Remote desktop scaling
+ Above 100% the server draws text and icons larger, without blurring (session DPI; requires Windows 10 / Server 2016 or newer). Below 100% the client requests a higher resolution and scales the image down — more desktop, smaller interface, slightly softer.
Match local display
Automatically resume dropped connections
Behavior
diff --git a/src/RdpManager/Themes/Strings.pl.xaml b/src/RdpManager/Themes/Strings.pl.xaml
index 5f7b6a2..b543567 100644
--- a/src/RdpManager/Themes/Strings.pl.xaml
+++ b/src/RdpManager/Themes/Strings.pl.xaml
@@ -110,8 +110,8 @@
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.
+ Skalowanie pulpitu zdalnego
+ Powyżej 100% serwer rysuje tekst i ikony większe, bez rozmycia (DPI sesji; wymaga Windows 10 / Server 2016 lub nowszego). Poniżej 100% klient żąda wyższej rozdzielczości i zmniejsza obraz — więcej pulpitu, drobniejszy interfejs, nieco mniej ostro.
Jak ekran lokalny
Automatycznie wznawiaj zerwane połączenie
Zachowanie
diff --git a/tests/RdpManager.Tests/RdpDisplayTests.cs b/tests/RdpManager.Tests/RdpDisplayTests.cs
index bbf9ab9..704b8c4 100644
--- a/tests/RdpManager.Tests/RdpDisplayTests.cs
+++ b/tests/RdpManager.Tests/RdpDisplayTests.cs
@@ -66,15 +66,17 @@ public void FormatResolution_RoundTripsWithParse()
[InlineData("150", 150)]
[InlineData("175%", 175)] // tolerujemy „%" z etykiety
[InlineData("9000", 500)] // powyżej MaxScale
- [InlineData("50", 100)] // poniżej MinScale
+ [InlineData("75", 75)] // pomniejszenie — poniżej 100% jest dozwolone
+ [InlineData("10", 50)] // 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(75, 75)]
[InlineData(600, 500)] // przycięcie do MaxScale
- [InlineData(10, 100)] // przycięcie do MinScale
+ [InlineData(10, 50)] // przycięcie do MinScale
public void EffectiveScale_ConfiguredWins(int configured, int expected)
=> Assert.Equal(expected, RdpDisplay.EffectiveScale(configured, 1.0));
@@ -85,7 +87,7 @@ public void EffectiveScale_ConfiguredWins(int configured, int expected)
[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(0.5, 100)] // „auto" nie schodzi pod 100% — pomniejszenie wybiera się świadomie
[InlineData(9.0, 500)]
public void EffectiveScale_AutoFollowsLocalDpi(double dpiScale, int expected)
=> Assert.Equal(expected, RdpDisplay.EffectiveScale(0, dpiScale));
@@ -98,6 +100,50 @@ public void EffectiveScale_AutoFollowsLocalDpi(double dpiScale, int expected)
public void EffectiveScale_AutoWithBogusDpiFallsBackTo100(double dpiScale)
=> Assert.Equal(100, RdpDisplay.EffectiveScale(0, dpiScale));
+ // ---------- pomniejszenie (skala < 100%) ----------
+
+ [Theory]
+ [InlineData(50, 100u)] // DPI nie zejdzie poniżej 100% — pomniejsza mnożnik rozdzielczości
+ [InlineData(75, 100u)]
+ [InlineData(100, 100u)]
+ [InlineData(150, 150u)]
+ [InlineData(500, 500u)]
+ public void DesktopScaleFactor_ClampedToProtocolRange(int scale, uint expected)
+ => Assert.Equal(expected, RdpDisplay.DesktopScaleFactor(scale));
+
+ [Theory]
+ [InlineData(100, 1.0)]
+ [InlineData(150, 1.0)] // powyżej 100% pracuje DPI, rozdzielczość zostaje 1:1
+ [InlineData(0, 1.0)]
+ [InlineData(50, 2.0)]
+ [InlineData(75, 100.0 / 75.0)]
+ [InlineData(80, 1.25)]
+ public void ResolutionMultiplier_OnlyGrowsBelow100(int scale, double expected)
+ => Assert.Equal(expected, RdpDisplay.ResolutionMultiplier(scale), 6);
+
+ [Theory]
+ [InlineData(1920, 1080, 100, 1920, 1080)]
+ [InlineData(1920, 1080, 150, 1920, 1080)] // DPI, nie rozdzielczość
+ [InlineData(1920, 1080, 75, 2560, 1440)]
+ [InlineData(1920, 1080, 50, 3840, 2160)]
+ [InlineData(1280, 720, 80, 1600, 900)]
+ public void ScaleResolution_GrowsDesktopBelow100(int w, int h, int scale, int expW, int expH)
+ => Assert.Equal((expW, expH), RdpDisplay.ScaleResolution(w, h, scale));
+
+ [Fact]
+ public void ScaleResolution_KeepsAspectWhenHittingSessionLimit()
+ {
+ // 6000×3000 przy 50% chciałoby 12000×6000 — limit to 8192, więc przycinamy MNOŻNIK
+ // (oba wymiary o tyle samo), a nie pojedynczy wymiar, żeby nie zmienić proporcji.
+ var (w, h) = RdpDisplay.ScaleResolution(6000, 3000, 50);
+ Assert.Equal(RdpUtils.MaxDim, w);
+ Assert.Equal(RdpUtils.MaxDim / 2, h);
+ }
+
+ [Fact]
+ public void ScaleResolution_RejectsTooSmallBase()
+ => Assert.Equal((0, 0), RdpDisplay.ScaleResolution(100, 100, 75));
+
[Fact]
public void DeviceScaleFactor_StaysAtAllowedValue()
{