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(); }