Improve x11 support in Linux with GDK X11 fallback and scale support (#1556) (Closes #1551)

* Fix GDK X11 support

* Better scaling support for Linux

---------

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
2026-06-29 17:27:38 +02:00
parent 5edb5ff780
commit dc340189d6
4 changed files with 638 additions and 24 deletions
+22
View File
@@ -1,4 +1,5 @@
using Daybreak.Linux.Configuration;
using Daybreak.Linux.Utils;
namespace Daybreak.Linux;
@@ -6,8 +7,29 @@ public static partial class Launcher
{
public static void Main(string[] args)
{
ForceX11Backend();
var bootstrap = Launch.Launcher.SetupBootstrap();
var platformConfiguration = new LinuxPlatformConfiguration();
Launch.Launcher.LaunchSequence(args, bootstrap, platformConfiguration);
}
/// <summary>
/// Forces GTK to use the X11 backend (via XWayland under Wayland sessions).
/// Photino/WebKitGTK/GTK3 and Daybreak's window interop rely on X11-only APIs
/// (gdk_x11_window_get_xid); without this, GTK selects the Wayland backend on
/// Wayland sessions and those calls abort with
/// "gdk_x11_window_get_xid: assertion 'GDK_IS_X11_WINDOW (window)' failed"
/// followed by a fatal Wayland protocol error (see issue #1551).
///
/// Must run before any GTK initialization (which happens lazily when the first
/// Photino window is created) and must use the native libc setenv: managed
/// Environment.SetEnvironmentVariable does not propagate to native getenv,
/// which is how GTK reads GDK_BACKEND. overwrite=0 lets a user who explicitly
/// exports GDK_BACKEND (e.g. to test native Wayland) keep their choice.
/// </summary>
private static void ForceX11Backend()
{
NativeMethods.setenv("GDK_BACKEND", "x11", 0);
}
}
+213 -24
View File
@@ -1,4 +1,5 @@
using Daybreak.Configuration.Options;
using Daybreak.Linux.Utils;
using Daybreak.Shared.Models;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Services.Screens;
@@ -9,26 +10,49 @@ using Photino.NET;
using System.Core.Extensions;
using System.Drawing;
using System.Extensions;
using System.Extensions.Core;
namespace Daybreak.Linux.Services.Screens;
// TODO: Implement proper Linux screen management using X11/Wayland APIs
/// <summary>
/// Linux screen management backed by GDK monitor enumeration. Reports real
/// monitor geometry and tracks each monitor's integer scale factor so the window
/// spawns sized to (and is restored on) the correct monitor. Scale is persisted
/// as DPI (scale * 96) in <see cref="ScreenManagerOptions"/>, mirroring the
/// Windows implementation, so a window saved on one monitor is rescaled when
/// restored on a monitor with a different scale.
/// </summary>
/// <remarks>
/// GDK reports an integer scale factor; under X11/XWayland (Daybreak forces the
/// X11 backend) this reflects GDK_SCALE / Xft.dpi-derived scaling. True
/// fractional per-monitor scaling would require the Wayland backend.
/// </remarks>
internal sealed class ScreenManager(
PhotinoWindow photinoWindow,
IOptionsProvider optionsProvider,
IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions,
ILogger<ScreenManager> logger) : IScreenManager, IHostedService
{
private const int DefaultDpi = 96;
private readonly PhotinoWindow photinoWindow = photinoWindow.ThrowIfNull();
private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
private readonly IOptionsMonitor<ScreenManagerOptions> liveUpdateableOptions = liveUpdateableOptions.ThrowIfNull();
private readonly ILogger<ScreenManager> logger = logger.ThrowIfNull();
// TODO: Implement proper screen enumeration for Linux
public IEnumerable<Screen> Screens => GetDummyScreens();
private readonly record struct MonitorInfo(Screen Screen, bool IsPrimary);
// Enumerated once (lazily) and cached; provides real monitor geometry for
// window placement. Monitor scale comes from DisplayScale (not GDK), because
// under the forced X11 backend GDK always reports a scale of 1.
private IReadOnlyList<MonitorInfo>? cachedMonitors;
private double? cachedScale;
public IEnumerable<Screen> Screens => this.GetMonitors().Select(static m => m.Screen);
Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
this.ApplyDisplayScale();
return Task.CompletedTask;
}
@@ -38,6 +62,31 @@ internal sealed class ScreenManager(
return Task.CompletedTask;
}
/// <summary>
/// Zooms the Photino webview to match the desktop UI scale so the interface is
/// rendered at the correct physical size on HiDPI monitors. Photino's built-in
/// scale detection is defeated by the forced X11 backend, so this is applied
/// explicitly. Must run on the UI thread, hence the <see cref="PhotinoWindow.Invoke"/>.
/// </summary>
private void ApplyDisplayScale()
{
var scale = this.GetEffectiveScale();
var zoom = (int)Math.Round(scale * 100.0);
if (zoom == 100)
{
return;
}
this.logger.LogDebug("Applying webview zoom {Zoom}% for display scale {Scale}", zoom, scale);
this.photinoWindow.Invoke(() => this.photinoWindow.Zoom = zoom);
}
private double GetEffectiveScale()
{
this.cachedScale ??= DisplayScale.GetEffectiveScale(this.logger);
return this.cachedScale.Value;
}
public void MoveWindowToSavedPosition()
{
var savedPosition = this.GetSavedPosition();
@@ -55,11 +104,15 @@ internal sealed class ScreenManager(
this.photinoWindow.Width,
this.photinoWindow.Height);
var dpi = this.GetDpiForPosition(position.Left, position.Top);
var options = this.liveUpdateableOptions.CurrentValue;
options.X = position.Left;
options.Y = position.Top;
options.Width = position.Width;
options.Height = position.Height;
options.DpiX = dpi;
options.DpiY = dpi;
this.optionsProvider.SaveOption(options);
}
@@ -70,6 +123,8 @@ internal sealed class ScreenManager(
options.Y = 0;
options.Width = 0;
options.Height = 0;
options.DpiX = DefaultDpi;
options.DpiY = DefaultDpi;
this.optionsProvider.SaveOption(options);
}
@@ -82,35 +137,169 @@ internal sealed class ScreenManager(
public Rectangle GetSavedPosition()
{
var options = this.liveUpdateableOptions.CurrentValue;
var savedPosition = new Rectangle(
(int)this.liveUpdateableOptions.CurrentValue.X,
(int)this.liveUpdateableOptions.CurrentValue.Y,
(int)this.liveUpdateableOptions.CurrentValue.Width,
(int)this.liveUpdateableOptions.CurrentValue.Height);
(int)options.X,
(int)options.Y,
(int)options.Width,
(int)options.Height);
if (savedPosition.Width is 0 || savedPosition.Height is 0)
{
var firstScreen = this.Screens.FirstOrDefault();
if (firstScreen.Size.IsEmpty)
{
// Return a reasonable default instead of throwing
return new Rectangle(0, 100, 1000, 900);
}
return new Rectangle(
firstScreen.Size.X + (firstScreen.Size.Width / 4),
firstScreen.Size.Y + (firstScreen.Size.Height / 4),
firstScreen.Size.Width / 2,
firstScreen.Size.Height / 2);
return this.GetDefaultPosition();
}
return savedPosition;
// Rescale the saved geometry if the monitor under it now has a different
// scale than when it was saved (e.g. monitor swapped, scale changed).
var savedDpiX = options.DpiX > 0 ? options.DpiX : DefaultDpi;
var savedDpiY = options.DpiY > 0 ? options.DpiY : DefaultDpi;
var currentDpi = this.GetDpiForPosition(savedPosition.Left, savedPosition.Top);
if (savedDpiX != currentDpi || savedDpiY != currentDpi)
{
var scaleX = (double)currentDpi / savedDpiX;
var scaleY = (double)currentDpi / savedDpiY;
savedPosition = new Rectangle(
(int)(savedPosition.X * scaleX),
(int)(savedPosition.Y * scaleY),
(int)(savedPosition.Width * scaleX),
(int)(savedPosition.Height * scaleY));
}
return this.EnsurePositionIsOnScreen(savedPosition);
}
private static List<Screen> GetDummyScreens()
private Rectangle GetDefaultPosition()
{
// TODO: Use X11/Wayland APIs to enumerate actual screens
// For now, return a single dummy screen with reasonable defaults
return [new Screen(0, new Rectangle(0, 0, 1920, 1080))];
var monitor = this.GetPrimaryMonitor();
if (monitor is not { } m || m.Screen.Size.IsEmpty)
{
// Reasonable fallback when GDK could not enumerate any monitor.
return new Rectangle(0, 100, 1000, 900);
}
var bounds = m.Screen.Size;
return new Rectangle(
bounds.X + (bounds.Width / 4),
bounds.Y + (bounds.Height / 4),
bounds.Width / 2,
bounds.Height / 2);
}
private Rectangle EnsurePositionIsOnScreen(Rectangle position)
{
var monitors = this.GetMonitors();
if (monitors.Count == 0)
{
return position;
}
var centerX = position.Left + (position.Width / 2);
var centerY = position.Top + (position.Height / 2);
var isOnScreen = monitors.Any(m =>
centerX >= m.Screen.Size.Left && centerX <= m.Screen.Size.Right &&
centerY >= m.Screen.Size.Top && centerY <= m.Screen.Size.Bottom);
if (isOnScreen)
{
return position;
}
// Off-screen (e.g. a disconnected monitor): re-home onto the primary.
var primary = this.GetPrimaryMonitor() ?? monitors[0];
var bounds = primary.Screen.Size;
return new Rectangle(
bounds.X + (bounds.Width / 4),
bounds.Y + (bounds.Height / 4),
Math.Min(position.Width, bounds.Width / 2),
Math.Min(position.Height, bounds.Height / 2));
}
/// <summary>
/// Returns the effective DPI (scale * 96) used for the saved-position rescale.
/// A single effective scale is used for the whole desktop: webview zoom is one
/// global value, and under the forced X11 backend GDK cannot report reliable
/// per-monitor scales anyway.
/// </summary>
private int GetDpiForPosition(int x, int y)
{
return (int)Math.Round(this.GetEffectiveScale() * DefaultDpi);
}
private MonitorInfo? GetPrimaryMonitor()
{
var monitors = this.GetMonitors();
if (monitors.Count == 0)
{
return null;
}
foreach (var m in monitors)
{
if (m.IsPrimary)
{
return m;
}
}
return monitors[0];
}
private IReadOnlyList<MonitorInfo> GetMonitors()
{
if (this.cachedMonitors is { Count: > 0 })
{
return this.cachedMonitors;
}
var monitors = EnumerateMonitors(this.logger);
if (monitors.Count > 0)
{
// Only cache a successful enumeration so an early (pre-GTK) call can retry.
this.cachedMonitors = monitors;
}
return monitors;
}
private static IReadOnlyList<MonitorInfo> EnumerateMonitors(ILogger<ScreenManager> logger)
{
var scopedLogger = logger.CreateScopedLogger();
// Ensure a default display exists in case monitors are queried before
// Photino created its first window. gtk_init_check is idempotent.
NativeMethods.gtk_init_check(nint.Zero, nint.Zero);
var display = NativeMethods.gdk_display_get_default();
if (display == nint.Zero)
{
scopedLogger.LogWarning("No default GdkDisplay available; cannot enumerate monitors");
return [];
}
var count = NativeMethods.gdk_display_get_n_monitors(display);
if (count <= 0)
{
scopedLogger.LogWarning("GDK reported {Count} monitors", count);
return [];
}
var primary = NativeMethods.gdk_display_get_primary_monitor(display);
var result = new List<MonitorInfo>(count);
for (var i = 0; i < count; i++)
{
var monitor = NativeMethods.gdk_display_get_monitor(display, i);
if (monitor == nint.Zero)
{
continue;
}
NativeMethods.gdk_monitor_get_geometry(monitor, out var geometry);
var screen = new Screen(i, new Rectangle(geometry.X, geometry.Y, geometry.Width, geometry.Height));
result.Add(new MonitorInfo(screen, monitor == primary && primary != nint.Zero));
}
scopedLogger.LogDebug("Enumerated {Count} monitor(s) via GDK", result.Count);
return result;
}
}
+337
View File
@@ -0,0 +1,337 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Utils;
/// <summary>
/// Detects the effective desktop UI scale factor on Linux so the Photino webview
/// can be zoomed to match HiDPI monitors.
/// </summary>
/// <remarks>
/// <para>
/// This is needed because Daybreak forces the GTK X11 backend (see
/// <c>Launcher.ForceX11Backend</c>): under XWayland, GTK/GDK and therefore
/// Photino's built-in <c>gdk_monitor_get_scale_factor</c> based auto-zoom always
/// report a scale of 1, so a fractional Wayland scale (e.g. 1.5) is invisible and
/// the UI renders too small.
/// </para>
/// <para>
/// Detection is layered, first match wins, so it degrades safely across
/// environments:
/// <list type="number">
/// <item><c>GDK_SCALE</c> * <c>GDK_DPI_SCALE</c> environment override (any session).</item>
/// <item>Wayland session: the focused monitor's scale from the compositor
/// (hyprctl / swaymsg / wlr-randr / kscreen-doctor). Shelling out keeps the
/// detection out-of-process so a misbehaving tool cannot crash the launcher.</item>
/// <item>X11 session: <c>Xft.dpi</c> / 96 (set by KDE/GNOME when scaling on X11).</item>
/// <item>Fallback: 1.0 (current behaviour, no regression).</item>
/// </list>
/// </para>
/// </remarks>
internal static class DisplayScale
{
private const double MinScale = 0.5;
private const double MaxScale = 4.0;
private const int ProcessTimeoutMs = 2000;
/// <summary>
/// Returns the effective UI scale (e.g. 1.0, 1.5, 2.0), clamped to a sane range.
/// Returns 1.0 if nothing could be determined.
/// </summary>
public static double GetEffectiveScale(ILogger logger)
{
if (TryGetEnvScale(out var envScale))
{
logger.LogDebug("Display scale {Scale} from GDK_SCALE/GDK_DPI_SCALE", envScale);
return Clamp(envScale);
}
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
{
if (TryGetWaylandScale(logger, out var wlScale))
{
logger.LogDebug("Display scale {Scale} from Wayland compositor", wlScale);
return Clamp(wlScale);
}
}
else if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
{
if (TryGetXftDpiScale(logger, out var x11Scale))
{
logger.LogDebug("Display scale {Scale} from Xft.dpi", x11Scale);
return Clamp(x11Scale);
}
}
logger.LogDebug("No display scale detected; defaulting to 1.0");
return 1.0;
}
private static bool TryGetEnvScale(out double scale)
{
scale = 1.0;
var gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE");
var gdkDpiScale = Environment.GetEnvironmentVariable("GDK_DPI_SCALE");
var any = false;
if (double.TryParse(gdkScale, NumberStyles.Float, CultureInfo.InvariantCulture, out var gs) && gs > 0)
{
scale *= gs;
any = true;
}
if (double.TryParse(gdkDpiScale, NumberStyles.Float, CultureInfo.InvariantCulture, out var gd) && gd > 0)
{
scale *= gd;
any = true;
}
return any;
}
private static bool TryGetWaylandScale(ILogger logger, out double scale)
{
scale = 1.0;
// hyprctl and swaymsg both emit a JSON array of monitors/outputs with
// "focused" and "scale" fields, so they share a parser. The focused
// output is where a new window spawns, which is exactly what we want.
if (TryRun("hyprctl", "monitors -j", logger, out var hypr) &&
TryParseFocusedScale(hypr, out scale))
{
return true;
}
if (TryRun("swaymsg", "-t get_outputs -r", logger, out var sway) &&
TryParseFocusedScale(sway, out scale))
{
return true;
}
// wlr-randr (generic wlroots) and kscreen-doctor (KDE) have no "focused"
// flag; fall back to the first scaled output reported.
if (TryRun("wlr-randr", "--json", logger, out var wlr) &&
TryParseFirstScale(wlr, "scale", out scale))
{
return true;
}
if (TryRun("kscreen-doctor", "-j", logger, out var kscreen) &&
TryParseKScreenScale(kscreen, out scale))
{
return true;
}
return false;
}
private static bool TryParseFocusedScale(string json, out double scale)
{
scale = 1.0;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return false;
}
double? firstScale = null;
foreach (var output in doc.RootElement.EnumerateArray())
{
if (!output.TryGetProperty("scale", out var scaleProp) ||
scaleProp.ValueKind != JsonValueKind.Number)
{
continue;
}
var value = scaleProp.GetDouble();
firstScale ??= value;
if (output.TryGetProperty("focused", out var focused) &&
focused.ValueKind is JsonValueKind.True)
{
scale = value;
return value > 0;
}
}
if (firstScale is > 0)
{
scale = firstScale.Value;
return true;
}
}
catch (JsonException)
{
}
return false;
}
private static bool TryParseFirstScale(string json, string property, out double scale)
{
scale = 1.0;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return false;
}
foreach (var output in doc.RootElement.EnumerateArray())
{
if (output.TryGetProperty(property, out var scaleProp) &&
scaleProp.ValueKind == JsonValueKind.Number)
{
var value = scaleProp.GetDouble();
if (value > 0)
{
scale = value;
return true;
}
}
}
}
catch (JsonException)
{
}
return false;
}
private static bool TryParseKScreenScale(string json, out double scale)
{
scale = 1.0;
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("outputs", out var outputs) ||
outputs.ValueKind != JsonValueKind.Array)
{
return false;
}
double? firstScale = null;
foreach (var output in outputs.EnumerateArray())
{
if (!output.TryGetProperty("scale", out var scaleProp) ||
scaleProp.ValueKind != JsonValueKind.Number)
{
continue;
}
var value = scaleProp.GetDouble();
firstScale ??= value;
if (output.TryGetProperty("enabled", out var enabled) &&
enabled.ValueKind is JsonValueKind.True &&
value > 0)
{
scale = value;
return true;
}
}
if (firstScale is > 0)
{
scale = firstScale.Value;
return true;
}
}
catch (JsonException)
{
}
return false;
}
private static bool TryGetXftDpiScale(ILogger logger, out double scale)
{
scale = 1.0;
if (!TryRun("xrdb", "-query", logger, out var output))
{
return false;
}
foreach (var line in output.Split('\n'))
{
if (!line.StartsWith("Xft.dpi:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var value = line["Xft.dpi:".Length..].Trim();
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dpi) && dpi > 0)
{
scale = dpi / 96.0;
return true;
}
}
return false;
}
/// <summary>
/// Runs a process and returns its stdout, or false if the executable is
/// missing, times out, or exits non-zero.
/// </summary>
private static bool TryRun(string fileName, string arguments, ILogger logger, out string output)
{
output = string.Empty;
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
},
};
if (!process.Start())
{
return false;
}
var stdout = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(ProcessTimeoutMs))
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Best effort; the process will be reaped by the OS.
}
return false;
}
if (process.ExitCode != 0)
{
return false;
}
output = stdout;
return !string.IsNullOrWhiteSpace(output);
}
catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException)
{
// Tool not installed / not on PATH — expected, try the next source.
logger.LogDebug("Display scale tool '{Tool}' unavailable: {Message}", fileName, e.Message);
return false;
}
}
private static double Clamp(double scale) => Math.Clamp(scale, MinScale, MaxScale);
}
+66
View File
@@ -14,6 +14,7 @@ public static partial class NativeMethods
public const string GtkLib = "libgtk-3.so.0";
public const string GdkLib = "libgdk-3.so.0";
public const string GlibLib = "libglib-2.0.so.0";
public const string LibcLib = "libc";
#endregion
@@ -208,6 +209,16 @@ public static partial class NativeMethods
#region GTK3 P/Invoke
/// <summary>
/// Initializes GTK without aborting on failure. Idempotent and safe to call
/// repeatedly (no-op once GTK is already initialized). Used to guarantee a
/// default <c>GdkDisplay</c> exists before enumerating monitors, in case it
/// is queried before Photino has created its first window.
/// </summary>
[LibraryImport(GtkLib, EntryPoint = "gtk_init_check")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool gtk_init_check(nint argc, nint argv);
[LibraryImport(GtkLib, EntryPoint = "gtk_widget_get_window")]
public static partial nint gtk_widget_get_window(nint widget);
@@ -294,5 +305,60 @@ public static partial class NativeMethods
[LibraryImport(GdkLib, EntryPoint = "gdk_x11_window_get_xid")]
public static partial ulong gdk_x11_window_get_xid(nint gdkWindow);
// --- Monitor enumeration / scaling ---
/// <summary>GdkRectangle: monitor geometry in application (logical) pixels.</summary>
[StructLayout(LayoutKind.Sequential)]
public struct GdkRectangle
{
public int X;
public int Y;
public int Width;
public int Height;
}
[LibraryImport(GdkLib, EntryPoint = "gdk_display_get_n_monitors")]
public static partial int gdk_display_get_n_monitors(nint display);
[LibraryImport(GdkLib, EntryPoint = "gdk_display_get_monitor")]
public static partial nint gdk_display_get_monitor(nint display, int monitorNum);
[LibraryImport(GdkLib, EntryPoint = "gdk_display_get_primary_monitor")]
public static partial nint gdk_display_get_primary_monitor(nint display);
[LibraryImport(GdkLib, EntryPoint = "gdk_display_get_monitor_at_point")]
public static partial nint gdk_display_get_monitor_at_point(nint display, int x, int y);
[LibraryImport(GdkLib, EntryPoint = "gdk_monitor_get_geometry")]
public static partial void gdk_monitor_get_geometry(nint monitor, out GdkRectangle geometry);
/// <summary>
/// Returns the integer scale factor (1, 2, ...) GDK applies to the monitor.
/// Under X11/XWayland this reflects GDK_SCALE / Xft.dpi-derived integer
/// scaling; true fractional per-monitor scaling requires the Wayland backend.
/// </summary>
/// <summary>
/// Returns the integer scale factor (1, 2, ...) GDK applies to the monitor.
/// Note: Daybreak does not use this for UI scaling because, under the forced
/// X11 backend, GDK always reports 1 regardless of the real (fractional)
/// Wayland scale; see <see cref="Daybreak.Linux.Utils.DisplayScale"/>.
/// </summary>
[LibraryImport(GdkLib, EntryPoint = "gdk_monitor_get_scale_factor")]
public static partial int gdk_monitor_get_scale_factor(nint monitor);
#endregion
#region libc P/Invoke
/// <summary>
/// Sets an environment variable in the native C runtime. Required because
/// <see cref="System.Environment.SetEnvironmentVariable(string, string)"/>
/// updates only the managed environment cache and is NOT visible to native
/// libraries' getenv() (e.g. GTK reading GDK_BACKEND at gtk_init).
/// </summary>
/// <param name="overwrite">When 0, an existing value is preserved.</param>
[LibraryImport(LibcLib, EntryPoint = "setenv", StringMarshalling = StringMarshalling.Utf8)]
public static partial int setenv(string name, string value, int overwrite);
#endregion
}