From 01f5fc262826799613a0223fa77d4648547345a7 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Thu, 29 Oct 2026 16:36:14 -0600 Subject: [PATCH 1/2] fix(macos): read modifier keys so gestures work while another app holds Secure Input While another app holds Secure Input (Terminal's Secure Keyboard Entry left on in the background, a password field), the donut's Option/Command gestures stopped in Filee: every press arrived without modifiers. SharpHook's libuiohook rebuilds its modifier mask on every event with CGEventSourceKeyState, which answers "up" for held keys while another app holds Secure Input. CGEventSourceFlagsState and the event's own flags stay right (what NSEvent.modifierFlags reports, and what other gesture apps read). - IPlatformServices.CurrentModifiers(): null by default; on macOS the flags state (CGEventSourceFlagsState). - TriggerService takes mouse gesture modifiers from it when present: at a press, and on moves while a button is held. Buttons still come from the hook. Windows and Linux are unchanged. - The Ctrl+Option+Space shortcut can't be helped: Secure Input withholds the key press itself from other apps. Checked on macOS 26.6 with a helper app holding Secure Input: without the change Option+drag does nothing, with it the donut opens. To drop once SharpHook reads the flags state itself. Co-Authored-By: Claude Opus 5.5 --- .../Services/Triggers/TriggerService.cs | 17 ++++-- src/Filee.Core/Platform/IPlatformServices.cs | 8 +++ .../MacOSPlatformServices.cs | 7 +++ src/Filee.Platform.MacOS/NativeMethods.cs | 16 ++++++ .../TriggerServiceLifecycleTests.cs | 57 +++++++++++++++++-- 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs index 16c12357..0b7d5ee2 100644 --- a/src/Filee.App/Services/Triggers/TriggerService.cs +++ b/src/Filee.App/Services/Triggers/TriggerService.cs @@ -201,10 +201,10 @@ public void Start() _hook.MousePressed += (_, e) => { if (Trace) - _log.LogInformation("Trace: press {Button} at {X},{Y}, mask {Mask} = {Modifiers}", e.Data.Button, e.Data.X, e.Data.Y, e.RawEvent.Mask, Map(e.RawEvent.Mask)); + _log.LogInformation("Trace: press {Button} at {X},{Y}, mask {Mask} = {Modifiers}", e.Data.Button, e.Data.X, e.Data.Y, e.RawEvent.Mask, ModifiersOf(e.RawEvent.Mask)); var (x, y) = ButtonPoint(e); (_pressX, _pressY, _heldMoves, _farthest) = (x, y, 0, 0); - _detector.MouseDown(Map(e.Data.Button), x, y, Map(e.RawEvent.Mask)); + _detector.MouseDown(Map(e.Data.Button), x, y, ModifiersOf(e.RawEvent.Mask)); }; _hook.MouseReleased += (_, e) => { @@ -341,7 +341,7 @@ private void OnMove(MouseHookEventArgs e) _heldMoves++; _farthest = Math.Max(_farthest, Math.Max(Math.Abs(e.Data.X - _pressX), Math.Abs(e.Data.Y - _pressY))); } - _detector.MouseMove(e.Data.X, e.Data.Y, Map(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); + _detector.MouseMove(e.Data.X, e.Data.Y, MoveModifiers(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); } private void OnRelativeMove(MouseHookEventArgs e) @@ -350,7 +350,7 @@ private void OnRelativeMove(MouseHookEventArgs e) _estY += e.Data.Y; if (Trace && HeldButton(e.RawEvent.Mask) != TriggerMouseButton.None) _log.LogInformation("Trace: relative move {Dx},{Dy} -> {X},{Y}, mask {Mask}", e.Data.X, e.Data.Y, _estX, _estY, e.RawEvent.Mask); - _detector.MouseMove(_estX, _estY, Map(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); + _detector.MouseMove(_estX, _estY, MoveModifiers(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); } internal bool InScope(TriggerGesture gesture, int x, int y) @@ -380,6 +380,15 @@ internal bool InScope(TriggerGesture gesture, int x, int y) _ => TriggerMouseButton.None, }; + /// + /// The modifiers a mouse event was made with: what the platform reports held (macOS, whose hook mask can lose them), + /// else the hook's mask. Only asked while it matters: at a press, and on moves with a button held. + /// + private ModifierKeys ModifiersOf(EventMask mask) => _platform.CurrentModifiers() ?? Map(mask); + + // A move's modifiers only matter while a button is held (a modifier pressed after the button): not asked otherwise. + private ModifierKeys MoveModifiers(EventMask mask) => HeldButton(mask) == TriggerMouseButton.None ? Map(mask) : ModifiersOf(mask); + internal static ModifierKeys Map(EventMask mask) { var result = ModifierKeys.None; diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index f42457f5..0d21d590 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -1,3 +1,5 @@ +using Filee.Core.Settings; + namespace Filee.Core.Platform; /// Operating-system specific services. @@ -36,6 +38,12 @@ public interface IPlatformServices /// ForegroundWindowInfo? GetForegroundWindow() => null; + /// + /// The modifier keys held now, as the system reports them, for matching mouse gestures; null where the global hook's + /// own event mask is right (it isn't on macOS while another app holds Secure Input, see the macOS implementation). + /// + ModifierKeys? CurrentModifiers() => null; + /// /// Names of the apps that have a window open now, as reports them, sorted, without /// Filee. Windows reports a main window per process; macOS and Linux list the windows on screen. diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 30c923b9..0bc2ab88 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using Filee.Core.Platform; +using Filee.Core.Settings; namespace Filee.Platform.MacOS; @@ -41,6 +42,12 @@ public bool IsFileManagerAt(int x, int y) => public string? ProcessNameAt(int x, int y) => NativeMethods.ProcessNameAt(x, y); + // SharpHook's libuiohook rebuilds its modifier mask on every event with CGEventSourceKeyState, which answers "up" for + // held keys while another app holds Secure Input (Terminal's Secure Keyboard Entry, a password field): every press + // then looks modifier-free and no ⌥ / ⌘ gesture matches. The flags state stays right, as it does for AppKit's + // NSEvent.modifierFlags. Drop this once SharpHook reads the flags state itself. + public ModifierKeys? CurrentModifiers() => NativeMethods.CurrentModifiers(); + public IReadOnlyList RunningAppNames() => IPlatformServices.AppNames(NativeMethods.WindowOwnerNames().ToList()); public IReadOnlyList GetFileManagerSelection() diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index b343bab7..02bd4892 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using Filee.Core.Settings; namespace Filee.Platform.MacOS; @@ -22,6 +23,18 @@ internal static class NativeMethods private static nint CoreFoundationConstant(string name) => Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), name)); + /// The modifier keys held now (kCGEventSourceStateCombinedSessionState: every keyboard, including posted keys). + internal static ModifierKeys CurrentModifiers() + { + var flags = CGEventSourceFlagsState(0); + var keys = ModifierKeys.None; + if ((flags & 0x20000) != 0) keys |= ModifierKeys.Shift; // kCGEventFlagMaskShift + if ((flags & 0x40000) != 0) keys |= ModifierKeys.Ctrl; // kCGEventFlagMaskControl + if ((flags & 0x80000) != 0) keys |= ModifierKeys.Alt; // kCGEventFlagMaskAlternate (Option) + if ((flags & 0x100000) != 0) keys |= ModifierKeys.Meta; // kCGEventFlagMaskCommand + return keys; + } + /// Process names of the app windows on screen (layer 0), front to back. internal static IEnumerable WindowOwnerNames() { @@ -396,6 +409,9 @@ internal static bool RequestAccessibility() [DllImport(CoreGraphics)] private static extern nint CGWindowListCopyWindowInfo(uint option, uint relativeToWindow); + [DllImport(CoreGraphics)] + private static extern ulong CGEventSourceFlagsState(int stateId); + [DllImport(CoreGraphics)] [return: MarshalAs(UnmanagedType.I1)] private static extern bool CGRectMakeWithDictionaryRepresentation(nint dictionary, out CGRect rect); diff --git a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs index 549c8249..1f1c2382 100644 --- a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs +++ b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs @@ -1,6 +1,7 @@ // Verifies that retrying gestures waits for the previous native hook to release its shared callback, that // Wayland's relative mouse motion still starts a drag gesture, and that Tab is reported (to flip the drag donut) only -// while asked for. +// while asked for; and that on macOS a press takes the modifiers the system reports when the hook's mask lost them +// (another app holding Secure Input). using Avalonia.Headless.XUnit; using Avalonia.Threading; @@ -172,6 +173,44 @@ public void Wayland_absolute_pointer_moves_set_the_estimate() Assert.Equal((110, 200), service.LastCursor); } + [AvaloniaFact] + public void A_press_takes_the_modifiers_the_platform_reports_when_the_hook_mask_lost_them() + { + // macOS while another app holds Secure Input: the hook's mask has the button but no modifier, while the system + // still reports Ctrl held. + var (provider, _, started) = StartGesture(wayland: false, modifiers: ModifierKeys.Ctrl); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + Assert.Equal((60, 60), (WaitForStart(started).X, WaitForStart(started).Y)); + } + + [AvaloniaFact] + public void A_modifier_pressed_after_the_button_counts_when_the_platform_reports_it() + { + var platform = new NullPlatformServicesForHooks { Modifiers = ModifierKeys.None }; + var (provider, _, started) = StartGesture(wayland: false, platform: platform); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + Assert.Null(Settle(started)); // the press is handled (on the hook's thread) before Ctrl goes down + platform.Modifiers = ModifierKeys.Ctrl; // Ctrl pressed while the button is held; the hook's mask doesn't show it + provider.PostMouse(EventType.MouseDragged, 52, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + WaitForStart(started); + } + + [AvaloniaFact] + public void Without_a_platform_reading_the_hook_mask_decides() + { + // Windows and Linux report nothing: a press without the modifier in its mask arms nothing, one with it drags. + var (provider, _, started) = StartGesture(wayland: false); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseReleased, 60, 60, EventMask.None); + Assert.Null(Settle(started)); + provider.PostMouse(EventType.MousePressed, 50, 60, Held); + provider.PostMouse(EventType.MouseDragged, 60, 60, Held); + WaitForStart(started); + } + [AvaloniaFact] public void Off_Wayland_presses_and_the_cursor_keep_their_coordinates() { @@ -252,11 +291,13 @@ internal static T Settle(Func read) return read(); } - private static (DelayedProvider Provider, TriggerService Service, Func Started) StartGesture(bool wayland) + private static (DelayedProvider Provider, TriggerService Service, Func Started) StartGesture(bool wayland, + ModifierKeys? modifiers = null, NullPlatformServicesForHooks? platform = null) { var provider = new DelayedProvider(); provider.AllowExit.Set(); - var service = Create(provider, () => { }, wayland); + var service = Create(provider, () => { }, wayland, + platform ?? new NullPlatformServicesForHooks { PressCarriesPosition = !wayland, Modifiers = modifiers }); // Ctrl + left drag on every OS: the default drag gesture uses Option on macOS, which these presses don't hold. service.Apply(new AppSettings { Triggers = [new TriggerGesture { Modifiers = ModifierKeys.Ctrl }] }); GestureEventArgs? started = null; @@ -272,8 +313,9 @@ private static GestureEventArgs WaitForStart(Func started) return started()!; } - internal static TriggerService Create(DelayedProvider provider, Action key, bool wayland = false) => - new(new NullPlatformServicesForHooks { PressCarriesPosition = !wayland }, NullLogger.Instance, () => + internal static TriggerService Create(DelayedProvider provider, Action key, bool wayland = false, + NullPlatformServicesForHooks? platform = null) => + new(platform ?? new NullPlatformServicesForHooks { PressCarriesPosition = !wayland }, NullLogger.Instance, () => { var hook = new QueuedGlobalHook(provider); hook.KeyPressed += (_, _) => key(); @@ -365,6 +407,11 @@ internal sealed class NullPlatformServicesForHooks : IPlatformServices public bool PressCarriesPosition { get; init; } = true; public bool FileManager { get; set; } = true; public string? Process { get; set; } = "explorer"; + + /// What reports (null: the hook's mask decides, as on Windows and Linux). + public ModifierKeys? Modifiers { get; set; } + + public ModifierKeys? CurrentModifiers() => Modifiers; public bool IsFileManagerAt(int x, int y) => FileManager; public string? ProcessNameAt(int x, int y) => Process; public IReadOnlyList GetFileManagerSelection() => []; From fe3dd6a301fd4ac565abf9a2b4d441d3a138f885 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Fri, 30 Oct 2026 15:37:10 -0600 Subject: [PATCH 2/2] fix(macos): the flip key works while another app holds Secure Input During a drag the donut heard the flip key only through the global hook, and while another app holds Secure Input (Terminal with Secure Keyboard Entry, which can keep it from the background) macOS gives no keys to a hook: Tab never flipped the donut. Measured: no key events reached the hook and CGEventSourceKeyState never read Tab down, while the app in front still got every key. On macOS the drag donut now takes the keyboard once files are dragged onto it, and Tab reaches its window like any key press; the drag carries on. The hook's copy of a press is ignored meanwhile (one flip per press). The keyboard goes back to the app that had it once a conversion starts or the donut closes, unless the user clicked elsewhere, and stays with Filee when the drop opens a window of its own (a tool, the merge order, the save dialog). Windows is unchanged: taking the keyboard there breaks Explorer's drag. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 9 +- src/Filee.App/Controls/DonutMenu.Flip.cs | 8 +- src/Filee.App/Services/RadialController.cs | 60 +++++- src/Filee.Core/Platform/IPlatformServices.cs | 12 ++ .../MacOSPlatformServices.cs | 17 ++ src/Filee.Platform.MacOS/NativeMethods.cs | 18 ++ tests/Filee.App.Tests/DonutFlipTests.cs | 178 ++++++++++++++++++ .../TriggerServiceLifecycleTests.cs | 4 + 8 files changed, 298 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a327812d..5eca5482 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,7 +140,14 @@ flipped to, and only then does the hub leave out its "⟳ Tab back" hint (a mous wheel flips). In click mode the focused window gets the key (`DonutMenu.HandleKey`) and the donut the wheel. During a drag the donut's window has no keyboard focus, so `RadialController` turns on `TriggerService.WatchFlipKey` while the drag donut shows files and flips on `FlipKeyPressed` / `FlipWheelTurned`: the global hook only listens (it -can't hold an event back; Explorer and Finder ignore Tab during a drag) and reports one flip per key press. +can't hold an event back; Explorer and Finder ignore Tab during a drag) and reports one flip per key press. On +macOS the drag donut takes the keyboard once files are on it (`RadialController.TakeKeyboard`), and the key reaches +its window as in click mode: while another app holds Secure Input (Terminal with Secure Keyboard Entry can keep it +from the background) macOS gives no keys to a hook, only to the app in front. The hook's copy of a press is ignored +while the window has the keyboard (`HookFlipsKey`, one flip per press, following `IsActive`); the keyboard goes back +to the app that had it once a conversion starts or the donut closes (`IPlatformServices.RememberFrontApp` / +`ReturnFocus`), unless the user clicked elsewhere meanwhile, and stays with Filee when the drop opens a window of its +own (a tool, the merge order, the save dialog). Not on Windows, where taking the keyboard during a drag breaks Explorer's. The editor's Front / Back switch (`ToolbarPageViewModel.EditingBack`) edits either list with the same drag and drop. A group is one slice that opens up to six non-group actions. Its members may be conversions or interactive tools diff --git a/src/Filee.App/Controls/DonutMenu.Flip.cs b/src/Filee.App/Controls/DonutMenu.Flip.cs index c3cf4294..f114c5a3 100644 --- a/src/Filee.App/Controls/DonutMenu.Flip.cs +++ b/src/Filee.App/Controls/DonutMenu.Flip.cs @@ -5,7 +5,9 @@ // The flip key (Tab unless the user records another in Settings → Shortcuts) and the mouse wheel flip it: // Click mode → the key reaches the focused window (HandleKey), the wheel the donut (OnPointerWheelChanged). // Drag mode → the window has no keyboard focus during an OS drag: RadialController flips when the global hook -// sees the key or the wheel (TriggerService.FlipKeyPressed / FlipWheelTurned). +// sees the key or the wheel (TriggerService.FlipKeyPressed / FlipWheelTurned). On macOS the donut takes +// the keyboard once files are dragged onto it (RadialController.TakeKeyboard), and the key then reaches +// the window as in click mode: the hook may not see it while another app holds Secure Input. // // The owner swaps Items immediately when FaceSwapping is raised (RadialWindow → RadialViewModel). Old slices are // retained for drawing only; hit-testing and drops always use the new face. @@ -85,8 +87,8 @@ private sealed record OldGroup(int Index, IReadOnlyList Children, dou public bool FlipWithWheel { get => GetValue(FlipWithWheelProperty); set => SetValue(FlipWithWheelProperty, value); } /// - /// Drag mode: the global hook flips on the flip key (RadialController), so the window's own key press doesn't flip - /// too. macOS can give the donut's window the keyboard during a drag, and one press then flipped twice (#504). + /// The global hook flips on the flip key (RadialController: drag mode, while the window doesn't have the keyboard), + /// so the window's own key press doesn't flip too; one press flipped twice otherwise (#504). /// public bool FlipKeyFromHook { get; set; } diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 1e7749d4..26a8b602 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -186,6 +186,7 @@ public void Close(bool animate = true) _window.DonutControl.LeaveProgressMode(); } _visible = false; + ReturnKeyboard(); WatchFlipKey(); _grace.Cancel(); ReplaceWeb(null); @@ -251,6 +252,7 @@ private RadialWindow EnsureWindow() return; viewModel.Load(files, clickMode: false); donut.PlayOpenAnimation(); + TakeKeyboard(window); WatchFlipKey(); }; window.Dropped += (hit, dropped) => @@ -273,6 +275,13 @@ private RadialWindow EnsureWindow() log.LogInformation("Drag over the donut: {Events} move event(s), ended on {Kind} {Index}, drawn lit {Lit:0.00}{Drop}", events, hit.Kind, hit.Index, lit, dropped ? "" : ", left without a drop"); window.EscapePressed += () => Close(); + // The flip key comes from the window while it has the keyboard, else from the hook (WatchFlipKey): followed by + // the value, which the Activated event may come before. + window.PropertyChanged += (_, e) => + { + if (e.Property == Avalonia.Controls.WindowBase.IsActiveProperty) + WatchFlipKey(); + }; window.Deactivated += (_, _) => { if (!viewModel.IsClickMode || _editing) @@ -291,10 +300,11 @@ private RadialWindow EnsureWindow() }; donut.ActiveIndexChanged += (_, index) => viewModel.ShowHint(index, donut.ActiveSubIndex, donut.IsCenterHot); - // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window). + // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window, and so + // does a drag-mode donut that has the keyboard: see TakeKeyboard). triggers.FlipKeyPressed += (_, _) => { - if (FlipsFromHook) + if (HookFlipsKey) donut.Flip(); }; triggers.FlipWheelTurned += (_, _) => @@ -342,6 +352,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w { log.LogInformation("Opening {Preset} for {Count} file(s)", preset.Id, sources.Count); var near = _window?.Position; + KeepKeyboard(); // for the tool's window Close(animate: false); if (web is null) windows.ShowTool(sources, preset, near); @@ -353,6 +364,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w // Merged PDFs, joined clips, slideshows and merged pictures follow the order the user picks. if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) { + KeepKeyboard(); // for the order window Close(animate: false); _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard); return; @@ -362,6 +374,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". if (preset.Output.AskEveryTime && (web is null || preset.IsCopyToFolder)) { + KeepKeyboard(); // for the save dialog Close(animate: false); _ = AskThenConvertAsync(sources, preset, _toClipboard, web); return; @@ -377,6 +390,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w } _converted = true; ShowProgress(job); + ReturnKeyboard(); // the ring shows the conversion; nothing here needs the keyboard any more } private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard) @@ -530,6 +544,7 @@ private void DetachProgress() private void HideWindow() { _visible = false; + ReturnKeyboard(); WatchFlipKey(); _window?.DonutControl.CollapseGroup(); _window?.Hide(); @@ -564,14 +579,51 @@ private async Task EditPresetAsync(int index, int sub = -1) private void WatchFlipKey() { triggers.WatchFlipKey = FlipsFromHook; - // One source per press: the hook in drag mode, the window in click mode (#504). + // One source per press (#504): the window while it has the keyboard (click mode, or a drag-mode donut that took + // it), else the hook. if (_window is not null) - _window.DonutControl.FlipKeyFromHook = FlipsFromHook; + _window.DonutControl.FlipKeyFromHook = HookFlipsKey; } /// A drag-mode donut shows files and nothing was dropped yet: the hook's flip key and wheel flip it. private bool FlipsFromHook => _visible && !_dropped && !viewModel.IsClickMode && viewModel.HasFiles; + /// The hook's flip key flips the donut: in drag mode, while its window doesn't have the keyboard. + private bool HookFlipsKey => FlipsFromHook && _window?.IsActive != true; + + // The donut took the keyboard during a drag (TakeKeyboard) and hasn't given it back yet. + private bool _tookKeyboard; + + /// + /// macOS: once files are dragged onto the donut, it takes the keyboard (Filee comes to the front), so the flip key + /// reaches its window as a key press like any other. Watched from outside by the global hook it may not: while an + /// app holds Secure Input (Terminal with Secure Keyboard Entry, which can keep it from the background) macOS + /// gives no keys to anything watching, only to the app in front. The drag carries on; the keyboard goes back to the + /// app that had it once the conversion starts or the donut closes (), and stays with + /// Filee when the drop opens a window of its own: a tool, the merge order, the save dialog (). + /// Not on Windows, where a window taking the keyboard during a drag breaks Explorer's drag. + /// + private void TakeKeyboard(RadialWindow window) + { + if (!OperatingSystem.IsMacOS() || _tookKeyboard || window.IsActive) + return; + platform.RememberFrontApp(); + _tookKeyboard = true; + window.Activate(); + } + + /// The drop opens a window of Filee's own: the keyboard stays with Filee for it, not given back on close. + private void KeepKeyboard() => _tookKeyboard = false; + + /// Gives the keyboard back to the app the donut took it from, if the donut still has it. + private void ReturnKeyboard() + { + if (!_tookKeyboard) + return; + _tookKeyboard = false; + platform.ReturnFocus(); + } + /// /// Top-left corner of a square window centred on , moved into /// when that would cross its edge. With group slices the window holds the ring they fan out diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 0d21d590..f6b31614 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -159,6 +159,18 @@ void OpenWith(string path) { } /// window below normal ones (seen after the Photos app opened) while it still reports itself as topmost. /// void RaiseTopmost(nint windowHandle); + + /// + /// Remembers the app in front now, so can give it the keyboard back: on macOS the donut + /// takes the keyboard during a drag. Elsewhere, and when this app is the one in front, nothing is remembered. + /// + void RememberFrontApp() { } + + /// + /// Gives the keyboard back to the app remembered, if this app still has it (the user + /// didn't click elsewhere meanwhile) and that app still runs; forgets it either way. + /// + void ReturnFocus() { } } /// diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 0bc2ab88..db8a737c 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -136,6 +136,23 @@ public void RaiseTopmost(nint windowHandle) NativeMethods.RaiseTopmost(windowHandle); } + // The app the donut took the keyboard from (its process id), 0 for none. + private int _focusFrom; + + public void RememberFrontApp() + { + var front = NativeMethods.FrontmostProcessId(); + _focusFrom = front != Environment.ProcessId ? front : 0; + } + + public void ReturnFocus() + { + var from = _focusFrom; + _focusFrom = 0; + if (from != 0 && NativeMethods.FrontmostProcessId() == Environment.ProcessId) + NativeMethods.ActivateProcess(from); + } + /// /// 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). diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index 02bd4892..716aaf65 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -126,6 +126,24 @@ internal static bool IsFinderFrontmost() return Marshal.PtrToStringUTF8(Send(bundle, sel_registerName("UTF8String"))) == "com.apple.finder"; } + /// The process id of the app in front (NSWorkspace.frontmostApplication), 0 if none. + internal static int FrontmostProcessId() + { + using var pool = new AutoreleasePool(); + var workspace = Send(objc_getClass("NSWorkspace"), sel_registerName("sharedWorkspace")); + var application = Send(workspace, sel_registerName("frontmostApplication")); + return application == 0 ? 0 : (int)Send(application, sel_registerName("processIdentifier")); + } + + /// Brings the app with process id to the front, if it still runs. + internal static void ActivateProcess(int pid) + { + using var pool = new AutoreleasePool(); + var application = Send(objc_getClass("NSRunningApplication"), sel_registerName("runningApplicationWithProcessIdentifier:"), pid); + if (application != 0) + SendBool(application, sel_registerName("activateWithOptions:"), 0); + } + internal static bool PrefersReducedMotion() { using var pool = new AutoreleasePool(); diff --git a/tests/Filee.App.Tests/DonutFlipTests.cs b/tests/Filee.App.Tests/DonutFlipTests.cs index 2a71f00c..65d684bc 100644 --- a/tests/Filee.App.Tests/DonutFlipTests.cs +++ b/tests/Filee.App.Tests/DonutFlipTests.cs @@ -869,6 +869,7 @@ public void In_drag_mode_tab_from_the_global_hook_flips_the_donut() => With(anim Assert.Equal(new[] { file }, vm.Files); Assert.False(vm.IsBackFace); Assert.True(triggers.WatchFlipKey); + var other = TakeKeyboardAway(window); provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); // #504: macOS can hand the donut's window the same press as well; it must not flip a second time. @@ -887,6 +888,96 @@ public void In_drag_mode_tab_from_the_global_hook_flips_the_donut() => With(anim provider.PostKey(EventType.KeyPressed, KeyCode.VcTab); Assert.False(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); window.Close(); + other?.Close(); + } + finally + { + folder.Delete(recursive: true); + } + }); + + /// + /// macOS: these tests are about the hook, which flips while the donut's window doesn't have the keyboard, as on + /// Windows and Linux; on macOS the donut takes it once files are on it (see the test after this one). Another window + /// takes it back; null where the donut didn't have it. + /// + private static Window? TakeKeyboardAway(RadialWindow window) + { + if (!window.IsActive) + return null; + // Showing another headless window doesn't take the keyboard from this one: the platform says it lost it, through + // the callback Avalonia keeps on the window's platform half (not public). + var impl = window.PlatformImpl!; + var deactivated = impl.GetType().GetInterfaces().Append(impl.GetType()) + .Select(t => t.GetProperty("Deactivated", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + .First(p => p is not null)!; + ((Action)deactivated.GetValue(impl)!).Invoke(); + Dispatcher.UIThread.RunJobs(); + Assert.False(window.IsActive); + return null; + } + + /// + /// macOS: once files are dragged onto the donut it takes the keyboard, so the flip key reaches its window as a key + /// press, which works while another app holds Secure Input and hides every key from the hook. One flip per press: + /// the hook's copy of it is ignored meanwhile. The keyboard goes back to the app that had it on the drop or when the + /// donut closes, and the hook takes over again if the user clicks elsewhere. + /// + [AvaloniaFact] + public void On_macos_the_drag_donut_takes_the_keyboard_so_the_flip_key_reaches_it() => With(animations: false, back: null, () => + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "The donut takes the keyboard during a drag on macOS only"); + var provider = new TriggerServiceLifecycleTests.DelayedProvider(); + provider.AllowExit.Set(); + using var triggers = TriggerServiceLifecycleTests.Create(provider, () => { }); + triggers.Start(); + Assert.True(SpinWait.SpinUntil(() => provider.Runs == 1, 2000)); + var vm = AppHost.Get(); + var platform = new TriggerServiceLifecycleTests.NullPlatformServicesForHooks(); + var radial = new RadialController(vm, AppHost.Get(), AppHost.Get(), Store, + platform, triggers, NullLogger.Instance); + var folder = Directory.CreateTempSubdirectory("filee-flip-"); + try + { + var file = Path.Combine(folder.FullName, "photo.jpg"); + File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF]); + radial.ShowForDrag(400, 400); + var window = radial.Window!; + RenderTests.Pump(); // laid out: the drag enters where the donut is + // A modifier-drag without files (a rubber-band selection) takes nothing. + Assert.False(window.IsActive); + Assert.Equal(0, platform.Remembered); + + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateFile(window.StorageProvider.TryGetFileFromPathAsync(file).GetAwaiter().GetResult()!)); + window.DragDrop(new Point(window.Bounds.Width / 2 + 60, window.Bounds.Height / 2), RawDragEventType.DragEnter, data, + DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.True(window.IsActive); + Assert.Equal(1, platform.Remembered); + + // Secure Input: only the window gets the press, and it flips on its own. + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(vm.IsBackFace); + // Without it the hook sees the same press too, and doesn't flip a second time (#504). + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); + Dispatcher.UIThread.RunJobs(); + Assert.False(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); + provider.PostKey(EventType.KeyReleased, KeyCode.VcTab, EventMask.LeftCtrl); + + // The user clicked elsewhere: the hook flips again. + var other = TakeKeyboardAway(window); + provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); + Assert.True(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); + + radial.Close(animate: false); + Assert.Equal(1, platform.Returned); + radial.Close(animate: false); // given back once + Assert.Equal(1, platform.Returned); + window.Close(); + other?.Close(); } finally { @@ -1001,6 +1092,7 @@ public void In_drag_mode_the_wheel_and_the_set_key_from_the_global_hook_flip_the DragDropEffects.Copy, RawInputModifiers.Control); Dispatcher.UIThread.RunJobs(); Assert.True(triggers.WatchFlipKey); + using var away = new Disposer(TakeKeyboardAway(window)); // Tab isn't the flip key now; F2 is. provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); @@ -1300,4 +1392,90 @@ public void The_editor_switches_between_the_front_and_the_back(string language, Store.SaveLibrary(); } }, language); + + /// + /// macOS: the keyboard the drag donut took goes back to the app that had it once a conversion starts, but stays with + /// Filee when the drop opens a window of its own (here the crop tool), or that window would open behind the app. + /// + [AvaloniaFact] + public void On_macos_the_keyboard_goes_back_after_a_conversion_and_stays_for_a_tools_window() => With(animations: false, back: null, () => + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "The donut takes the keyboard during a drag on macOS only"); + var provider = new TriggerServiceLifecycleTests.DelayedProvider(); + provider.AllowExit.Set(); + using var triggers = TriggerServiceLifecycleTests.Create(provider, () => { }); + var vm = AppHost.Get(); + var platform = new TriggerServiceLifecycleTests.NullPlatformServicesForHooks(); + var radial = new RadialController(vm, AppHost.Get(), AppHost.Get(), Store, + platform, triggers, NullLogger.Instance); + var conversions = AppHost.Get(); + var folder = Directory.CreateTempSubdirectory("filee-flip-"); + ConversionJob? started = null; + try + { + var file = Path.Combine(folder.FullName, "photo.jpg"); + File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF]); + + // A slice that converts: the keyboard goes back once the ring shows the conversion. + var (window, data) = DragOnto(radial, file); + var donut = window.DonutControl; + var convert = Enumerable.Range(0, vm.Items.Count).First(i => vm.PresetAt(i, -1) is { IsTool: false, IsGroup: false }); + var at = donut.TranslatePoint(At(donut, DonutGeometry.MidAngle(convert, vm.Items.Count)), window)!.Value; + var before = conversions.Jobs.Select(j => j.Job.Id).ToHashSet(); + window.DragDrop(at, RawDragEventType.DragOver, data, DragDropEffects.Copy, RawInputModifiers.Control); + window.DragDrop(at, RawDragEventType.Drop, data, DragDropEffects.Copy, RawInputModifiers.Control); + var job = Assert.Single(conversions.Jobs, j => !before.Contains(j.Job.Id)); + started = job.Job; + job.CancelCommand.Execute(null); + Assert.Equal(1, platform.Remembered); + Assert.Equal(1, platform.Returned); + radial.Close(animate: false); + Assert.Equal(1, platform.Returned); // given back once + + // The crop tool on the back face: its window gets the keyboard, so it isn't given back. + (window, data) = DragOnto(radial, file); + donut = window.DonutControl; + Assert.Equal(2, platform.Remembered); + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(vm.IsBackFace); + var crop = Enumerable.Range(0, vm.Items.Count).First(i => vm.PresetAt(i, -1) is { IsCropTool: true }); + at = donut.TranslatePoint(At(donut, DonutGeometry.MidAngle(crop, vm.Items.Count)), window)!.Value; + window.DragDrop(at, RawDragEventType.DragOver, data, DragDropEffects.Copy, RawInputModifiers.Control); + window.DragDrop(at, RawDragEventType.Drop, data, DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.False(radial.IsVisible); + Assert.Equal(1, platform.Returned); + window.Close(); + } + finally + { + radial.Close(animate: false); + (typeof(WindowService).GetField("_cropOverlay", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(AppHost.Get()) as Window)?.Close(); + if (started is not null) + ClipWindowTests.WaitFor(() => started.State is not (JobState.Queued or JobState.Running)); + try { folder.Delete(recursive: true); } catch (IOException) { } + } + }); + + // A drag of file onto the donut, laid out first so the drag enters where the donut is. + private static (RadialWindow Window, DataTransfer Data) DragOnto(RadialController radial, string file) + { + radial.ShowForDrag(400, 400); + var window = radial.Window!; + RenderTests.Pump(); + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateFile(window.StorageProvider.TryGetFileFromPathAsync(file).GetAwaiter().GetResult()!)); + window.DragDrop(new Point(window.Bounds.Width / 2 + 60, window.Bounds.Height / 2), RawDragEventType.DragEnter, data, + DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.True(window.IsActive); + return (window, data); + } + + private sealed class Disposer(Window? window) : IDisposable + { + public void Dispose() => window?.Close(); + } } diff --git a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs index 1f1c2382..e0633e35 100644 --- a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs +++ b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs @@ -424,5 +424,9 @@ public void SetContextMenu(bool enabled, string executablePath, string label) { public void RevealInFileManager(string path) { } public void OpenFile(string path) { } public void RaiseTopmost(nint windowHandle) { } + public int Remembered { get; private set; } + public int Returned { get; private set; } + public void RememberFrontApp() => Remembered++; + public void ReturnFocus() => Returned++; } }