diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 05ca6259e..ffb441ad3 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -225,6 +225,16 @@ private RadialWindow EnsureWindow() var window = new RadialWindow { DataContext = viewModel }; var donut = window.DonutControl; + // macOS uses a panel while the donut is shown over a full-screen app. Restore its original window class + // after any hide and before Avalonia releases it at quit, including a quit while it is still visible (#805). + void RestoreNativeWindow() => platform.RestoreWindow(window.TryGetPlatformHandle()?.Handle ?? 0); + window.Closing += (_, _) => RestoreNativeWindow(); + window.PropertyChanged += (_, change) => + { + if (change.Property == RadialWindow.IsVisibleProperty && !change.GetNewValue()) + RestoreNativeWindow(); + }; + // On native Wayland, hiding an xdg_toplevel surface causes the compositor to destroy its surface, // and re-showing raises an InvalidOperationException. Track Closed to re-create on the next gesture. if (Filee.Platform.Linux.WaylandSupport.NativeWaylandRequested) diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index f42457f58..b999edcab 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -151,6 +151,9 @@ void OpenWith(string path) { } /// window below normal ones (seen after the Photos app opened) while it still reports itself as topmost. /// void RaiseTopmost(nint windowHandle); + + /// Restores temporary native window configuration after a raised window hides or before it closes. + void RestoreWindow(nint windowHandle) { } } /// diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 30c923b9b..100bedad4 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -129,6 +129,13 @@ public void RaiseTopmost(nint windowHandle) NativeMethods.RaiseTopmost(windowHandle); } + /// Restores a topmost window's native class and style after it hides or before it closes. + public void RestoreWindow(nint windowHandle) + { + if (windowHandle != 0) + NativeMethods.RestoreWindow(windowHandle); + } + /// /// Lets drags that offer file paths or file promises in older forms reach the window (the donut), not just ones /// offering file URLs: other file managers than Finder write those (issue #160). diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index b343bab75..8fe14deb1 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -131,9 +131,8 @@ internal static void RaiseTopmost(nint window) behavior &= ~((1 << 16) | (1 << 17) | (1 << 18)); // Finder can occupy a full-screen Space that would otherwise hide another app's toolbar. SendInteger(window, sel_registerName("setCollectionBehavior:"), behavior); - // While Filee shows in the Dock, AppKit keeps its windows off another app's full-screen Space and lets only a - // non-activating panel join it (#251). AvnPanel is Avalonia's NSPanel build of AvnWindow, same ivars and methods - // but canBecomeMainWindow, so the donut becomes one in place. + // A non-activating panel can join another app's full-screen Space while Filee remains in the Dock (#251). + // Restore AvnWindow when hidden and before close so AppKit's KVO cleanup sees the initialized class (#805). var panel = objc_getClass("AvnPanel"); if (panel != 0 && object_getClass(window) == objc_getClass("AvnWindow")) { @@ -146,6 +145,21 @@ internal static void RaiseTopmost(nint window) SendVoid(window, sel_registerName("orderFrontRegardless")); } + internal static void RestoreWindow(nint window) + { + if (window == 0) + return; + + var panel = objc_getClass("AvnPanel"); + var avnWindow = objc_getClass("AvnWindow"); + if (panel == 0 || avnWindow == 0 || object_getClass(window) != panel) + return; + + const nint nonactivatingPanel = 1 << 7; + SendInteger(window, sel_registerName("setStyleMask:"), Send(window, sel_registerName("styleMask")) & ~nonactivatingPanel); + object_setClass(window, avnWindow); + } + /// /// Adds to the drag types of every view in that already takes /// drags (Avalonia's view, registered for public.data only). AppKit sends a drag to a view only when it offers one diff --git a/tests/Filee.App.Tests/RadialControllerTests.cs b/tests/Filee.App.Tests/RadialControllerTests.cs index ede1551c7..8bd650714 100644 --- a/tests/Filee.App.Tests/RadialControllerTests.cs +++ b/tests/Filee.App.Tests/RadialControllerTests.cs @@ -7,12 +7,95 @@ using Avalonia.Input; using Filee.App.Controls; using Filee.App.Services; +using Filee.App.Services.Triggers; +using Filee.App.ViewModels; +using Filee.Core.Platform; using Filee.Core.Presets; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App.Tests; public class RadialControllerTests { + [AvaloniaFact] + public void Hiding_the_donut_restores_native_configuration_before_it_is_reused() + { + TestServices.EnsureInitialized("en"); + var platform = new WindowPlatform(); + var radial = Create(platform); + try + { + for (var i = 1; i <= 3; i++) + { + radial.ShowForDrag(400, 400); + Assert.Equal(i - 1, platform.Restores); + // Both an external Hide and the controller's close path must restore the native window. + if (i == 1) + radial.Window!.Hide(); + else + radial.Close(animate: false); + Assert.False(radial.Window!.IsVisible); + Assert.Equal(i, platform.Restores); + } + } + finally + { + radial.Window?.Close(); + } + } + + [AvaloniaFact] + public void Closing_a_visible_donut_restores_it_before_the_native_window_is_released() + { + TestServices.EnsureInitialized("en"); + var platform = new WindowPlatform(); + var radial = Create(platform); + radial.ShowForDrag(400, 400); + var window = radial.Window!; + var checkedBeforeRelease = false; + window.Closing += (_, _) => + { + Assert.Equal(1, platform.Restores); + Assert.NotNull(window.PlatformImpl); + checkedBeforeRelease = true; + }; + try + { + window.Close(); + Assert.True(checkedBeforeRelease); + Assert.Null(window.PlatformImpl); + } + finally + { + if (window.PlatformImpl is not null) + window.Close(); + } + } + + private static RadialController Create(IPlatformServices platform) => + new(AppHost.Get(), AppHost.Get(), AppHost.Get(), + AppHost.Get(), platform, AppHost.Get(), NullLogger.Instance); + + private sealed class WindowPlatform : IPlatformServices + { + public int Restores { get; private set; } + public void RestoreWindow(nint windowHandle) => Restores++; + public void RaiseTopmost(nint windowHandle) { } + public bool IsFileManagerAt(int x, int y) => false; + public string? ProcessNameAt(int x, int y) => null; + public IReadOnlyList GetFileManagerSelection() => []; + public int SystemDragThreshold => 4; + public bool PrefersReducedMotion => false; + public void SetStartWithSystem(bool enabled, string executablePath) { } + public void SetContextMenu(bool enabled, string executablePath, string label) { } + public ModernContextMenuState GetModernContextMenuState(string executablePath) => ModernContextMenuState.Unsupported; + public Task SetModernContextMenuAsync(bool enabled, string executablePath) => + Task.FromResult(new ModernContextMenuResult(false)); + public void RevealInFileManager(string path) { } + public void OpenFile(string path) { } + } + /// A conversion ran through the donut and its ring finished: progress mode stays on, nothing is pending. private static void FinishARing(DonutMenu donut) { diff --git a/tests/Filee.Platform.Tests/MacOSWindowTests.cs b/tests/Filee.Platform.Tests/MacOSWindowTests.cs new file mode 100644 index 000000000..fbbc688fa --- /dev/null +++ b/tests/Filee.Platform.Tests/MacOSWindowTests.cs @@ -0,0 +1,191 @@ +// Topmost windows use a panel while shown, then restore their original class/style when hidden or before close (#805). +// Objective-C test doubles exercise the real interop calls without creating AppKit UI on xUnit's worker thread. + +using System.Runtime.InteropServices; +using Filee.Platform.MacOS; + +namespace Filee.Platform.Tests; + +public sealed class MacOSWindowTests +{ + [Fact] + public void Raising_and_restoring_a_window_repeatedly_restores_its_native_class_and_style() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires the macOS Objective-C runtime."); + return; + } + + _ = NativeLibrary.Load("/System/Library/Frameworks/Foundation.framework/Foundation"); + // Production checks these exact Avalonia class names. This test runs in the platform-only test process, + // which does not load Avalonia.Native; never replace a real class if another host has loaded it. + if (GetClass("AvnWindow") != 0 || GetClass("AvnPanel") != 0) + { + Assert.Skip("Requires a process without Avalonia.Native window classes."); + return; + } + + nint behavior = (1 << 1) | (1 << 6) | (1 << 7) | (1 << 9) | (1 << 16) | (1 << 17) | (1 << 18); + const nint nonactivatingPanel = 1 << 7; + const nint unrelatedStyle = (1 << 2) | (1 << 11); + nint style = unrelatedStyle; + nint level = 0; + var orders = 0; + var hidesOnDeactivate = true; + var styleChanges = 0; + var hidesOnDeactivateChanges = 0; + GetInteger getBehavior = (_, _) => behavior; + GetInteger getStyle = (_, _) => style; + SetInteger setBehavior = (_, _, value) => behavior = value; + SetInteger setLevel = (_, _, value) => level = value; + SetInteger setStyle = (_, _, value) => + { + style = value; + styleChanges++; + }; + SetInteger setHidesOnDeactivate = (_, _, value) => + { + hidesOnDeactivate = value != 0; + hidesOnDeactivateChanges++; + }; + SendAction orderFront = (_, _) => orders++; + Delegate[] callbacks = [getBehavior, getStyle, setBehavior, setLevel, setStyle, setHidesOnDeactivate, orderFront]; + + var baseClass = AllocateClass(GetClass("NSObject"), "FileeRaiseTopmostTestWindow", 0); + Assert.NotEqual(0, baseClass); + nint windowClass = 0, panelClass = 0, window = 0, foreign = 0; + try + { + AddMethod(baseClass, "collectionBehavior", getBehavior, "q@:"); + AddMethod(baseClass, "styleMask", getStyle, "q@:"); + AddMethod(baseClass, "setCollectionBehavior:", setBehavior, "v@:q"); + AddMethod(baseClass, "setLevel:", setLevel, "v@:q"); + AddMethod(baseClass, "setStyleMask:", setStyle, "v@:q"); + AddMethod(baseClass, "setHidesOnDeactivate:", setHidesOnDeactivate, "v@:q"); + AddMethod(baseClass, "orderFrontRegardless", orderFront, "v@:"); + RegisterClass(baseClass); + + var platform = new MacOSPlatformServices(); + platform.RaiseTopmost(0); + platform.RestoreWindow(0); + foreign = Send(Send(baseClass, Selector("alloc")), Selector("init")); + Assert.NotEqual(0, foreign); + platform.RaiseTopmost(foreign); + platform.RestoreWindow(foreign); + Assert.Equal(baseClass, ObjectClass(foreign)); + Assert.Equal(unrelatedStyle, style); + Assert.True(hidesOnDeactivate); + Assert.Equal(0, styleChanges); + Assert.Equal(0, hidesOnDeactivateChanges); + orders = 0; + + windowClass = RegisterSubclass(baseClass, "AvnWindow"); + panelClass = RegisterSubclass(baseClass, "AvnPanel"); + window = Send(Send(windowClass, Selector("alloc")), Selector("init")); + Assert.NotEqual(0, window); + + for (var i = 1; i <= 3; i++) + { + platform.RaiseTopmost(window); + Assert.Equal(panelClass, ObjectClass(window)); + Assert.Equal((nint)(1 | (1 << 6) | (1 << 8)), behavior); + Assert.Equal(unrelatedStyle | nonactivatingPanel, style); + Assert.False(hidesOnDeactivate); + Assert.Equal((nint)3, level); + Assert.Equal(2 * i - 1, orders); + Assert.Equal(2 * i - 1, styleChanges); + Assert.Equal(i, hidesOnDeactivateChanges); + + platform.RaiseTopmost(window); + Assert.Equal(panelClass, ObjectClass(window)); + Assert.Equal(unrelatedStyle | nonactivatingPanel, style); + Assert.False(hidesOnDeactivate); + Assert.Equal(2 * i, orders); + Assert.Equal(2 * i - 1, styleChanges); + Assert.Equal(i, hidesOnDeactivateChanges); + + platform.RestoreWindow(window); + Assert.Equal(windowClass, ObjectClass(window)); + Assert.Equal(unrelatedStyle, style); + Assert.False(hidesOnDeactivate); + Assert.Equal(2 * i, styleChanges); + + platform.RestoreWindow(window); + Assert.Equal(windowClass, ObjectClass(window)); + Assert.Equal(unrelatedStyle, style); + Assert.Equal(2 * i, styleChanges); + } + + var styleChangesBeforeForeignRestore = styleChanges; + var hidesOnDeactivateChangesBeforeForeignRestore = hidesOnDeactivateChanges; + platform.RaiseTopmost(foreign); + platform.RestoreWindow(foreign); + Assert.Equal(baseClass, ObjectClass(foreign)); + Assert.Equal(unrelatedStyle, style); + Assert.Equal(styleChangesBeforeForeignRestore, styleChanges); + Assert.Equal(hidesOnDeactivateChangesBeforeForeignRestore, hidesOnDeactivateChanges); + } + finally + { + if (window != 0) + Send(window, Selector("release")); + if (foreign != 0) + Send(foreign, Selector("release")); + if (panelClass != 0) + DisposeClass(panelClass); + if (windowClass != 0) + DisposeClass(windowClass); + DisposeClass(baseClass); + GC.KeepAlive(callbacks); + } + } + + private static nint RegisterSubclass(nint superclass, string name) + { + var result = AllocateClass(superclass, name, 0); + Assert.NotEqual(0, result); + RegisterClass(result); + return result; + } + + private static void AddMethod(nint cls, string name, Delegate callback, string signature) => + Assert.True(AddNativeMethod(cls, Selector(name), Marshal.GetFunctionPointerForDelegate(callback), signature)); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate nint GetInteger(nint receiver, nint selector); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SetInteger(nint receiver, nint selector, nint value); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SendAction(nint receiver, nint selector); + + private const string ObjC = "/usr/lib/libobjc.A.dylib"; + + [DllImport(ObjC, EntryPoint = "objc_getClass")] + private static extern nint GetClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_allocateClassPair")] + private static extern nint AllocateClass(nint superclass, [MarshalAs(UnmanagedType.LPUTF8Str)] string name, nuint extraBytes); + + [DllImport(ObjC, EntryPoint = "objc_registerClassPair")] + private static extern void RegisterClass(nint cls); + + [DllImport(ObjC, EntryPoint = "objc_disposeClassPair")] + private static extern void DisposeClass(nint cls); + + [DllImport(ObjC, EntryPoint = "object_getClass")] + private static extern nint ObjectClass(nint instance); + + [DllImport(ObjC, EntryPoint = "class_addMethod")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool AddNativeMethod(nint cls, nint selector, nint implementation, + [MarshalAs(UnmanagedType.LPUTF8Str)] string signature); + + [DllImport(ObjC, EntryPoint = "sel_registerName")] + private static extern nint Selector([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector); +}