Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Filee.App/Services/RadialController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>())
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)
Expand Down
3 changes: 3 additions & 0 deletions src/Filee.Core/Platform/IPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
void RaiseTopmost(nint windowHandle);

/// <summary>Restores temporary native window configuration after a raised window hides or before it closes.</summary>
void RestoreWindow(nint windowHandle) { }
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/Filee.Platform.MacOS/MacOSPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ public void RaiseTopmost(nint windowHandle)
NativeMethods.RaiseTopmost(windowHandle);
}

/// <summary>Restores a topmost window's native class and style after it hides or before it closes.</summary>
public void RestoreWindow(nint windowHandle)
{
if (windowHandle != 0)
NativeMethods.RestoreWindow(windowHandle);
}

/// <summary>
/// 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).
Expand Down
20 changes: 17 additions & 3 deletions src/Filee.Platform.MacOS/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
{
Expand All @@ -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);
}

/// <summary>
/// Adds <paramref name="types"/> to the drag types of every view in <paramref name="window"/> that already takes
/// drags (Avalonia's view, registered for public.data only). AppKit sends a drag to a view only when it offers one
Expand Down
83 changes: 83 additions & 0 deletions tests/Filee.App.Tests/RadialControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RadialViewModel>(), AppHost.Get<ConversionService>(), AppHost.Get<WindowService>(),
AppHost.Get<UserDataStore>(), platform, AppHost.Get<TriggerService>(), NullLogger<RadialController>.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<string> 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<ModernContextMenuResult> SetModernContextMenuAsync(bool enabled, string executablePath) =>
Task.FromResult(new ModernContextMenuResult(false));
public void RevealInFileManager(string path) { }
public void OpenFile(string path) { }
}

/// <summary>A conversion ran through the donut and its ring finished: progress mode stays on, nothing is pending.</summary>
private static void FinishARing(DonutMenu donut)
{
Expand Down
191 changes: 191 additions & 0 deletions tests/Filee.Platform.Tests/MacOSWindowTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading