Skip to content

Commit 4b670ee

Browse files
Fix Linux WebView first-paint and Wayland-session GTK init (#5)
Two related fixes for the same broken-on-Linux symptom — both contained inside the package, both no-op on platforms that already work. 1. Layout participation for the inner NativeControlHost NativeWebView is a Control (not a NativeControlHost) that adds its inner NativeWebViewControlHost to VisualChildren after async adapter creation. In Avalonia, adding to VisualChildren puts a control in the visual tree but does NOT make it a layout participant — the parent must explicitly Measure/Arrange visual children from MeasureOverride/ArrangeOverride. NativeWebView previously did neither, so the inner NativeControlHost's Bounds stayed default(Rect) and the cross-platform NativeControlHost flow's `if (bounds.Width == 0 && bounds.Height == 0) return false` guard in TryUpdateNativeControlPosition silently skipped the call to ShowInBounds. On Win32/macOS this is invisible because their holder windows are visible-by-default; on X11 the holder stays 1×1 IsUnMapped, the reparented WebKit GTK toplevel is IsUnviewable, and navigation completes ok=True with zero pixels visible. xwininfo on the broken case: Avalonia toplevel (window size) IsViewable Native-host slot 1×1 IsUnMapped <-- bug Reparented GTK 200×200 IsUnviewable <-- bug Fix: - NativeWebView.MeasureOverride now drives Measure() on each Layoutable in VisualChildren with the size it returns. - A new NativeWebView.ArrangeOverride drives Arrange() on each Layoutable in VisualChildren to the final size. - InvalidateMeasure() is called immediately after VisualChildren.Add((Control)controlHostImpl) so the layout pass actually re-runs for the freshly-added child (VisualChildren.Add does not itself invalidate layout). The bug was reproduced in a standalone Avalonia Sandbox without WebView — a plain Control adding a NativeControlHost to VisualChildren without measure/arrange, which produces the same 1×1 IsUnMapped slot xwininfo signature. The Measure/Arrange + InvalidateMeasure shape was verified to fix the symptom there before being applied here. xwininfo on the Avalonia.Controls.WebView.Samples.Desktop app with this fix: slot is the parent's bounds, IsViewable, GTK toplevel matches. WPF code paths (Avalonia.Xpf.Controls.WebView) are unchanged — every addition is gated behind #if AVALONIA. Verified to compile clean for net8.0-windows / net10.0-windows. 2. Override GDK_BACKEND=x11 around GTK init Both GtkX11WebViewAdapter and GtkOffscreenAvaloniaWebViewAdapter use Avalonia's GTK bridge (Avalonia.X11.NativeDialogs.Gtk.StartGtk) to initialize GTK, and both rely on X11/GDK-X11-only entry points. Under a Wayland session the desktop typically pre-sets GDK_BACKEND=wayland, so the bridge's gtk_init picks the Wayland backend and fails with "Unable to initialize GTK". Consumers previously had to set GDK_BACKEND=x11 in the shell before launching. Add a shared helper AvaloniaGtk.EnsureX11GdkBackendForGtkInit() that overrides GDK_BACKEND for the duration of the bridge call, then restores the previous value. Used via `using var _ = ...` at the top of each adapter's CreateBuilder so the override is visible at the call site and tightly scoped to GTK init. Notes: - Only overrides the unset case and the implicit "wayland" case (the default under a Wayland session). If the user has explicitly set some other backend like "broadway", that's a deliberate choice in conflict with loading an X11-only adapter and we let gtk_init fail with its own error rather than silently override. - Concurrent CreateBuilder calls share a single override window via refcounting: first call captures the previous env value and applies the override, subsequent overlapping calls only refcount, and the override is restored when the last scope is disposed. Without refcounting, two concurrent calls could each snapshot the other's already-overridden value and leave the env stuck at "x11". - Environment.SetEnvironmentVariable is not sufficient on Linux: it updates the .NET managed env cache but does not propagate to libc's environ, which is what gtk_init reads via getenv. A direct libc setenv call is required (verified empirically — setting only the managed value left gtk_init still failing despite the C# side reading "x11"). gdk_set_allowed_backends was tried first as the GTK-blessed alternative but the call returned cleanly without constraining Avalonia's GTK init helper in practice. Verified on KDE Neon 24.04 (Plasma 5 / X11) and Arch Linux + KDE Plasma 6 (Wayland session, XWayland for X11) using a minimal Avalonia 12 standalone repro app: stock 12.0.0 paints blank with the slot/GTK state above, with this PR paints first-show without any consumer-side GDK_BACKEND=x11 in the environment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 45d9086 commit 4b670ee

4 files changed

Lines changed: 143 additions & 5 deletions

File tree

src/Avalonia.Controls.WebView.Core/Gtk/AvaloniaGtk.cs

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
using System.Runtime.CompilerServices;
77
using System.Runtime.InteropServices;
88
using System.Runtime.Versioning;
9+
using System.Threading;
910
using System.Threading.Tasks;
1011
using Avalonia.Logging;
1112

1213
namespace Avalonia.Controls.Gtk;
1314

14-
internal static class AvaloniaGtk
15+
internal static partial class AvaloniaGtk
1516
{
1617
static AvaloniaGtk()
1718
{
@@ -63,6 +64,105 @@ static AvaloniaGtk()
6364

6465
public static bool HasSoup3 { get; }
6566

67+
/// <summary>
68+
/// Ensures GDK_BACKEND=x11 is in effect for the duration of the bridge call that
69+
/// initializes GTK in Avalonia.X11.NativeDialogs.Gtk. The X11 GTK adapters in this
70+
/// package use X11/GDK-X11-only entry points and require the X11 GDK backend; under
71+
/// a Wayland session GDK_BACKEND is typically pre-set to "wayland" by the desktop,
72+
/// which would otherwise cause `gtk_init` to fail with "Unable to initialize GTK".
73+
/// </summary>
74+
/// <remarks>
75+
/// Only overrides the unset case and the implicit "wayland" case (the default under
76+
/// a Wayland session). If the user has explicitly set some other backend like
77+
/// "broadway", that's a deliberate choice in conflict with loading an X11-only
78+
/// adapter, and we let `gtk_init` fail with its own error rather than silently
79+
/// override.
80+
///
81+
/// `Environment.SetEnvironmentVariable` alone is insufficient on Linux — it updates
82+
/// the .NET managed env cache but does not propagate to libc's environ, which is
83+
/// what `gtk_init` reads via `getenv`. A direct libc `setenv` call is required.
84+
///
85+
/// The returned IDisposable restores the previous env value on Dispose. By that
86+
/// point GTK has already locked in its backend choice for the process, so restoring
87+
/// the env doesn't switch backends — it just keeps the rest of the process's env
88+
/// state clean for any unrelated code that reads GDK_BACKEND.
89+
///
90+
/// Concurrent calls share a single override window via refcounting: the first call
91+
/// captures the previous env value and applies the override, subsequent overlapping
92+
/// calls only refcount, and the override is restored when the last scope is
93+
/// disposed. Without refcounting, two concurrent CreateBuilder calls could each
94+
/// snapshot the other's already-overridden value and leave the env stuck at "x11"
95+
/// after both restore.
96+
/// </remarks>
97+
public static IDisposable EnsureX11GdkBackendForGtkInit()
98+
{
99+
if (!OperatingSystem.IsLinux())
100+
return EmptyScope.Instance;
101+
102+
lock (s_overrideLock)
103+
{
104+
if (s_overrideCount == 0)
105+
{
106+
var current = Environment.GetEnvironmentVariable("GDK_BACKEND");
107+
if (string.Equals(current, "x11", StringComparison.Ordinal))
108+
return EmptyScope.Instance;
109+
if (current is { Length: > 0 } && !string.Equals(current, "wayland", StringComparison.Ordinal))
110+
return EmptyScope.Instance;
111+
112+
try { setenv("GDK_BACKEND", "x11", 1); }
113+
catch (DllNotFoundException) { return EmptyScope.Instance; }
114+
catch (EntryPointNotFoundException) { return EmptyScope.Instance; }
115+
Environment.SetEnvironmentVariable("GDK_BACKEND", "x11");
116+
s_savedBackend = current;
117+
}
118+
s_overrideCount++;
119+
}
120+
return new RestoreGdkBackendScope();
121+
}
122+
123+
private static readonly object s_overrideLock = new();
124+
private static int s_overrideCount;
125+
private static string? s_savedBackend;
126+
127+
[LibraryImport("libc", EntryPoint = "setenv", StringMarshalling = StringMarshalling.Utf8)]
128+
private static partial int setenv(string name, string value, int overwrite);
129+
130+
[LibraryImport("libc", EntryPoint = "unsetenv", StringMarshalling = StringMarshalling.Utf8)]
131+
private static partial int unsetenv(string name);
132+
133+
private sealed class EmptyScope : IDisposable
134+
{
135+
public static readonly EmptyScope Instance = new();
136+
public void Dispose() { }
137+
}
138+
139+
private sealed class RestoreGdkBackendScope : IDisposable
140+
{
141+
private int _disposed;
142+
public void Dispose()
143+
{
144+
if (Interlocked.Exchange(ref _disposed, 1) != 0)
145+
return;
146+
lock (s_overrideLock)
147+
{
148+
if (--s_overrideCount > 0)
149+
return;
150+
var previous = s_savedBackend;
151+
s_savedBackend = null;
152+
try
153+
{
154+
if (previous is null)
155+
unsetenv("GDK_BACKEND");
156+
else
157+
setenv("GDK_BACKEND", previous, 1);
158+
}
159+
catch (DllNotFoundException) { }
160+
catch (EntryPointNotFoundException) { }
161+
Environment.SetEnvironmentVariable("GDK_BACKEND", previous);
162+
}
163+
}
164+
}
165+
66166
public static Version? TryGetVersion()
67167
{
68168
try

src/Avalonia.Controls.WebView.Core/Gtk/GtkOffscreenAvaloniaWebViewAdapter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ private GtkOffscreenAvaloniaWebViewAdapter(GtkWebViewEnvironmentRequestedEventAr
3434
public static async Task<WebViewAdapter.OffscreenWebViewAdapterBuilder> CreateBuilder(
3535
GtkWebViewEnvironmentRequestedEventArgs environmentArgs)
3636
{
37+
using var _ = EnsureX11GdkBackendForGtkInit();
3738
var adapter = await RunOnGlibThreadAsync(() => new GtkOffscreenAvaloniaWebViewAdapter(environmentArgs));
3839
return (parent) =>
3940
{

src/Avalonia.Controls.WebView.Core/Gtk/GtkX11WebViewAdapter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ private GtkX11WebViewAdapter(GtkWebViewEnvironmentRequestedEventArgs environment
2828
public static async Task<WebViewAdapter.NativeWebViewAdapterBuilder> CreateBuilder(
2929
GtkWebViewEnvironmentRequestedEventArgs environmentArgs)
3030
{
31+
using var _ = EnsureX11GdkBackendForGtkInit();
3132
var adapter = await RunOnGlibThreadAsync(() => new GtkX11WebViewAdapter(environmentArgs));
3233
return (parent, _) =>
3334
{

src/Avalonia.Controls.WebView/NativeWebView.cs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,12 @@ private async void OnAttached()
466466

467467
#if AVALONIA
468468
VisualChildren.Add((Control)controlHostImpl);
469+
// VisualChildren.Add doesn't itself invalidate layout. Without an explicit
470+
// invalidation here, MeasureOverride/ArrangeOverride below are never called
471+
// for the freshly added child, its Bounds stay default(Rect), and the X11
472+
// INativeControlHostImpl never gets non-zero bounds to map its slot at
473+
// (the symptom in https://github.com/AvaloniaUI/Avalonia.Controls.WebView/issues/5).
474+
InvalidateMeasure();
469475
#elif WPF
470476
IsVisibleChanged += OnIsVisibleChanged;
471477
var visual = (System.Windows.Media.Visual)controlHostImpl;
@@ -507,16 +513,46 @@ protected override ControlSize MeasureOverride(ControlSize availableSize)
507513
// Also WPF doesn't have an equivalent.
508514
// Keeping this hack makes some sort of a compromise, where on Ava 11.3 we have this property and and potentially reset by user if needed.
509515
var measured = base.MeasureOverride(availableSize);
516+
517+
ControlSize resolved;
510518
#if AVALONIA
511519
if (s_setSizing is not null)
512-
return measured;
520+
resolved = measured;
521+
else
522+
#endif
523+
resolved = new ControlSize(
524+
double.IsInfinity(availableSize.Width) ? measured.Width : availableSize.Width,
525+
double.IsInfinity(availableSize.Height) ? measured.Height : availableSize.Height);
526+
527+
#if AVALONIA
528+
// controlHostImpl is added via VisualChildren.Add, which does NOT make it a
529+
// layout participant — Avalonia only measures/arranges visual children that
530+
// the parent explicitly drives. Without this, the inner NativeControlHost's
531+
// Bounds stay default(Rect) and the X11 INativeControlHostImpl never maps
532+
// its slot. See issue #5 / PR #38.
533+
foreach (var child in VisualChildren)
534+
{
535+
if (child is global::Avalonia.Layout.Layoutable layoutable)
536+
layoutable.Measure(resolved);
537+
}
513538
#endif
514539

515-
return new ControlSize(
516-
double.IsInfinity(availableSize.Width) ? measured.Width : availableSize.Width,
517-
double.IsInfinity(availableSize.Height) ? measured.Height : availableSize.Height);
540+
return resolved;
518541
}
519542

543+
#if AVALONIA
544+
protected override ControlSize ArrangeOverride(ControlSize finalSize)
545+
{
546+
var arranged = base.ArrangeOverride(finalSize);
547+
foreach (var child in VisualChildren)
548+
{
549+
if (child is global::Avalonia.Layout.Layoutable layoutable)
550+
layoutable.Arrange(new global::Avalonia.Rect(arranged));
551+
}
552+
return arranged;
553+
}
554+
#endif
555+
520556
private void WithFocusOnLostFocus(object? sender, Core.IWebViewAdapterWithFocus.LostFocusDirection e)
521557
{
522558
Core.WebViewDispatcher.VerifyAccess();

0 commit comments

Comments
 (0)