using System.Diagnostics; using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Logging; namespace Daybreak.Linux.Utils; /// /// Detects the effective desktop UI scale factor on Linux so the Photino webview /// can be zoomed to match HiDPI monitors. /// /// /// /// This is needed because Daybreak forces the GTK X11 backend (see /// Launcher.ForceX11Backend): under XWayland, GTK/GDK and therefore /// Photino's built-in gdk_monitor_get_scale_factor 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. /// /// /// Detection is layered, first match wins, so it degrades safely across /// environments: /// /// GDK_SCALE * GDK_DPI_SCALE environment override (any session). /// 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. /// X11 session: Xft.dpi / 96 (set by KDE/GNOME when scaling on X11). /// Fallback: 1.0 (current behaviour, no regression). /// /// /// internal static class DisplayScale { private const double MinScale = 0.5; private const double MaxScale = 4.0; private const int ProcessTimeoutMs = 2000; /// /// 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. /// 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; } /// /// Runs a process and returns its stdout, or false if the executable is /// missing, times out, or exits non-zero. /// 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); }