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/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.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">
-
+
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()
{
diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs
index ed6d66c..619435f 100644
--- a/tests/Filee.App.Tests/RenderTests.cs
+++ b/tests/Filee.App.Tests/RenderTests.cs
@@ -125,7 +125,11 @@ public void Donut_toolbar_waits_for_files_in_drag_mode()
Pump();
Assert.Empty(vm.Items);
+ // Nothing shows until files are dragged in (a Ctrl + drag without files must not open anything).
+ Assert.Equal(0, vm.DonutOpacity);
Save(window, "donut-drag-waiting.png");
+ vm.Load([@"C:\photos\a.jpg"], clickMode: false);
+ Assert.Equal(1, vm.DonutOpacity);
window.Close();
}