From 5ac47eafb2b566deba57ac1f515b45452640bc3c Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 1 Oct 2026 23:50:30 +0900 Subject: [PATCH 1/2] Fix the crash after adding the Windows 11 menu entry GetPackagePathByFullName2 was imported from kernel32.dll, which doesn't export it. The call only happens once the package is registered, so adding the entry crashed Filee right after Windows accepted it, and the General page threw on every later visit. It is now imported from its API set, the package state and menu commands never take the app down, and a test checks that every native import exists in the DLL it names. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/Pages/GeneralPageViewModel.cs | 11 ++++++- .../ExplorerMenuRegistration.cs | 15 ++++++++- src/Filee.Platform.Windows/NativeMethods.cs | 3 +- .../WindowsPlatformServices.cs | 33 +++++++++++++++---- tests/Filee.App.Tests/ExplorerMenuTests.cs | 29 ++++++++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index d5ee544..445e611 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -85,7 +85,16 @@ private async Task ChangeModernMenu(bool enabled) return; ModernMenuBusy = true; ModernMenuStatus = _loc["general.modern_menu_busy"]; - var result = await _platform.SetModernContextMenuAsync(enabled, exe); + ModernContextMenuResult result; + try + { + result = await _platform.SetModernContextMenuAsync(enabled, exe); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // A command that throws would end the app (async void path): report it on the page instead. + result = new ModernContextMenuResult(false, Error: ex.Message); + } ModernMenuBusy = false; RefreshModernMenu(); if (result.Cancelled) diff --git a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs index e3f8136..b11060e 100644 --- a/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs +++ b/src/Filee.Platform.Windows/ExplorerMenuRegistration.cs @@ -282,7 +282,20 @@ public static string ShortenDeploymentError(string message) return string.Join(' ', message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } - private static unsafe string? ExternalLocationOf(string fullName) + /// The install folder the package points to, or null when Windows can't tell. + public static unsafe string? ExternalLocationOf(string fullName) + { + try + { + return ExternalLocationOfCore(fullName); + } + catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException) + { + return null; // older Windows without the API: the folder is then not compared + } + } + + private static unsafe string? ExternalLocationOfCore(string fullName) { uint length = 0; if (NativeMethods.GetPackagePathByFullName2(fullName, NativeMethods.PackagePathType_EffectiveExternal, ref length, null) diff --git a/src/Filee.Platform.Windows/NativeMethods.cs b/src/Filee.Platform.Windows/NativeMethods.cs index 592de99..1e360bb 100644 --- a/src/Filee.Platform.Windows/NativeMethods.cs +++ b/src/Filee.Platform.Windows/NativeMethods.cs @@ -58,7 +58,8 @@ public unsafe struct PACKAGE_ID [LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)] public static unsafe partial int GetPackagesByPackageFamily(string packageFamilyName, ref uint count, char** packageFullNames, ref uint bufferLength, char* buffer); - [LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)] + // Not exported by kernel32.dll (only by kernelbase.dll): import it from its API set, Windows 10 1903 and later. + [LibraryImport("api-ms-win-appmodel-runtime-l1-1-3.dll", StringMarshalling = StringMarshalling.Utf16)] public static unsafe partial int GetPackagePathByFullName2(string packageFullName, int packagePathType, ref uint pathLength, char* path); [LibraryImport("kernel32.dll")] diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index ce4a28b..5efb423 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -139,10 +139,21 @@ public void SetContextMenu(bool enabled, string executablePath, string label) CreateShortcut(SendToShortcutPath, executablePath, "--convert", label); } - public ModernContextMenuState GetModernContextMenuState(string executablePath) => - Path.GetDirectoryName(executablePath) is { Length: > 0 } folder - ? ExplorerMenuRegistration.GetState(folder) - : ModernContextMenuState.Unsupported; + public ModernContextMenuState GetModernContextMenuState(string executablePath) + { + try + { + return Path.GetDirectoryName(executablePath) is { Length: > 0 } folder + ? ExplorerMenuRegistration.GetState(folder) + : ModernContextMenuState.Unsupported; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Asking Windows about packages must never take the app or the settings page down. + Trace.TraceWarning($"Reading the Explorer menu state failed: {ex}"); + return ModernContextMenuState.Unsupported; + } + } public Task SetModernContextMenuAsync(bool enabled, string executablePath) => Task.Run(async () => { @@ -150,9 +161,17 @@ public Task SetModernContextMenuAsync(bool enabled, str if (string.IsNullOrEmpty(folder)) return new ModernContextMenuResult(false, Error: "Unknown install folder."); - var result = enabled - ? await ExplorerMenuRegistration.AddAsync(folder) - : await ExplorerMenuRegistration.RemoveAsync(allowElevation: true); + ModernContextMenuResult result; + try + { + result = enabled + ? await ExplorerMenuRegistration.AddAsync(folder) + : await ExplorerMenuRegistration.RemoveAsync(allowElevation: true); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + result = new ModernContextMenuResult(false, Error: ex.Message); + } // Keep exactly one entry: the title file exists while the Explorer menu setting is on. if (ExplorerMenuRegistration.ReadTitle() is { Length: > 0 } title) diff --git a/tests/Filee.App.Tests/ExplorerMenuTests.cs b/tests/Filee.App.Tests/ExplorerMenuTests.cs index 8daf4eb..c6ed47d 100644 --- a/tests/Filee.App.Tests/ExplorerMenuTests.cs +++ b/tests/Filee.App.Tests/ExplorerMenuTests.cs @@ -90,6 +90,35 @@ public void Family_name_matches_what_Windows_computes_from_the_publisher() Assert.Equal(ExplorerMenuPackage.FamilyName, ExplorerMenuRegistration.ComputeFamilyName()); } + [Fact] + public void Every_native_import_exists_in_the_dll_it_names() + { + // GetPackagePathByFullName2 was imported from kernel32.dll, which doesn't export it: the call only failed + // once a package was registered and crashed the app right after "Add to the main menu" (1.1.0). + var nativeMethods = typeof(ExplorerMenuRegistration).Assembly.GetType("Filee.Platform.Windows.NativeMethods", throwOnError: true)!; + var imports = nativeMethods.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic) + .Select(m => (Method: m, Import: m.GetCustomAttributes(typeof(LibraryImportAttribute), false).OfType().FirstOrDefault())) + .Where(m => m.Import is not null) + .ToList(); + Assert.NotEmpty(imports); + Assert.All(imports, m => + { + var entryPoint = m.Import!.EntryPoint ?? m.Method.Name; + Assert.True(NativeLibrary.TryLoad(m.Import.LibraryName, out var library), $"{m.Import.LibraryName} can't be loaded"); + Assert.True(NativeLibrary.TryGetExport(library, entryPoint, out _), $"{m.Import.LibraryName} has no {entryPoint}"); + }); + } + + [Fact] + public void Reading_the_registered_package_never_throws() + { + Assert.Null(ExplorerMenuRegistration.ExternalLocationOf("Filee.NotInstalled_1.0.0.0_x64__0000000000000")); + // Whatever is registered on this machine (nothing on CI): a registered sparse package has an external location. + if (ExplorerMenuRegistration.Find() is { } package) + Assert.False(string.IsNullOrEmpty(package.ExternalLocation), package.FullName); + _ = ExplorerMenuRegistration.GetState(BuiltFolder); + } + [Fact] public void PowerShell_literals_escape_straight_and_typographic_single_quotes() { From fbc679d4bdfe5c6c7bffa3fbdba2d6e8af5baf9b Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 1 Oct 2026 23:50:31 +0900 Subject: [PATCH 2/2] Don't show the donut for a drag gesture without files The gesture is detected before anyone knows what is dragged, so a modifier + drag of text or a rubber-band selection opened an empty ring. The window now waits invisibly at the cursor and the donut appears only when files enter; otherwise it closes quietly when the button is released. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Services/RadialController.cs | 12 ++++++++---- src/Filee.App/ViewModels/RadialViewModel.cs | 16 ++++++++++++++++ src/Filee.App/Views/RadialWindow.axaml | 3 ++- tests/Filee.App.Tests/RenderTests.cs | 4 ++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index e3f11a8..4c887ab 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -1,8 +1,9 @@ // Opens, fills and closes the live donut toolbar. // // Drag mode (modifier + drag in Explorer): -// gesture detected → empty ring appears at the cursor (not activated) → DragEnter brings the file list → -// slices pop out → drop on a slice converts / drop in the hole or release elsewhere closes. +// gesture detected → an invisible window waits at the cursor (not activated) → DragEnter brings the file list → +// the donut pops out → drop on a slice converts / drop in the hole or release elsewhere closes. Without files +// (text, a rubber-band selection) nothing ever shows. // Click mode (keyboard shortcut, context menu, drop zone): // files are known up front → donut appears activated → click a slice to convert, right-click to edit, // Esc / click outside closes. @@ -45,7 +46,11 @@ public sealed class RadialController( public bool IsVisible => _visible; - /// Drag gesture started at the given physical screen point. + /// + /// Drag gesture started at the given physical screen point. The window is placed under the cursor but stays + /// invisible until files are dragged into it (FilesEntered); a gesture without files (text, a rubber-band + /// selection, nothing at all) never shows anything and closes when the button is released. + /// public void ShowForDrag(int x, int y) { ReleaseProgressToToast(); @@ -54,7 +59,6 @@ public void ShowForDrag(int x, int y) viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); - window.DonutControl.PlayOpenAnimation(); } /// Opens the donut for known files (click mode). diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 2526a20..01cb703 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -20,6 +20,18 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, [ObservableProperty] private double _holeRatio = 0.42; [ObservableProperty] private double _sliceOpacity = 0.96; + /// + /// 0 while a drag gesture waits for files, 1 otherwise. The gesture starts before anyone knows what is being + /// dragged (a file, text, or nothing: Ctrl + a rubber-band selection), so the donut only appears when files enter. + /// + [ObservableProperty] private double _donutOpacity = 1; + + /// + /// Background of the window while the donut is hidden: practically invisible, but not fully transparent, so + /// Windows still delivers the drag to this window and the files can arrive. + /// + [ObservableProperty] private string _hitBackground = "Transparent"; + /// Files the donut will convert (empty while waiting for a drag to enter). public IReadOnlyList Files { get; private set; } = []; @@ -41,6 +53,8 @@ public void ResetForDrag() Items = []; CenterTitle = loc["donut.center_drag"]; CenterSubtitle = null; + DonutOpacity = 0; + HitBackground = "#01000000"; } /// Fills the donut for the given files. @@ -48,6 +62,8 @@ public void Load(IReadOnlyList files, bool clickMode) { IsClickMode = clickMode; Files = files; + DonutOpacity = 1; + HitBackground = "Transparent"; ApplyAppearance(); var profile = ProfileSelector.Select(store.Profiles, files); diff --git a/src/Filee.App/Views/RadialWindow.axaml b/src/Filee.App/Views/RadialWindow.axaml index 5b1d9d7..6ddde95 100644 --- a/src/Filee.App/Views/RadialWindow.axaml +++ b/src/Filee.App/Views/RadialWindow.axaml @@ -18,8 +18,9 @@ ShowActivated="False" CanResize="False" SizeToContent="WidthAndHeight"> - +