diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ae31e671..59e570886 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,14 +45,15 @@ Linux release packages must run on Ubuntu 22.04 (glibc 2.35). CI builds rhwp fro machine that built it. Check a package with `python3 build/check-elf-glibc.py --max 2.35 `. See [docs/ENGINES.md](docs/ENGINES.md#linux-builds-for-older-glibc). -- **Linux:** drag gestures require X11. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11, +- **Linux:** drag gestures require X11 by default. Install the Avalonia desktop dependencies (fontconfig, FreeType, X11, Xrandr, Xi, Xtst, Xt, XkbCommon, ICE and SM) and CJK fonts for document comparisons. File actions are installed for Thunar, Nautilus, Nemo and Dolphin. The first enabled keyboard shortcut uses Thunar's native custom action; close Thunar before changing or pausing that shortcut and reopen it afterwards. Global selected-file queries and hold gestures are not supported. Wayland can use file actions, the drop zone, watch folders and CLI. - On KDE Plasma 6 the drag gesture can also run there, experimentally and only with `FILEE_WAYLAND_GESTURES=1`. It needs - the user in the `input` group (libuiohook's libinput back end) and the KWin script in `installer/linux/kwin`, which - moves the donut window (titled "Filee Donut") to the cursor. See `installer/linux/INSTALL.txt`. + On KDE Plasma 6 and Hyprland, the drag gesture can also run there, experimentally with `FILEE_WAYLAND_GESTURES=1`. It needs + the user in the `input` group (libuiohook's libinput back end) and a compositor positioning mechanism (the KWin script in + `installer/linux/kwin` or a Hyprland floating window rule). Native Wayland support (`Avalonia.Wayland`) is also available + as an opt-in via `FILEE_USE_WAYLAND=1`. See `installer/linux/INSTALL.txt`. - **macOS:** grant Filee Accessibility permission for gestures and Finder Automation permission for selection. Keep the development `.app` at a stable path while testing these permissions. Finder integration is a Quick Action under Services; auto-start uses a per-user LaunchAgent. diff --git a/Directory.Packages.props b/Directory.Packages.props index a8af2b14e..5ec34bcdc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index ea4846847..7e00ed2c8 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -8,6 +8,7 @@ Each component remains under its own license. | Component | License | Project | |---|---|---| | Avalonia UI | MIT | https://github.com/AvaloniaUI/Avalonia | +| NWayland (used by Avalonia.Wayland) | MIT | https://github.com/kekekeks/NWayland | | CommunityToolkit.Mvvm | MIT | https://github.com/CommunityToolkit/dotnet | | SharpHook (libuiohook) | MIT (libuiohook: GPL-3.0 with linking exception / LGPL-3.0) | https://github.com/TolikPylypchuk/SharpHook | | Magick.NET / ImageMagick | Apache-2.0 / ImageMagick License | https://github.com/dlemstra/Magick.NET | diff --git a/installer/linux/INSTALL.txt b/installer/linux/INSTALL.txt index 68d5c4a5d..97a8599a1 100644 --- a/installer/linux/INSTALL.txt +++ b/installer/linux/INSTALL.txt @@ -24,10 +24,10 @@ X11 supports the global gesture toolbar; it needs libXtst and libXt (libxtst6, libxt6). A native Wayland session has additional input restrictions; use the drop window and file-manager actions, or the experimental setup below. -Wayland gesture (experimental, KDE Plasma 6 only, off by default): Filee reads -the mouse and keyboard from /dev/input through libinput, and a KWin script moves -the donut to the cursor because Wayland apps cannot place windows. It needs -libinput, libudev and libwayland-client (libinput10, libudev1 and +Wayland gesture (experimental, KDE Plasma 6 and Hyprland, off by default): Filee reads +the mouse and keyboard from /dev/input through libinput, and a KWin script or Hyprland +window rule places the donut at the cursor because Wayland apps cannot place windows. +It needs libinput, libudev and libwayland-client (libinput10, libudev1 and libwayland-client0 on Ubuntu). To turn it on: 1. Start Filee with FILEE_WAYLAND_GESTURES=1 in its environment, for example with the line FILEE_WAYLAND_GESTURES=1 in ~/.config/environment.d/filee.conf @@ -36,9 +36,7 @@ libwayland-client0 on Ubuntu). To turn it on: can read all keyboard and mouse input from any program they run, so enable this only if you accept that. You may already be in it for other tools. sudo usermod -aG input "$USER" -3. Install the KWin script and enable it, so it loads at every login (you can - also enable "Filee donut at the cursor" in System Settings > Window - Management > KWin Scripts): +3. For KDE Plasma: Install the KWin script and enable it, so it loads at every login: kpackagetool6 --type=KWin/Script --install "${XDG_DATA_HOME:-$HOME/.local/share}/filee/application/kwin/filee-donut" kwriteconfig6 --file kwinrc --group Plugins --key filee-donutEnabled true qdbus6 org.kde.KWin /KWin reconfigure @@ -46,6 +44,18 @@ libwayland-client0 on Ubuntu). To turn it on: distribution; use whichever one you have. After updating Filee, upgrade the installed script the same way, with --upgrade instead of --install, then run the reconfigure line again. + For Hyprland: Add the floating and placement window rules in ~/.config/hypr/hyprland.conf (or hyprland.lua): + windowrulev2 = float, title:^(Filee Donut)$ + windowrulev2 = move cursor -50% -50%, title:^(Filee Donut)$ + windowrulev2 = noborder, title:^(Filee Donut)$ + windowrulev2 = noshadow, title:^(Filee Donut)$ + windowrulev2 = noblur, title:^(Filee Donut)$ + +Native Wayland Backend (experimental, off by default): +By default, Filee runs through XWayland on Wayland desktops for maximum compatibility. +To opt into the native Wayland backend (Avalonia.Wayland): + Start Filee with FILEE_USE_WAYLAND=1 in its environment. + (e.g., export FILEE_USE_WAYLAND=1 or set in ~/.config/environment.d/filee.conf). Limits: Wayland cannot say which window is under the cursor, so a Ctrl+drag in any app opens an invisible donut window. It only shows when files are dragged onto it, but it rejects drops that are not files, so Ctrl+drag to copy text in an diff --git a/src/Filee.App/Filee.App.csproj b/src/Filee.App/Filee.App.csproj index aff92ad78..fee2ef10a 100644 --- a/src/Filee.App/Filee.App.csproj +++ b/src/Filee.App/Filee.App.csproj @@ -35,6 +35,16 @@ + + true + $(DefineConstants);FILEE_NATIVE_WAYLAND + + + + + + + diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index ad20c37ed..7aa04f93a 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -274,19 +274,28 @@ private static List OtherProcessesOfThisExe(string exe) /// Also used by the XAML previewer. public static AppBuilder BuildAvaloniaApp() { - var builder = AppBuilder.Configure().UsePlatformDetect(); + var builder = AppBuilder.Configure(); +#if FILEE_NATIVE_WAYLAND + // The native backend is only compiled into Linux builds and only used when asked for (FILEE_USE_WAYLAND=1). + builder = Filee.Platform.Linux.WaylandSupport.NativeWaylandRequested ? builder.UseWayland() : builder.UsePlatformDetect(); +#else + builder = builder.UsePlatformDetect(); +#endif + if (OperatingSystem.IsMacOS()) builder = builder.With(new MacOSPlatformOptions { ShowInDock = PreloadedStore?.Settings.ShowInDock ?? true }); if (OperatingSystem.IsWindows()) builder = builder.With(WindowsRendering.Options( WindowsRendering.Normalize(Environment.GetEnvironmentVariable(WindowsRendering.Variable)), adapters => Log.LogInformation("Graphics adapters: {Adapters}", adapters))); + builder = builder .ConfigureFonts(fonts => fonts.AddFontCollection(new FileeFontCollection())) #if DEBUG .WithDeveloperTools() #endif .LogToTrace(); + AvaloniaLogForwarder.Install(Log); return builder; } diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 7682481f7..659223aa5 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -220,6 +220,17 @@ private RadialWindow EnsureWindow() var window = new RadialWindow { DataContext = viewModel }; var donut = window.DonutControl; + // 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) + { + window.Closed += (_, _) => + { + if (ReferenceEquals(_window, window)) + _window = null; + }; + } + window.DragEntered += () => _dragReachedDonut = true; // Tells "the drag reached the donut but brought no files" apart, with what it brought (file promises, say). window.DragWithoutFiles += formats => diff --git a/src/Filee.Platform.Linux/LinuxPlatformServices.cs b/src/Filee.Platform.Linux/LinuxPlatformServices.cs index 15ae1143d..151353961 100644 --- a/src/Filee.Platform.Linux/LinuxPlatformServices.cs +++ b/src/Filee.Platform.Linux/LinuxPlatformServices.cs @@ -21,12 +21,15 @@ public sealed class LinuxPlatformServices : IPlatformServices string.Equals(Environment.GetEnvironmentVariable("XDG_SESSION_TYPE"), "wayland", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")); - // Wayland gestures (experimental) are opt-in: FILEE_WAYLAND_GESTURES=1 on KDE Plasma. libuiohook's libinput backend - // reads /dev/input, so /dev/input is only probed once the switch is set, and it works once the user is in the - // `input` group. Presses carry no position and a mouse only moves relatively there; a KWin script places the donut. + // Wayland gestures (experimental) are opt-in: FILEE_WAYLAND_GESTURES=1 on KDE Plasma and Hyprland. + // libuiohook's libinput backend reads /dev/input, so /dev/input is only probed once the switch is set, + // and it works once the user is in the `input` group. Presses carry no position and a mouse only moves + // relatively there; a KWin script or Hyprland window rule places the donut at the cursor. private static readonly bool WaylandGestures = IsWaylandSession && Environment.GetEnvironmentVariable("FILEE_WAYLAND_GESTURES") == "1" - && (Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP") ?? "").Split(':').Contains("KDE", StringComparer.OrdinalIgnoreCase) + && WaylandSupport.IsSupportedCompositor( + Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"), + Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE")) && CanReadInputDevices(); private static bool CanReadInputDevices() diff --git a/src/Filee.Platform.Linux/WaylandSupport.cs b/src/Filee.Platform.Linux/WaylandSupport.cs new file mode 100644 index 000000000..f8504ce82 --- /dev/null +++ b/src/Filee.Platform.Linux/WaylandSupport.cs @@ -0,0 +1,32 @@ +// Decisions about Wayland that only depend on environment values, kept apart from the Linux services so the app can ask +// them on every platform and tests can feed them values. + +namespace Filee.Platform.Linux; + +public static class WaylandSupport +{ + /// + /// Whether the native Wayland backend (Avalonia.Wayland) is opted into: on Linux, with FILEE_USE_WAYLAND=1 + /// and a Wayland display to connect to. Otherwise Filee runs through X11 or XWayland. + /// + public static bool IsNativeWaylandRequested(bool isLinux, string? fileeUseWayland, string? waylandDisplay) => + isLinux + && string.Equals(fileeUseWayland, "1", StringComparison.Ordinal) + && !string.IsNullOrEmpty(waylandDisplay); + + /// for this process. + public static bool NativeWaylandRequested => IsNativeWaylandRequested(OperatingSystem.IsLinux(), + Environment.GetEnvironmentVariable("FILEE_USE_WAYLAND"), Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")); + + /// + /// Whether the compositor has a way to put the donut at the cursor: KDE Plasma (the KWin script) or Hyprland (a + /// window rule), told by XDG_CURRENT_DESKTOP or HYPRLAND_INSTANCE_SIGNATURE. + /// + public static bool IsSupportedCompositor(string? xdgCurrentDesktop, string? hyprlandSignature) + { + var desktops = (xdgCurrentDesktop ?? "").Split(':'); + return desktops.Contains("KDE", StringComparer.OrdinalIgnoreCase) + || desktops.Contains("Hyprland", StringComparer.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(hyprlandSignature); + } +} diff --git a/tests/Filee.Platform.Tests/WaylandDecisionTests.cs b/tests/Filee.Platform.Tests/WaylandDecisionTests.cs new file mode 100644 index 000000000..131ccb79f --- /dev/null +++ b/tests/Filee.Platform.Tests/WaylandDecisionTests.cs @@ -0,0 +1,46 @@ +// The Wayland decisions that only depend on environment values (#367): which compositors can place the donut, and +// when the native backend is used. + +using Filee.Platform.Linux; + +namespace Filee.Platform.Tests; + +public sealed class WaylandDecisionTests +{ + [Theory] + [InlineData("KDE", null, true)] + [InlineData("kde", null, true)] + [InlineData("KDE:GNOME", null, true)] + [InlineData("Hyprland", null, true)] + [InlineData("hyprland", null, true)] + [InlineData(null, "instance_12345", true)] + [InlineData("", "instance_12345", true)] + [InlineData("GNOME", null, false)] + [InlineData("sway", null, false)] + [InlineData("XFCE", null, false)] + [InlineData(null, null, false)] + [InlineData("", "", false)] + public void KDE_and_Hyprland_can_place_the_donut( + string? xdgCurrentDesktop, string? hyprlandSignature, bool expected) + { + var result = WaylandSupport.IsSupportedCompositor(xdgCurrentDesktop, hyprlandSignature); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(true, "1", "wayland-0", true)] + [InlineData(true, "1", "wayland-1", true)] + [InlineData(false, "1", "wayland-0", false)] // Not Linux + [InlineData(true, null, "wayland-0", false)] // Switch unset + [InlineData(true, "", "wayland-0", false)] // Switch empty + [InlineData(true, "0", "wayland-0", false)] // Switch 0 + [InlineData(true, "true", "wayland-0", false)] // Switch not "1" + [InlineData(true, "1", null, false)] // No WAYLAND_DISPLAY + [InlineData(true, "1", "", false)] // Empty WAYLAND_DISPLAY + public void The_native_backend_needs_Linux_the_switch_and_a_display( + bool isLinux, string? fileeUseWayland, string? waylandDisplay, bool expected) + { + var result = WaylandSupport.IsNativeWaylandRequested(isLinux, fileeUseWayland, waylandDisplay); + Assert.Equal(expected, result); + } +}