From 111d5603712a2a0c2cec460e52cd62a89376cd1e Mon Sep 17 00:00:00 2001 From: 0x5BFA <62196528+0x5bfa@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:56:12 +0900 Subject: [PATCH] Fix Linux X11 lifecycle and interop --- .github/workflows/ci.yml | 32 ++ DesktopFlyouts.slnx | 3 + Directory.Build.targets | 2 +- Directory.Packages.props | 3 + src/DesktopFlyouts.Shared/DesktopFlyout.cs | 7 + .../DesktopFlyoutIsland.cs | 6 +- .../DesktopFlyoutIslandTemplateSettings.cs | 4 +- .../DesktopFlyouts.Shared.projitems | 4 +- .../DesktopMenuFlyout.Uno.cs | 3 +- .../DesktopMenuFlyout.cs | 1 + .../Helpers/GeneralHelpers.X11.cs | 180 ++++--- .../Helpers/WindowHelpers.X11.cs | 98 +++- .../Helpers/X11ScreenGeometry.cs | 102 ++++ .../Helpers/X11WindowActivation.cs | 14 + .../SystemTrayIcon.X11.cs | 506 +++++++++++++----- src/DesktopFlyouts.Shared/X11PInvoke.cs | 70 ++- .../XamlIslandHostWindow.X11.cs | 333 +++++++----- .../DesktopFlyouts.Uno.csproj | 44 +- ...DesktopFlyoutIslandTemplateSettings.Uno.cs | 22 + .../DesktopFlyouts.X11.Tests.csproj | 25 + .../X11InteropLayoutTests.cs | 49 ++ .../X11ScreenGeometryTests.cs | 57 ++ .../X11WindowActivationTests.cs | 30 ++ 23 files changed, 1194 insertions(+), 401 deletions(-) create mode 100644 src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs create mode 100644 src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs create mode 100644 src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs create mode 100644 tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj create mode 100644 tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs create mode 100644 tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs create mode 100644 tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9084dc6..fbc41cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ env: WASDK_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Wasdk.Sample.App\DesktopFlyouts.Wasdk.Sample.App.csproj' UWP_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Uwp.Sample.App\DesktopFlyouts.Uwp.Sample.App.csproj' UNO_APP_PROJ_PATH: '${{ github.workspace }}\samples\DesktopFlyouts.Uno.Sample.App\DesktopFlyouts.Uno.Sample.App.csproj' + UNO_LIB_PROJ_PATH: '${{ github.workspace }}/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj' + X11_TEST_PROJ_PATH: '${{ github.workspace }}/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj' jobs: build-wasdk: @@ -109,3 +111,33 @@ jobs: - name: Build the sample app run: dotnet build $env:UNO_APP_PROJ_PATH -c $env:CONFIGURATION -a $env:PLATFORM --no-restore + + build-test-uno-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 9.0.x + 10.0.x + + - name: Restore the Uno library + run: dotnet restore "$UNO_LIB_PROJ_PATH" + + - name: Build the Uno library on Linux + run: dotnet build "$UNO_LIB_PROJ_PATH" -c Release --no-restore + + - name: Run X11 unit tests + run: dotnet test "$X11_TEST_PROJ_PATH" -c Release + + - name: Check changed-file whitespace + if: github.event_name == 'pull_request' + shell: bash + run: git diff --check "$(git merge-base HEAD "origin/$GITHUB_BASE_REF")"..HEAD diff --git a/DesktopFlyouts.slnx b/DesktopFlyouts.slnx index d04044e..d1a9b3f 100644 --- a/DesktopFlyouts.slnx +++ b/DesktopFlyouts.slnx @@ -65,5 +65,8 @@ + + + diff --git a/Directory.Build.targets b/Directory.Build.targets index fae66ae..6df7011 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -5,7 +5,7 @@ Debug;Release x86;x64;arm64 - win-x86;win-x64;win-arm64 + win-x86;win-x64;win-arm64 diff --git a/Directory.Packages.props b/Directory.Packages.props index 4824bf8..1ac4bac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,9 +20,12 @@ + + + diff --git a/src/DesktopFlyouts.Shared/DesktopFlyout.cs b/src/DesktopFlyouts.Shared/DesktopFlyout.cs index 48a5a76..f2ec82c 100644 --- a/src/DesktopFlyouts.Shared/DesktopFlyout.cs +++ b/src/DesktopFlyouts.Shared/DesktopFlyout.cs @@ -1319,7 +1319,11 @@ private void HostWindow_SystemSettingsChanged(object? sender, EventArgs e) } /// +#if HAS_UNO + public new void Dispose() +#else public void Dispose() +#endif { if (_disposed) return; @@ -1349,6 +1353,9 @@ public void Dispose() _host?.Dispose(); IsOpen = false; +#if HAS_UNO + base.Dispose(); +#endif GC.SuppressFinalize(this); } } diff --git a/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs b/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs index 549cc01..c2684d4 100644 --- a/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs +++ b/src/DesktopFlyouts.Shared/DesktopFlyoutIsland.cs @@ -117,7 +117,7 @@ private void DesktopFlyoutIsland_LayoutUpdated(object? sender, object e) private void UpdateTemplateSettings() { -#if WASDK && !HAS_UNO +#if WASDK TemplateSettings.BackdropCornerRadius = new( GetBackdropCornerRadius(CornerRadius.TopLeft), GetBackdropCornerRadius(CornerRadius.TopRight), @@ -126,12 +126,13 @@ private void UpdateTemplateSettings() #endif } -#if WASDK && !HAS_UNO +#if WASDK private static double GetBackdropCornerRadius(double cornerRadius) { return Math.Max(0D, cornerRadius > 0D ? cornerRadius - 1D : 0D); } +#if !HAS_UNO internal void UpdateOwnerBackdrop() { TemplateSettings.SystemBackdrop = _owner is not null && _owner.TryGetTarget(out var owner) @@ -143,6 +144,7 @@ internal void ClearOwnerBackdrop() { TemplateSettings.SystemBackdrop = null; } +#endif #endif private static bool AreClose(Size first, Size second) diff --git a/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs b/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs index b8eb0ef..6d4ee1f 100644 --- a/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs +++ b/src/DesktopFlyouts.Shared/DesktopFlyoutIslandTemplateSettings.cs @@ -20,7 +20,7 @@ namespace DesktopFlyouts /// public partial class DesktopFlyoutIslandTemplateSettings : DependencyObject { -#if WASDK && !HAS_UNO +#if WASDK /// /// Gets the corner radius used by backdrop elements inside the island template. /// @@ -28,12 +28,14 @@ public partial class DesktopFlyoutIslandTemplateSettings : DependencyObject [GeneratedDependencyProperty] public partial CornerRadius BackdropCornerRadius { get; internal set; } +#if !HAS_UNO /// /// Gets the library-created system backdrop used by backdrop elements inside the island template. /// /// The owning flyout's generated system backdrop for this island. [GeneratedDependencyProperty] public partial SystemBackdrop? SystemBackdrop { get; internal set; } +#endif #endif } } diff --git a/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems b/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems index 2aecb33..28f8297 100644 --- a/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems +++ b/src/DesktopFlyouts.Shared/DesktopFlyouts.Shared.projitems @@ -39,6 +39,8 @@ + + @@ -47,4 +49,4 @@ - \ No newline at end of file + diff --git a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs index 428fd2f..92a00f4 100644 --- a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs +++ b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.Uno.cs @@ -309,7 +309,7 @@ private static double Clamp(double value, double min, double max) } /// - public void Dispose() + public new void Dispose() { if (_disposed) return; @@ -332,6 +332,7 @@ public void Dispose() _host?.Dispose(); IsOpen = false; + base.Dispose(); GC.SuppressFinalize(this); } } diff --git a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs index 2f830aa..aae89b9 100644 --- a/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs +++ b/src/DesktopFlyouts.Shared/DesktopMenuFlyout.cs @@ -7,6 +7,7 @@ #if UWP +using Windows.Graphics; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Markup; diff --git a/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs b/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs index c419d6a..87217c7 100644 --- a/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs +++ b/src/DesktopFlyouts.Shared/Helpers/GeneralHelpers.X11.cs @@ -1,92 +1,140 @@ #if HAS_UNO -// Real implementation: detects system theme via D-Bus org.freedesktop.portal.Settings. +// Detects the system theme via D-Bus org.freedesktop.portal.Settings. // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html -using System; -using System.Threading.Tasks; -using Tmds.DBus.Protocol; using DesktopFlyouts.DBus; +using Tmds.DBus.Protocol; + +namespace DesktopFlyouts; -namespace DesktopFlyouts +internal static class GeneralHelpers { - internal static class GeneralHelpers + private const string Service = "org.freedesktop.portal.Desktop"; + private const string ObjectPath = "/org/freedesktop/portal/desktop"; + + private static readonly object SyncRoot = new(); + private static bool _isTaskbarLight; + private static Task? _initializationTask; + private static DBusConnection? _connection; + private static IDisposable? _settingsWatch; + + static GeneralHelpers() { - private const string Service = "org.freedesktop.portal.Desktop"; - private const string ObjectPath = "/org/freedesktop/portal/desktop"; + AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeResources(); + } + + internal static event EventHandler? SystemSettingsChanged; + + internal static bool IsTaskbarLight() + { + ThrowHelper.ThrowIfNotLinux(); + + lock (SyncRoot) + { + _initializationTask ??= InitializeAsync(); + return _isTaskbarLight; + } + } - private static bool? _isTaskbarLight; - private static readonly object _lock = new(); - private static bool _initialized; + internal static bool IsTaskbarColorPrevalenceEnabled() + { + // Linux desktop environments do not have a Windows-style "accent color on taskbar" setting. + return false; + } - internal static bool IsTaskbarLight() + private static async Task InitializeAsync() + { + DBusConnection? connection = null; + IDisposable? settingsWatch = null; + try { - ThrowHelper.ThrowIfNotLinux(); + var sessionAddress = DBusAddress.Session; + if (sessionAddress is null) + return; + + connection = new DBusConnection(sessionAddress); + await connection.ConnectAsync(); - if (!_initialized) + var desktopService = new DBusService(connection, Service); + var settings = desktopService.CreateSettings(ObjectPath); + if (await settings.GetVersionAsync() < 2) + return; + + var result = await settings.ReadOneAsync( + "org.freedesktop.appearance", + "color-scheme"); + UpdateTheme(result.GetUInt32()); + + settingsWatch = await settings.WatchSettingChangedAsync(tuple => { - _ = InitAsync().ConfigureAwait(false); - } + if (tuple is { Namespace: "org.freedesktop.appearance", Key: "color-scheme" }) + UpdateTheme(tuple.Value.GetUInt32()); + }); - lock (_lock) + lock (SyncRoot) { - return _isTaskbarLight ?? false; + _connection = connection; + _settingsWatch = settingsWatch; + connection = null; + settingsWatch = null; } } - - internal static bool IsTaskbarColorPrevalenceEnabled() + catch { - // Linux desktop environments do not have a Windows-style "accent color on taskbar" setting. - return false; + // D-Bus or the portal is unavailable. Keep the dark fallback for this process. } + finally + { + settingsWatch?.Dispose(); + connection?.Dispose(); + } + } - private static async Task InitAsync() + private static void UpdateTheme(uint colorScheme) + { + // 0 = no preference, 1 = dark, 2 = light. + var isLight = colorScheme != 1; + var changed = false; + lock (SyncRoot) { - try + if (_isTaskbarLight != isLight) { - var sessionAddress = DBusAddress.Session; - if (sessionAddress is null) - return; - - var connection = new DBusConnection(sessionAddress); - await connection.ConnectAsync(); - - var desktopService = new DBusService(connection, Service); - var settings = desktopService.CreateSettings(ObjectPath); - - var version = await settings.GetVersionAsync(); - if (version < 2) - return; - - var result = await settings.ReadOneAsync("org.freedesktop.appearance", "color-scheme"); - var colorScheme = result.GetUInt32(); - // 0 = no preference, 1 = dark, 2 = light - lock (_lock) - { - _isTaskbarLight = colorScheme != 1; - _initialized = true; - } - - _ = settings.WatchSettingChangedAsync(tuple => - { - if (tuple is { Namespace: "org.freedesktop.appearance", Key: "color-scheme" }) - { - lock (_lock) - { - _isTaskbarLight = tuple.Value.GetUInt32() != 1; - } - } - }); - } - catch - { - // D-Bus not available or portal not present; fall back to default (dark). - lock (_lock) - { - _isTaskbarLight = false; - _initialized = true; - } + _isTaskbarLight = isLight; + changed = true; } } + + if (changed) + SystemSettingsChanged?.Invoke(null, EventArgs.Empty); + } + + private static void DisposeResources() + { + IDisposable? settingsWatch; + DBusConnection? connection; + lock (SyncRoot) + { + settingsWatch = _settingsWatch; + connection = _connection; + _settingsWatch = null; + _connection = null; + } + + try + { + settingsWatch?.Dispose(); + } + catch + { + } + + try + { + connection?.Dispose(); + } + catch + { + } } } #endif diff --git a/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs b/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs index 1586c97..84d2d66 100644 --- a/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs +++ b/src/DesktopFlyouts.Shared/Helpers/WindowHelpers.X11.cs @@ -4,7 +4,7 @@ // _NET_WM_STRUT_PARTIAL on child windows identifies individual panel positions. using System; -using System.Diagnostics; +using System.Collections.Generic; using System.Drawing; using System.Runtime.InteropServices; using static DesktopFlyouts.X11PInvoke; @@ -52,17 +52,15 @@ internal static Rectangle GetFlyoutWorkAreaRect(Point? anchorPoint = null) return new Rectangle(0, 0, 1920, 1080); } - // Read _NET_WORKAREA (4 CARDINALs: left, top, width, height) + var hasMonitor = TryReadMonitorRect(display, rootWindow, anchorPoint, out var monitorRect); + if (!hasMonitor && TryReadScreenDimensions(display, out var screenRect)) + monitorRect = screenRect; + if (TryReadNetWorkArea(display, rootWindow, out var workArea)) - { - return workArea; - } + return X11ScreenGeometry.ApplyWorkArea(monitorRect, workArea); - // Fallback: full screen - if (TryReadScreenDimensions(display, out var screenRect)) - { - return screenRect; - } + if (monitorRect.Width > 0 && monitorRect.Height > 0) + return monitorRect; // all methods failed, using default 1920x1080 return new Rectangle(0, 0, 1920, 1080); @@ -90,14 +88,11 @@ internal static bool TryGetTaskbarInfoForPoint(Point point, out Rectangle rect, if (rootWindow == IntPtr.Zero) return false; - // Enumerate child windows and check _NET_WM_STRUT_PARTIAL / _NET_WM_STRUT - if (!TryReadNetWorkArea(display, rootWindow, out var workArea)) + // Strut coordinates are relative to the complete root window, not an + // individual XRandR monitor. + if (!TryReadScreenDimensions(display, out var screenRect)) return false; - var screenRect = workArea; - if (TryReadScreenDimensions(display, out var full)) - screenRect = full; - return TryFindPanelAtPoint(display, rootWindow, point, screenRect, out rect, out edge); } finally @@ -111,18 +106,62 @@ private static bool TryReadNetWorkArea(IntPtr display, IntPtr rootWindow, out Re workArea = default; var netWorkAreaAtom = XInternAtom(display, "_NET_WORKAREA", false); - if (ReadCardinals(display, rootWindow, netWorkAreaAtom, out var cardinals) && cardinals.Length >= 4) + if (!ReadCardinals(display, rootWindow, netWorkAreaAtom, out var cardinals)) + return false; + + nuint currentDesktop = 0; + var currentDesktopAtom = XInternAtom(display, "_NET_CURRENT_DESKTOP", false); + if (ReadCardinals(display, rootWindow, currentDesktopAtom, out var desktopValues) && + desktopValues.Length > 0) + currentDesktop = desktopValues[0]; + + return X11ScreenGeometry.TrySelectWorkArea(cardinals, currentDesktop, out workArea); + } + + private static bool TryReadMonitorRect( + IntPtr display, + IntPtr rootWindow, + Point? anchorPoint, + out Rectangle monitorRect) + { + monitorRect = default; + nint monitorsPointer = 0; + try { - workArea = new Rectangle( - (int)cardinals[0], - (int)cardinals[1], - (int)cardinals[2], - (int)cardinals[3]); - return workArea.Width > 0 && workArea.Height > 0; - } + monitorsPointer = XRRGetMonitors(display, rootWindow, true, out var monitorCount); + if (monitorsPointer is 0 || monitorCount <= 0) + return false; - // _NET_WORKAREA not available or empty - return false; + var monitorSize = Marshal.SizeOf(); + var monitors = new List(monitorCount); + for (var i = 0; i < monitorCount; i++) + { + var monitor = Marshal.PtrToStructure( + monitorsPointer + (i * monitorSize)); + if (monitor.width <= 0 || monitor.height <= 0) + continue; + + monitors.Add(new X11MonitorGeometry( + new Rectangle(monitor.x, monitor.y, monitor.width, monitor.height), + monitor.primary != 0)); + } + + monitorRect = X11ScreenGeometry.SelectMonitor(monitors, anchorPoint); + return monitorRect.Width > 0 && monitorRect.Height > 0; + } + catch (DllNotFoundException) + { + return false; + } + catch (EntryPointNotFoundException) + { + return false; + } + finally + { + if (monitorsPointer is not 0) + XRRFreeMonitors(monitorsPointer); + } } private static bool TryReadScreenDimensions(IntPtr display, out Rectangle screenRect) @@ -306,11 +345,12 @@ private static bool ReadCardinals(IntPtr display, nint window, nint atom, out nu var result = XGetWindowProperty( display, (nuint)window, (nuint)atom, 0, 256, - false, 6, // CARDINAL = 6 - out var actualType, out _, + false, XA_CARDINAL, + out var actualType, out var actualFormat, out var nItems, out _, out var prop); - if (result != 0 || actualType is 0 || nItems is 0 || prop is 0) + if (result != 0 || actualType != XA_CARDINAL || actualFormat != 32 || + nItems is 0 || nItems > int.MaxValue || prop is 0) return false; try diff --git a/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs b/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs new file mode 100644 index 0000000..b369344 --- /dev/null +++ b/src/DesktopFlyouts.Shared/Helpers/X11ScreenGeometry.cs @@ -0,0 +1,102 @@ +#if HAS_UNO +using System.Drawing; + +namespace DesktopFlyouts; + +internal readonly record struct X11MonitorGeometry(Rectangle Bounds, bool IsPrimary); + +internal static class X11ScreenGeometry +{ + internal static Rectangle SelectMonitor( + IReadOnlyList monitors, + Point? anchorPoint) + { + if (monitors.Count is 0) + return default; + + if (anchorPoint is { } anchor) + { + foreach (var monitor in monitors) + { + if (monitor.Bounds.Contains(anchor)) + return monitor.Bounds; + } + + var nearest = monitors[0].Bounds; + var nearestDistance = GetSquaredDistance(anchor, nearest); + for (var i = 1; i < monitors.Count; i++) + { + var candidate = monitors[i].Bounds; + var candidateDistance = GetSquaredDistance(anchor, candidate); + if (candidateDistance < nearestDistance) + { + nearest = candidate; + nearestDistance = candidateDistance; + } + } + + return nearest; + } + + foreach (var monitor in monitors) + { + if (monitor.IsPrimary) + return monitor.Bounds; + } + + return monitors[0].Bounds; + } + + internal static bool TrySelectWorkArea( + ReadOnlySpan values, + nuint currentDesktop, + out Rectangle workArea) + { + workArea = default; + if (values.Length < 4) + return false; + + var desktopOffset = currentDesktop <= int.MaxValue / 4 + ? (int)currentDesktop * 4 + : 0; + if (desktopOffset < 0 || desktopOffset + 3 >= values.Length) + desktopOffset = 0; + + workArea = new Rectangle( + ToCoordinate(values[desktopOffset]), + ToCoordinate(values[desktopOffset + 1]), + ToDimension(values[desktopOffset + 2]), + ToDimension(values[desktopOffset + 3])); + return workArea.Width > 0 && workArea.Height > 0; + } + + internal static Rectangle ApplyWorkArea(Rectangle monitor, Rectangle workArea) + { + if (monitor.Width <= 0 || monitor.Height <= 0) + return workArea; + + if (workArea.Width <= 0 || workArea.Height <= 0) + return monitor; + + var intersection = Rectangle.Intersect(monitor, workArea); + return intersection.Width > 0 && intersection.Height > 0 + ? intersection + : monitor; + } + + private static long GetSquaredDistance(Point point, Rectangle rectangle) + { + var nearestX = Math.Clamp(point.X, rectangle.Left, rectangle.Right - 1); + var nearestY = Math.Clamp(point.Y, rectangle.Top, rectangle.Bottom - 1); + var deltaX = (long)point.X - nearestX; + var deltaY = (long)point.Y - nearestY; + return (deltaX * deltaX) + (deltaY * deltaY); + } + + private static int ToCoordinate(nuint value) + => unchecked((int)(uint)value); + + private static int ToDimension(nuint value) + => value > int.MaxValue ? int.MaxValue : (int)value; +} +#endif diff --git a/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs b/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs new file mode 100644 index 0000000..3d69fe0 --- /dev/null +++ b/src/DesktopFlyouts.Shared/Helpers/X11WindowActivation.cs @@ -0,0 +1,14 @@ +#if HAS_UNO +namespace DesktopFlyouts; + +internal static class X11WindowActivation +{ + internal static bool UsesOverrideRedirect(DesktopFlyoutActivationMode activationMode) + => activationMode is DesktopFlyoutActivationMode.NeverActivate; + + internal static bool ShouldRequestActivation( + DesktopFlyoutActivationMode activationMode, + bool activate) + => activate && activationMode is DesktopFlyoutActivationMode.Activate; +} +#endif diff --git a/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs b/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs index 8789311..e901e2f 100644 --- a/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs +++ b/src/DesktopFlyouts.Shared/SystemTrayIcon.X11.cs @@ -19,14 +19,19 @@ namespace DesktopFlyouts; public class SystemTrayIcon : IDisposable { readonly string _id; + readonly object _lifecycleLock = new(); + readonly SynchronizationContext? _eventContext; DBusConnection? _connection; DBus.DBus? _dBus; StatusNotifierWatcher? _statusNotifierWatcher; X11StatusNotifierItemHandler? _sniHandler; IDisposable? _serviceWatchDisposable; - string? _sysTrayServiceName; + CancellationTokenSource? _connectionCancellation; + int _connectionGeneration; bool _isDisposed; bool _serviceConnected; + bool _isRegistered; + bool _registrationPending; bool _isVisible; (int, int, byte[]) _currentIcon = (1, 1, new byte[] { 255, 0, 0, 0 }); @@ -48,8 +53,8 @@ public SystemTrayIcon(string iconPath, string tooltip, System.Guid id) /// The tooltip text. /// The stable identifier for the tray icon. /// - /// Construction prepares the icon resources and begins D-Bus initialization. The - /// icon is not registered with the StatusNotifierWatcher until is called. + /// Construction prepares the icon resources. D-Bus initialization and registration + /// begin when is called. /// public SystemTrayIcon(string iconPath, string tooltip, string id) { @@ -57,44 +62,37 @@ public SystemTrayIcon(string iconPath, string tooltip, string id) _id = id; _iconPath = iconPath; _tooltip = tooltip; + _eventContext = SynchronizationContext.Current; - // Render the initial icon synchronously so that any SetIcon() call - // during async initialization is not overwritten by the captured path. _currentIcon = RenderIcon(iconPath); - - _ = InitAsync(); - - async Task InitAsync() - { - await InitializeAsync(); - _sniHandler!.ActivationDelegate += OnActivation; - _sniHandler!.ContextMenuDelegate += OnContextMenu; - _sniHandler!.SecondaryActivateDelegate += OnSecondaryActivate; - _sniHandler!.ScrollDelegate += OnScroll; - } } void OnActivation(int x, int y) { - LeftClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))); + RaiseOnCapturedContext(() => + LeftClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)))); } void OnContextMenu(int x, int y) { - RightClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))); + RaiseOnCapturedContext(() => + RightClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)))); } void OnSecondaryActivate(int x, int y) { - MiddleClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y))); + RaiseOnCapturedContext(() => + MiddleClicked?.Invoke(this, new MouseEventReceivedEventArgs(new(x, y)))); } void OnScroll(int delta, string orientation) { - Scrolled?.Invoke(this, new MouseScrollEventReceivedEventArgs( - delta, - orientation == "horizontal" ? MouseScrollOrientation.Horizontal : MouseScrollOrientation.Vertical - )); + RaiseOnCapturedContext(() => + Scrolled?.Invoke(this, new MouseScrollEventReceivedEventArgs( + delta, + orientation == "horizontal" + ? MouseScrollOrientation.Horizontal + : MouseScrollOrientation.Vertical))); } // ─── Public API ──────────────────────────────────────────────── @@ -109,15 +107,24 @@ void OnScroll(int delta, string orientation) /// public string Tooltip { - get => _tooltip; + get + { + lock (_lifecycleLock) + return _tooltip; + } set { - if (_isDisposed) - throw new ObjectDisposedException(nameof(SystemTrayIcon)); + X11StatusNotifierItemHandler? handler; + lock (_lifecycleLock) + { + if (_isDisposed) + throw new ObjectDisposedException(nameof(SystemTrayIcon)); - _tooltip = value; - if (_sniHandler?.Connection is not null) - _sniHandler.SetTitleAndTooltip(value ?? ""); + _tooltip = value; + handler = _isRegistered ? _sniHandler : null; + } + + handler?.SetTitleAndTooltip(value); } } @@ -136,8 +143,25 @@ public string Tooltip /// public void SetIcon(string iconPath) { - _iconPath = iconPath; - UpdateIcon(iconPath); + lock (_lifecycleLock) + { + if (_isDisposed) + throw new ObjectDisposedException(nameof(SystemTrayIcon)); + } + + var icon = RenderIcon(iconPath); + X11StatusNotifierItemHandler? handler; + lock (_lifecycleLock) + { + if (_isDisposed) + throw new ObjectDisposedException(nameof(SystemTrayIcon)); + + _iconPath = iconPath; + _currentIcon = icon; + handler = _isRegistered ? _sniHandler : null; + } + + handler?.SetIcon(icon); } /// @@ -149,12 +173,35 @@ public void SetIcon(string iconPath) /// public void Show() { - if (_isDisposed) - throw new ObjectDisposedException(nameof(SystemTrayIcon)); + int generation; + bool initialize; + bool register; + CancellationToken initializationToken = default; + lock (_lifecycleLock) + { + if (_isDisposed) + throw new ObjectDisposedException(nameof(SystemTrayIcon)); - _isVisible = true; - if (_serviceConnected) - _ = CreateTrayIconAsync(); + _isVisible = true; + initialize = _connection is null && _connectionCancellation is null; + if (initialize) + { + _connectionCancellation = new CancellationTokenSource(); + generation = ++_connectionGeneration; + initializationToken = _connectionCancellation.Token; + } + else + { + generation = _connectionGeneration; + } + + register = _serviceConnected && !_isRegistered && !_registrationPending; + } + + if (initialize) + _ = InitializeAsync(generation, initializationToken); + else if (register) + _ = CreateTrayIconAsync(generation); } /// @@ -166,11 +213,7 @@ public void Show() /// public void Destroy() { - if (_isDisposed) - throw new ObjectDisposedException(nameof(SystemTrayIcon)); - - _isVisible = false; - DestroyTrayIcon(); + CloseConnection(disposeObject: false); } /// @@ -183,32 +226,15 @@ public void Destroy() /// public void Dispose() { - if (_isDisposed) - return; - - _isDisposed = true; - _isVisible = false; - - DestroyTrayIcon(); - - _serviceWatchDisposable?.Dispose(); - _serviceWatchDisposable = null; - _connection?.Dispose(); - _connection = null; - _dBus = null; - _statusNotifierWatcher = null; - _serviceConnected = false; - + CloseConnection(disposeObject: true); GC.SuppressFinalize(this); } - ~SystemTrayIcon() => Dispose(); - /// /// Gets or sets whether the tray icon is visible. /// /// if the tray icon is visible; otherwise, - /// . The default is . + /// . The default is . /// /// If the tray icon is already registered with the StatusNotifierWatcher, setting this property /// updates the icon state immediately. Otherwise, the value is recorded and applied when @@ -216,7 +242,11 @@ public void Dispose() /// public bool IsVisible { - get => _isVisible; + get + { + lock (_lifecycleLock) + return _isVisible; + } set { if (value) Show(); @@ -267,147 +297,355 @@ public bool IsVisible // ─── D-Bus Initialization ───────────────────────────────────── - async Task InitializeAsync(CancellationToken cancellationToken = default) + async Task InitializeAsync(int generation, CancellationToken cancellationToken) { - if (_isDisposed) - throw new ObjectDisposedException(nameof(SystemTrayIcon)); - - _connection = new DBusConnection(DBusAddress.Session!); - await _connection.ConnectAsync(); + DBusConnection? connection = null; + X11StatusNotifierItemHandler? handler = null; + var attached = false; + try + { + var sessionAddress = DBusAddress.Session; + if (sessionAddress is null) + return; + + connection = new DBusConnection(sessionAddress); + await connection.ConnectAsync(); + cancellationToken.ThrowIfCancellationRequested(); + + var dBus = new DBus.DBus(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus"); + handler = new X11StatusNotifierItemHandler(connection, _id, _id); + handler.ActivationDelegate += OnActivation; + handler.ContextMenuDelegate += OnContextMenu; + handler.SecondaryActivateDelegate += OnSecondaryActivate; + handler.ScrollDelegate += OnScroll; + connection.AddMethodHandler(handler); + + lock (_lifecycleLock) + { + if (_isDisposed || !_isVisible || generation != _connectionGeneration) + return; - _dBus = new(_connection, "org.freedesktop.DBus", "/org/freedesktop/DBus"); + _connection = connection; + _dBus = dBus; + _sniHandler = handler; + attached = true; + } - _sniHandler = new X11StatusNotifierItemHandler(_connection, _id, _id); + connection = null; + handler = null; + await WatchAsync(generation, dBus, cancellationToken); + } + catch (OperationCanceledException) + { + } + catch + { + } + finally + { + if (!attached) + { + DetachHandler(handler); + try + { + if (connection is not null && handler is not null) + connection.RemoveMethodHandler(handler.Path); + } + catch + { + } - await WatchAsync(cancellationToken); + connection?.Dispose(); + lock (_lifecycleLock) + { + if (generation == _connectionGeneration && _connection is null) + { + _connectionCancellation?.Dispose(); + _connectionCancellation = null; + } + } + } + } } - async Task WatchAsync(CancellationToken cancellationToken) + async Task WatchAsync( + int generation, + DBus.DBus dBus, + CancellationToken cancellationToken) { + IDisposable? watch = null; try { - _serviceWatchDisposable = await _dBus!.WatchNameOwnerChangedAsync( + watch = await dBus.WatchNameOwnerChangedAsync( change => { if (change.A0 == "org.kde.StatusNotifierWatcher") - OnNameChange(change.A0, change.A2); + OnNameChange(generation, change.A0, change.A2); }, emitOnCapturedContext: false); + cancellationToken.ThrowIfCancellationRequested(); - var nameOwner = await _dBus.GetNameOwnerAsync("org.kde.StatusNotifierWatcher"); - OnNameChange("org.kde.StatusNotifierWatcher", nameOwner); + lock (_lifecycleLock) + { + if (_isDisposed || generation != _connectionGeneration) + return; + + _serviceWatchDisposable = watch; + watch = null; + } + + try + { + var nameOwner = await dBus.GetNameOwnerAsync("org.kde.StatusNotifierWatcher"); + OnNameChange(generation, "org.kde.StatusNotifierWatcher", nameOwner); + } + catch (DBusErrorReplyException ex) when ( + ex.ErrorName == "org.freedesktop.DBus.Error.NameHasNoOwner") + { + } } - catch (DBusErrorReplyException ex) when (ex.ErrorName == "org.freedesktop.DBus.Error.NameHasNoOwner") + catch (OperationCanceledException) { } catch { - _serviceWatchDisposable = null; + } + finally + { + watch?.Dispose(); } } - void OnNameChange(string name, string? newOwner) + void OnNameChange(int generation, string name, string? newOwner) { - if (_isDisposed || _connection is null || name != "org.kde.StatusNotifierWatcher") - return; - - if (!_serviceConnected && !string.IsNullOrEmpty(newOwner)) + var register = false; + lock (_lifecycleLock) { - _serviceConnected = true; - _statusNotifierWatcher = new StatusNotifierWatcher(_connection, "org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher"); - - // Re-register the handler on the existing connection. - // No need to DestroyTrayIcon() here — the handler was already - // removed when the watcher went away, and DestroyTrayIcon() - // would dispose the connection we still need. - _connection.RemoveMethodHandler(_sniHandler!.Path); - _connection.AddMethodHandler(_sniHandler); + if (_isDisposed || generation != _connectionGeneration || _connection is null || + name != "org.kde.StatusNotifierWatcher") + return; - if (_isVisible) - _ = CreateTrayIconAsync(); - } - else if (_serviceConnected && string.IsNullOrEmpty(newOwner)) - { - DestroyTrayIcon(); - _serviceConnected = false; + if (!string.IsNullOrEmpty(newOwner)) + { + _serviceConnected = true; + _isRegistered = false; + _registrationPending = false; + _statusNotifierWatcher = new StatusNotifierWatcher( + _connection, + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher"); + register = _isVisible; + } + else + { + _serviceConnected = false; + _isRegistered = false; + _registrationPending = false; + _statusNotifierWatcher = null; + } } + + if (register) + _ = CreateTrayIconAsync(generation); } - async Task CreateTrayIconAsync() + async Task CreateTrayIconAsync(int generation) { - if (_connection is null || !_serviceConnected || _isDisposed || _statusNotifierWatcher is null) - return; + DBusConnection connection; + StatusNotifierWatcher watcher; + X11StatusNotifierItemHandler handler; + lock (_lifecycleLock) + { + if (_isDisposed || !_isVisible || !_serviceConnected || _isRegistered || + _registrationPending || + generation != _connectionGeneration || _connection is null || + _statusNotifierWatcher is null || _sniHandler is null) + return; + + _registrationPending = true; + connection = _connection; + watcher = _statusNotifierWatcher; + handler = _sniHandler; + } try { - _connection.RemoveMethodHandler(_sniHandler!.Path); - _connection.AddMethodHandler(_sniHandler); + await watcher.RegisterStatusNotifierItemAsync(connection.UniqueName!); - await RegisterWithStatusNotifierWatcherAsync(); + string tooltip; + (int, int, byte[]) icon; + CancellationToken cancellationToken; + lock (_lifecycleLock) + { + if (_isDisposed || !_isVisible || !_serviceConnected || + generation != _connectionGeneration || + !ReferenceEquals(connection, _connection) || + !ReferenceEquals(watcher, _statusNotifierWatcher)) + return; + + _registrationPending = false; + _isRegistered = true; + tooltip = _tooltip; + icon = _currentIcon; + cancellationToken = _connectionCancellation?.Token ?? default; + } - ReEmitSignalsForGnomeShellAsync(); + handler.SetTitleAndTooltip(tooltip); + handler.SetIcon(icon); + _ = ReEmitSignalsForGnomeShellAsync(generation, cancellationToken); } catch { + lock (_lifecycleLock) + { + if (generation == _connectionGeneration) + _registrationPending = false; + } } } - async Task RegisterWithStatusNotifierWatcherAsync() + async Task ReEmitSignalsForGnomeShellAsync( + int generation, + CancellationToken cancellationToken) { - _sysTrayServiceName = _connection!.UniqueName!; - try { - await _statusNotifierWatcher!.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + await Task.Delay(GnomeShellInitialDelayMs, cancellationToken); + ReEmitIconIfCurrent(generation); + + await Task.Delay(GnomeShellSecondDelayMs, cancellationToken); + ReEmitIconIfCurrent(generation); + } + catch (OperationCanceledException) + { } catch { - throw; } - - _sniHandler!.SetTitleAndTooltip(_tooltip); - _sniHandler.SetIcon(_currentIcon); } - void ReEmitSignalsForGnomeShellAsync() + void ReEmitIconIfCurrent(int generation) { - _ = Task.Run(async () => + X11StatusNotifierItemHandler? handler; + (int, int, byte[]) icon; + lock (_lifecycleLock) { - await Task.Delay(GnomeShellInitialDelayMs); - if (!_isDisposed && _sniHandler?.Connection is not null) - _sniHandler.SetIcon(_currentIcon); + if (_isDisposed || !_isVisible || !_isRegistered || + generation != _connectionGeneration) + return; + + handler = _sniHandler; + icon = _currentIcon; + } - await Task.Delay(GnomeShellSecondDelayMs); - if (!_isDisposed && _sniHandler?.Connection is not null) - _sniHandler.SetIcon(_currentIcon); - }); + handler?.SetIcon(icon); } - void DestroyTrayIcon() + void CloseConnection(bool disposeObject) { - if (_sniHandler is null) - return; + CancellationTokenSource? cancellation; + IDisposable? watch; + DBusConnection? connection; + X11StatusNotifierItemHandler? handler; + lock (_lifecycleLock) + { + if (_isDisposed) + { + if (disposeObject) + return; + throw new ObjectDisposedException(nameof(SystemTrayIcon)); + } + + if (disposeObject) + _isDisposed = true; + + _isVisible = false; + _connectionGeneration++; + cancellation = _connectionCancellation; + watch = _serviceWatchDisposable; + connection = _connection; + handler = _sniHandler; + _connectionCancellation = null; + _serviceWatchDisposable = null; + _connection = null; + _dBus = null; + _statusNotifierWatcher = null; + _sniHandler = null; + _serviceConnected = false; + _isRegistered = false; + _registrationPending = false; + } try { - _connection?.RemoveMethodHandler(_sniHandler.Path); + cancellation?.Cancel(); + } + catch + { + } + cancellation?.Dispose(); + + DetachHandler(handler); + try + { + if (connection is not null && handler is not null) + connection.RemoveMethodHandler(handler.Path); + } + catch + { + } + + try + { + watch?.Dispose(); } catch { } - } - // ─── Icon Management ────────────────────────────────────────── + try + { + // Closing the unique D-Bus name is the StatusNotifierItem protocol's + // unregister operation. The watcher removes the item automatically. + connection?.Dispose(); + } + catch + { + } + } - void UpdateIcon(string iconPath) + void DetachHandler(X11StatusNotifierItemHandler? handler) { - if (_isDisposed) - throw new ObjectDisposedException(nameof(SystemTrayIcon)); + if (handler is null) + return; - _currentIcon = RenderIcon(iconPath); + handler.ActivationDelegate -= OnActivation; + handler.ContextMenuDelegate -= OnContextMenu; + handler.SecondaryActivateDelegate -= OnSecondaryActivate; + handler.ScrollDelegate -= OnScroll; + } - if (_sniHandler?.Connection is not null) - _sniHandler.SetIcon(_currentIcon); + void RaiseOnCapturedContext(Action action) + { + if (_eventContext is null || ReferenceEquals(SynchronizationContext.Current, _eventContext)) + { + bool invoke; + lock (_lifecycleLock) + invoke = !_isDisposed; + if (invoke) + action(); + } + else + { + _eventContext.Post(_ => + { + bool invoke; + lock (_lifecycleLock) + invoke = !_isDisposed; + if (invoke) + action(); + }, null); + } } // ─── Icon Rendering ─────────────────────────────────────────── diff --git a/src/DesktopFlyouts.Shared/X11PInvoke.cs b/src/DesktopFlyouts.Shared/X11PInvoke.cs index bb0780b..7f65837 100644 --- a/src/DesktopFlyouts.Shared/X11PInvoke.cs +++ b/src/DesktopFlyouts.Shared/X11PInvoke.cs @@ -7,6 +7,7 @@ partial class X11PInvoke { private const string LibX11 = "libX11.so.6"; private const string LibXext = "libXext.so.6"; + private const string LibXrandr = "libXrandr.so.2"; [LibraryImport(LibX11)] public static partial nint XOpenDisplay(nint display); @@ -20,10 +21,32 @@ partial class X11PInvoke [LibraryImport(LibX11, StringMarshalling = StringMarshalling.Utf8)] public static partial nint XInternAtom(nint display, string atomName, [MarshalAs(UnmanagedType.Bool)] bool onlyIfExists); - [LibraryImport(LibX11)] - public static partial int XChangeProperty( + [LibraryImport(LibX11, EntryPoint = "XChangeProperty")] + private static partial int XChangePropertyNative( nint display, nint window, nint property, nint type, - int format, PropertyMode mode, byte[] data, int nelements); + int format, PropertyMode mode, nint data, int nelements); + + public static unsafe int XChangeProperty32( + nint display, + nint window, + nint property, + nint type, + PropertyMode mode, + ReadOnlySpan data) + { + fixed (nuint* dataPointer = data) + { + return XChangePropertyNative( + display, + window, + property, + type, + 32, + mode, + (nint)dataPointer, + data.Length); + } + } [LibraryImport(LibX11)] public static partial int XMapWindow(nint display, nint window); @@ -75,8 +98,8 @@ public static partial int XTranslateCoordinates( [LibraryImport(LibXext)] public static partial int XShapeCombineRegion(nint display, nint window, int destKind, int xOff, int yOff, nint srcRegion, int op); - - + + [LibraryImport(LibX11)] public static partial nint XCreateRegion(); @@ -89,6 +112,19 @@ public static partial int XShapeCombineRegion(nint display, nint window, int des [LibraryImport(LibX11)] public static partial int XSetInputFocus(nint display, nint focus, int revertTo, nint time); + [LibraryImport(LibX11)] + public static partial int XGetInputFocus(nint display, out nint focusReturn, out int revertToReturn); + + [LibraryImport(LibXrandr)] + public static partial nint XRRGetMonitors( + nint display, + nint window, + [MarshalAs(UnmanagedType.Bool)] bool getActive, + out int monitorCount); + + [LibraryImport(LibXrandr)] + public static partial void XRRFreeMonitors(nint monitors); + [LibraryImport(LibX11)] public static partial int XGetWindowProperty( nint display, @@ -126,13 +162,25 @@ public struct XSetWindowAttributes { public nint background_pixmap, background_pixel, border_pixmap, border_pixel; public int bit_gravity, win_gravity, backing_store; - public uint backing_planes, backing_pixel; + public nuint backing_planes, backing_pixel; public int save_under; public nint event_mask, do_not_propagate_mask; public int override_redirect; public nint colormap, cursor; } + [StructLayout(LayoutKind.Sequential)] + public struct XRRMonitorInfo + { + public nuint name; + public int primary; + public int automatic; + public int noutput; + public int x, y, width, height; + public int mwidth, mheight; + public nint outputs; + } + [StructLayout(LayoutKind.Explicit)] public struct XEvent { @@ -144,7 +192,7 @@ public struct XEvent public struct XClientMessageEvent { public int type; - public long serial; + public nuint serial; public int send_event; public nint display; public nint window; @@ -152,7 +200,7 @@ public struct XClientMessageEvent public int format; public nint ptr1, ptr2, ptr3, ptr4, ptr5; } - + [StructLayout(LayoutKind.Sequential)] public struct XRegionRectangle { @@ -162,15 +210,19 @@ public struct XRegionRectangle public const int ClientMessage = 33; public const long StructureNotifyMask = 1L << 17; + public const long SubstructureNotifyMask = 1L << 19; + public const long SubstructureRedirectMask = 1L << 20; public const long FocusChangeMask = 1L << 21; public const long PropertyChangeMask = 1L << 22; public const nint CWBackPixmap = 1 << 0; public const nint CWOverrideRedirect = 1 << 9; // Predefined X11 atom ID for type ATOM. public const nint XA_ATOM = 4; + public const nuint XA_CARDINAL = 6; + public const nuint XA_WINDOW = 33; // background_pixmap values public const nint None = 0; // no background — transparent under compositors public const nint ParentRelative = 1; // copy parent's background } -#endif \ No newline at end of file +#endif diff --git a/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs b/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs index cbe4cd1..d04bbd8 100644 --- a/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs +++ b/src/DesktopFlyouts.Shared/XamlIslandHostWindow.X11.cs @@ -28,6 +28,7 @@ internal partial class XamlIslandHostWindow : IDisposable private bool _disposed; private bool _focused; private bool _focusMonitoring; + private nint _previousActiveWindow; private readonly DispatcherTimer _focusTimer; private DesktopFlyoutActivationMode _activationMode = DesktopFlyoutActivationMode.Activate; @@ -41,8 +42,23 @@ internal Rect WindowSize return default; var attrs = new XWindowAttributes(); - XGetWindowAttributes(_display, _x11Window, ref attrs); - return new Rect(0, 0, attrs.Width, attrs.Height); + if (XGetWindowAttributes(_display, _x11Window, ref attrs) is 0) + return default; + + var rootWindow = XDefaultRootWindow(_display); + if (rootWindow is not 0 && + XTranslateCoordinates( + _display, + _x11Window, + rootWindow, + 0, + 0, + out var rootX, + out var rootY, + out _) is not 0) + return new Rect(rootX, rootY, attrs.Width, attrs.Height); + + return new Rect(attrs.X, attrs.Y, attrs.Width, attrs.Height); } } @@ -80,7 +96,8 @@ internal XamlIslandHostWindow() _window = new TransparentWindow(); _window.Title = "DesktopFlyoutHost"; - var nativeWindow = (X11NativeWindow)Uno.UI.Xaml.WindowHelper.GetNativeWindow(_window); + var nativeWindow = Uno.UI.Xaml.WindowHelper.GetNativeWindow(_window) as X11NativeWindow + ?? throw new InvalidOperationException("Uno did not create an X11 native window."); if ((_display = XOpenDisplay(0)) is 0) throw new InvalidOperationException("Failed to open X11 display."); @@ -111,6 +128,7 @@ internal XamlIslandHostWindow() // Subscribe to window events. _window.Closed += OnWindowClosed; _window.Activated += OnWindowActivated; + GeneralHelpers.SystemSettingsChanged += GeneralHelpers_SystemSettingsChanged; _focusTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; _focusTimer.Tick += FocusTimer_Tick; @@ -128,21 +146,27 @@ private void ConfigureWindow() if (_display is 0 || _x11Window is 0) return; - // Set override_redirect on BOTH windows so the WM doesn't reposition or decorate them. - // Uno creates two windows (RootX11Window + TopX11Window child) and maps both in ShowCore(). - // Both need override_redirect to prevent KWin from grabbing and repositioning. + // NeverActivate windows bypass the window manager so it cannot assign focus. + // Activate and NoActivateOnOpen remain WM-managed and differ only in whether + // an activation request is sent when they are mapped. foreach (var window in managedWindows) - SetOverrideRedirect(window, true); + SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(_activationMode)); // Remove window decorations by setting Motif WM hints (no title, no resize, no close). - var motifHints = new byte[5 * 4]; // 5 x uint32 - BitConverter.TryWriteBytes(new Span(motifHints, 0, 4), 2); // MWM_HINTS_DECORATIONS - BitConverter.TryWriteBytes(new Span(motifHints, 4, 4), 0); // no decorations - BitConverter.TryWriteBytes(new Span(motifHints, 8, 4), 0); // functions - BitConverter.TryWriteBytes(new Span(motifHints, 12, 4), 0); // input_mode - BitConverter.TryWriteBytes(new Span(motifHints, 16, 4), 0); // status - XChangeProperty(_display, _x11Window, _motifWmHintsAtom, - _motifWmHintsAtom, 32, PropertyMode.Replace, motifHints, 5); + ReadOnlySpan motifHints = [ + 2, // MWM_HINTS_DECORATIONS + 0, // functions + 0, // decorations + 0, // input_mode + 0, // status + ]; + XChangeProperty32( + _display, + _x11Window, + _motifWmHintsAtom, + _motifWmHintsAtom, + PropertyMode.Replace, + motifHints); // Set EWMH hints on BOTH windows — task managers may read either. foreach (var wnd in managedWindows) @@ -156,23 +180,30 @@ private void ConfigureWindow() private void SetNetWmState(nint window) { - var stateAtoms = new int[] - { - (int)_netWmStateSkipTaskbarAtom, - (int)_netWmStateSkipPagerAtom, - (int)_netWmStateAboveAtom, - }; - var stateBytes = new byte[stateAtoms.Length * 4]; - Buffer.BlockCopy(stateAtoms, 0, stateBytes, 0, stateBytes.Length); - XChangeProperty(_display, window, _netWmStateAtom, XA_ATOM, 32, PropertyMode.Replace, stateBytes, stateAtoms.Length); + ReadOnlySpan stateAtoms = [ + (nuint)_netWmStateSkipTaskbarAtom, + (nuint)_netWmStateSkipPagerAtom, + (nuint)_netWmStateAboveAtom, + ]; + XChangeProperty32( + _display, + window, + _netWmStateAtom, + XA_ATOM, + PropertyMode.Replace, + stateAtoms); } private void SetNetWmWindowType(nint window) { - var typeAtomArr = new int[] { (int)_netWmWindowTypeDockAtom }; - var typeBytes = new byte[4]; - Buffer.BlockCopy(typeAtomArr, 0, typeBytes, 0, 4); - XChangeProperty(_display, window, _netWmWindowTypeAtom, XA_ATOM, 32, PropertyMode.Replace, typeBytes, 1); + ReadOnlySpan typeAtoms = [(nuint)_netWmWindowTypeDockAtom]; + XChangeProperty32( + _display, + window, + _netWmWindowTypeAtom, + XA_ATOM, + PropertyMode.Replace, + typeAtoms); } internal void SetContent(object content) @@ -182,18 +213,19 @@ internal void SetContent(object content) internal void PreserveActivationState() { - // On X11, activation state preservation is handled at the window manager level. - // This is a no-op for now; the flyout window will activate normally. + _previousActiveWindow = TryGetActiveWindow(out var activeWindow) && + !managedWindows.Contains(activeWindow) + ? activeWindow + : 0; } internal void RestoreActivationState() { - // On X11, restore activation by raising the window. - if (_display is 0 || _x11Window is 0) + if (_display is 0 || _previousActiveWindow is 0) return; - XRaiseWindow(_display, _x11Window); - XFlush(_display); + if (!TryGetActiveWindow(out var activeWindow) || activeWindow != _previousActiveWindow) + RequestActivation(_previousActiveWindow); } internal void MoveAndResize(RectInt32 rect, bool activate = true) @@ -212,28 +244,8 @@ internal void MoveAndResize(RectInt32 rect, bool activate = true) XMoveWindow(_display, _x11Window, rect.X, rect.Y); XSync(_display, false); - if (activate) - { - // Send _NET_ACTIVE_WINDOW message to request activation. - var xclient = new XEvent - { - type = ClientMessage, - xclient = new XClientMessageEvent - { - type = ClientMessage, - display = _display, - window = _x11Window, - message_type = _netActiveWindowAtom, - format = 32, - ptr1 = (nint)2, // _NET_WM_STATE_REQUEST - ptr2 = 0, - ptr3 = 0, - } - }; - var rootWindow = XDefaultRootWindow(_display); - XSendEvent(_display, rootWindow, false, - (nint)(StructureNotifyMask | FocusChangeMask), ref xclient); - } + if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate)) + RequestActivation(_x11Window); XSync(_display, false); } @@ -268,10 +280,9 @@ internal void Maximize(System.Drawing.Rectangle workArea, bool activate = true) XMoveWindow(_display, _x11Window, x, y); XSync(_display, false); - if (activate) - { - XRaiseWindow(_display, _x11Window); - } + XRaiseWindow(_display, _x11Window); + if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate)) + RequestActivation(_x11Window); XSync(_display, false); } @@ -325,10 +336,10 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true) SetWindowOpacity(wnd, 0); XFlush(_display); - // Re-apply override_redirect immediately before map on BOTH windows. - // Uno's ShowCore() maps both windows; KWin grabs TopX11Window if it lacks override_redirect. + // Re-apply the activation policy immediately before Uno maps both its + // root and rendering windows. foreach (var window in managedWindows) - SetOverrideRedirect(window, true); + SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(_activationMode)); // Capture current position so we can re-apply it after map. var currentAttrs = new XWindowAttributes(); @@ -339,7 +350,7 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true) // Map both RootX11Window and TopX11Window (child where Skia renders). foreach (var wnd in managedWindows) XMapWindow(_display, wnd); - + // Re-apply position after map — KWin overrides our position during XMapWindow. XMoveWindow(_display, _x11Window, savedX, savedY); @@ -350,10 +361,9 @@ internal ValueTask UpdateWindowVisibility(bool isVisible, bool activate = true) SetNetWmWindowType(wnd); } - if (activate) - { - XRaiseWindow(_display, _x11Window); - } + XRaiseWindow(_display, _x11Window); + if (X11WindowActivation.ShouldRequestActivation(_activationMode, activate)) + RequestActivation(_x11Window); XSync(_display, false); // Restore opacity after Skia has presented at least one frame. @@ -438,17 +448,15 @@ internal void SetActivationMode(DesktopFlyoutActivationMode activationMode) if (_display is 0 || _x11Window is 0) return; - // For NeverActivate, we set override_redirect so the WM doesn't manage focus. foreach (var window in managedWindows) - SetOverrideRedirect(window, activationMode == DesktopFlyoutActivationMode.NeverActivate); + SetOverrideRedirect(window, X11WindowActivation.UsesOverrideRedirect(activationMode)); XFlush(_display); } internal bool NavigateFocus(object reason) { - // On X11, focus navigation is handled by the window manager. - // Attempt to set input focus to the flyout window. - if (_display is 0 || _x11Window is 0) + if (_activationMode is DesktopFlyoutActivationMode.NeverActivate || + _display is 0 || _x11Window is 0) return false; XSetInputFocus(_display, _x11Window, 1 /* RevertToParent */, 0 /* CurrentTime */); @@ -458,9 +466,14 @@ internal bool NavigateFocus(object reason) private void SetWindowOpacity(nint window, uint opacity) { - var data = BitConverter.GetBytes(opacity); - XChangeProperty(_display, window, _netWmOpacityAtom, 6 /* XA_CARDINAL */, 32, - PropertyMode.Replace, data, 1); + ReadOnlySpan data = [opacity]; + XChangeProperty32( + _display, + window, + _netWmOpacityAtom, + (nint)XA_CARDINAL, + PropertyMode.Replace, + data); } public void Dispose() @@ -475,6 +488,7 @@ public void Dispose() _window.Closed -= OnWindowClosed; _window.Activated -= OnWindowActivated; + GeneralHelpers.SystemSettingsChanged -= GeneralHelpers_SystemSettingsChanged; if (_display is not 0 && _x11Window is not 0) { @@ -486,7 +500,7 @@ public void Dispose() XCloseDisplay(_display); } - _window.Content = null; + _window.Content = null; _window.Close(); GC.SuppressFinalize(this); @@ -500,6 +514,81 @@ private void SetOverrideRedirect(nint window, bool enabled) XChangeWindowAttributes(_display, window, CWOverrideRedirect, ref attrs); } + private void RequestActivation(nint window) + { + if (window is 0) + return; + + _ = TryGetActiveWindow(out var currentActiveWindow); + var clientMessage = new XEvent + { + type = ClientMessage, + xclient = new XClientMessageEvent + { + type = ClientMessage, + display = _display, + window = window, + message_type = _netActiveWindowAtom, + format = 32, + ptr1 = 1, // normal application + ptr2 = 0, // CurrentTime + ptr3 = currentActiveWindow, + } + }; + var rootWindow = XDefaultRootWindow(_display); + if (rootWindow is not 0) + { + XSendEvent( + _display, + rootWindow, + false, + (nint)(SubstructureRedirectMask | SubstructureNotifyMask), + ref clientMessage); + XFlush(_display); + } + } + + private bool TryGetActiveWindow(out nint activeWindow) + { + activeWindow = 0; + var rootWindow = XDefaultRootWindow(_display); + if (rootWindow is not 0) + { + var status = XGetWindowProperty( + _display, + (nuint)rootWindow, + (nuint)_netActiveWindowAtom, + 0, + 1, + false, + 0, + out var actualType, + out var actualFormat, + out var itemCount, + out _, + out var property); + + if (status is 0 && actualType == XA_WINDOW && actualFormat is 32 && + itemCount > 0 && property is not 0) + { + try + { + activeWindow = Marshal.ReadIntPtr(property); + return activeWindow is not 0; + } + finally + { + XFree(property); + } + } + + if (property is not 0) + XFree(property); + } + + return XGetInputFocus(_display, out activeWindow, out _) is not 0 && activeWindow is not 0; + } + internal void StartFocusMonitoring() { if (_focusMonitoring || _disposed || _display is 0 || _x11Window is 0 || _windowInactivated is null) @@ -517,32 +606,7 @@ internal void StartFocusMonitoring() private bool IsOurWindowActive() { - var rootWindow = XDefaultRootWindow(_display); - if (rootWindow is 0) - return false; - - var status = XGetWindowProperty( - _display, - (nuint)rootWindow, - (nuint)_netActiveWindowAtom, - 0, 1, false, - 0 /* AnyPropertyType */, - out var actualType, out _, - out var nItems, out _, - out var prop); - - if (status != 0 || actualType is not 33 /* XA_WINDOW */ || nItems < 1 || prop is 0) - return false; - - try - { - var activeWindow = Marshal.ReadIntPtr(prop); - return activeWindow == _x11Window; - } - finally - { - XFree(prop); - } + return TryGetActiveWindow(out var activeWindow) && managedWindows.Contains(activeWindow); } internal void StopFocusMonitoring() @@ -564,38 +628,14 @@ private void FocusTimer_Tick(object? sender, object e) try { - var rootWindow = XDefaultRootWindow(_display); - if (rootWindow is 0) + if (!TryGetActiveWindow(out var activeWindow)) return; - // Read _NET_ACTIVE_WINDOW from the root window (type WINDOW, 32-bit). - // AnyPropertyType (0) = return data regardless of the property's actual type. - var status = XGetWindowProperty( - _display, - (nuint)rootWindow, - (nuint)_netActiveWindowAtom, - 0, 1, false, - 0 /* AnyPropertyType */, - out var actualType, out _, - out var nItems, out _, - out var prop); - - if (status != 0 || actualType is not 33 /* XA_WINDOW */ || nItems < 1 || prop is 0) - return; + var wasFocused = _focused; + _focused = managedWindows.Contains(activeWindow); - try - { - var activeWindow = Marshal.ReadIntPtr(prop); - var wasFocused = _focused; - _focused = activeWindow == _x11Window; - - if (wasFocused && !_focused) - _windowInactivated?.Invoke(this, EventArgs.Empty); - } - finally - { - XFree(prop); - } + if (wasFocused && !_focused) + _windowInactivated?.Invoke(this, EventArgs.Empty); } catch (ObjectDisposedException) { @@ -612,10 +652,37 @@ private void OnWindowClosed(object? sender, WindowEventArgs args) _windowInactivated?.Invoke(this, EventArgs.Empty); } + private void GeneralHelpers_SystemSettingsChanged(object? sender, EventArgs args) + { + if (_disposed) + return; + + if (_window.DispatcherQueue.HasThreadAccess) + { + SystemSettingsChanged?.Invoke(this, EventArgs.Empty); + } + else + { + _window.DispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + SystemSettingsChanged?.Invoke(this, EventArgs.Empty); + }); + } + } + private void OnWindowActivated(object? sender, WindowActivatedEventArgs args) { - if (args.WindowActivationState is Windows.UI.Core.CoreWindowActivationState.Deactivated) + if (_activationMode is DesktopFlyoutActivationMode.NeverActivate) + return; + + if (args.WindowActivationState is not Windows.UI.Core.CoreWindowActivationState.Deactivated) + { + _focused = true; + } + else if (_focused) { + _focused = false; _windowInactivated?.Invoke(this, EventArgs.Empty); } } diff --git a/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj b/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj index 77c5127..b4dfdd1 100644 --- a/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj +++ b/src/DesktopFlyouts.Uno/DesktopFlyouts.Uno.csproj @@ -12,7 +12,7 @@ UnoFeatures let's you quickly add and manage implicit package references based on the features you want to use. https://aka.platform.uno/singleproject-features --> - + SkiaRenderer; @@ -45,33 +45,29 @@ Shell System TrayIcon DesktopFlyouts DesktopFlyout UWP WinUI MUX WASDK WindowsAppSDK Uno Platform Windows icon-128.png - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs b/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs new file mode 100644 index 0000000..d314c9d --- /dev/null +++ b/src/DesktopFlyouts.Uno/GeneratorBackfill/DesktopFlyoutIslandTemplateSettings.Uno.cs @@ -0,0 +1,22 @@ +// Manual dependency property implementation for Uno. +// The GeneratedDependencyProperty source generator does not run under Uno.Sdk. + +using Microsoft.UI.Xaml; + +namespace DesktopFlyouts; + +public partial class DesktopFlyoutIslandTemplateSettings +{ + public partial CornerRadius BackdropCornerRadius + { + get => (CornerRadius)GetValue(BackdropCornerRadiusProperty); + internal set => SetValue(BackdropCornerRadiusProperty, value); + } + + public static readonly DependencyProperty BackdropCornerRadiusProperty = + DependencyProperty.Register( + nameof(BackdropCornerRadius), + typeof(CornerRadius), + typeof(DesktopFlyoutIslandTemplateSettings), + new PropertyMetadata(default(CornerRadius))); +} diff --git a/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj b/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj new file mode 100644 index 0000000..153eb0f --- /dev/null +++ b/tests/DesktopFlyouts.X11.Tests/DesktopFlyouts.X11.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + false + true + false + false + enable + enable + $(DefineConstants);HAS_UNO + + + + + + + + + + + + + + + diff --git a/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs b/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs new file mode 100644 index 0000000..96ca900 --- /dev/null +++ b/tests/DesktopFlyouts.X11.Tests/X11InteropLayoutTests.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; +using Xunit; + +namespace DesktopFlyouts.Tests; + +public class X11InteropLayoutTests +{ + [Fact] + public void XSetWindowAttributesMatchesNativeLayout() + { + if (IntPtr.Size is 8) + { + Assert.Equal(112, Marshal.SizeOf()); + AssertOffset("backing_planes", 48); + AssertOffset("backing_pixel", 56); + AssertOffset("save_under", 64); + AssertOffset("event_mask", 72); + AssertOffset("override_redirect", 88); + AssertOffset("cursor", 104); + } + else + { + Assert.Equal(60, Marshal.SizeOf()); + AssertOffset("backing_planes", 28); + AssertOffset("backing_pixel", 32); + AssertOffset("save_under", 36); + AssertOffset("event_mask", 40); + AssertOffset("override_redirect", 48); + AssertOffset("cursor", 56); + } + } + + [Fact] + public void XRandRMonitorInfoMatchesNativeLayout() + { + Assert.Equal(IntPtr.Size is 8 ? 56 : 44, Marshal.SizeOf()); + AssertOffset("outputs", IntPtr.Size is 8 ? 48 : 40); + } + + [Fact] + public void XClientMessageEventMatchesNativeLayout() + { + Assert.Equal(IntPtr.Size is 8 ? 96 : 48, Marshal.SizeOf()); + AssertOffset("ptr1", IntPtr.Size is 8 ? 56 : 28); + } + + private static void AssertOffset(string fieldName, int expected) + => Assert.Equal(expected, Marshal.OffsetOf(fieldName).ToInt32()); +} diff --git a/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs b/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs new file mode 100644 index 0000000..04fa9bd --- /dev/null +++ b/tests/DesktopFlyouts.X11.Tests/X11ScreenGeometryTests.cs @@ -0,0 +1,57 @@ +using System.Drawing; +using Xunit; + +namespace DesktopFlyouts.Tests; + +public class X11ScreenGeometryTests +{ + private static readonly X11MonitorGeometry[] Monitors = + [ + new(new Rectangle(-1920, 0, 1920, 1080), false), + new(new Rectangle(0, 0, 2560, 1440), true), + ]; + + [Fact] + public void SelectMonitorUsesPrimaryWithoutAnchor() + { + Assert.Equal(new Rectangle(0, 0, 2560, 1440), + X11ScreenGeometry.SelectMonitor(Monitors, null)); + } + + [Fact] + public void SelectMonitorUsesAnchorIncludingNegativeCoordinates() + { + Assert.Equal(new Rectangle(-1920, 0, 1920, 1080), + X11ScreenGeometry.SelectMonitor(Monitors, new Point(-800, 500))); + } + + [Fact] + public void SelectMonitorUsesNearestMonitorForOffscreenAnchor() + { + Assert.Equal(new Rectangle(0, 0, 2560, 1440), + X11ScreenGeometry.SelectMonitor(Monitors, new Point(4000, 500))); + } + + [Fact] + public void SelectWorkAreaUsesCurrentVirtualDesktop() + { + nuint[] values = + [ + 0, 0, 2560, 1400, + unchecked((nuint)(uint)-1920), 24, 4480, 1056, + ]; + + Assert.True(X11ScreenGeometry.TrySelectWorkArea(values, 1, out var workArea)); + Assert.Equal(new Rectangle(-1920, 24, 4480, 1056), workArea); + } + + [Fact] + public void ApplyWorkAreaClipsSelectedMonitor() + { + var monitor = new Rectangle(0, 0, 2560, 1440); + var workArea = new Rectangle(-1920, 24, 4480, 1380); + + Assert.Equal(new Rectangle(0, 24, 2560, 1380), + X11ScreenGeometry.ApplyWorkArea(monitor, workArea)); + } +} diff --git a/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs b/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs new file mode 100644 index 0000000..cbf6ebc --- /dev/null +++ b/tests/DesktopFlyouts.X11.Tests/X11WindowActivationTests.cs @@ -0,0 +1,30 @@ +using Xunit; + +namespace DesktopFlyouts.Tests; + +public class X11WindowActivationTests +{ + [Theory] + [InlineData(DesktopFlyoutActivationMode.Activate, false)] + [InlineData(DesktopFlyoutActivationMode.NoActivateOnOpen, false)] + [InlineData(DesktopFlyoutActivationMode.NeverActivate, true)] + public void OverrideRedirectIsReservedForNeverActivate( + DesktopFlyoutActivationMode mode, + bool expected) + { + Assert.Equal(expected, X11WindowActivation.UsesOverrideRedirect(mode)); + } + + [Theory] + [InlineData(DesktopFlyoutActivationMode.Activate, true, true)] + [InlineData(DesktopFlyoutActivationMode.Activate, false, false)] + [InlineData(DesktopFlyoutActivationMode.NoActivateOnOpen, true, false)] + [InlineData(DesktopFlyoutActivationMode.NeverActivate, true, false)] + public void ActivationRequestsRespectMode( + DesktopFlyoutActivationMode mode, + bool activate, + bool expected) + { + Assert.Equal(expected, X11WindowActivation.ShouldRequestActivation(mode, activate)); + } +}