mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
* Fix GDK X11 support * Better scaling support for Linux --------- Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user