diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50ab67e0..8578daa2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,13 +158,14 @@ wheel flips). In click mode the focused window gets the key (`DonutMenu.HandleKe 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. 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. +macOS the drag donut takes key window status once files are on it (`RadialController.TakeKeyboard` / `IPlatformServices.TakeKeyWindow`), +and the key reaches its window without whole-application activation, keeping macOS Show Desktop (Desktop Reveal) and Exposé uninterrupted: +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 key window and 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`); key window status is explicitly released once a conversion starts or the donut closes +(`IPlatformServices.ReleaseKeyWindow` / `ReturnFocus`), returning typing to the previous app (unless the user clicked elsewhere meanwhile) while the progress ring finishes, 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 1a95b530..02e4a000 100644 --- a/src/Filee.App/Controls/DonutMenu.Flip.cs +++ b/src/Filee.App/Controls/DonutMenu.Flip.cs @@ -6,8 +6,9 @@ // 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). 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. +// key window status without whole-app activation 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. On drop or close, key window status is explicitly released (ReleaseKeyWindow / ReturnFocus). // // 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. diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index cf542dbb..d1a7e7d5 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -745,12 +745,14 @@ private void WatchFlipKey() 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 (). + /// macOS: once files are dragged onto the donut, it takes the keyboard without activating the entire application + /// (by making the non-activating panel the key window), so the flip key reaches its window as a key press while + /// leaving macOS "Show Desktop" (Desktop Reveal) mode uninterrupted. Watched from outside by the global hook it + /// may not reach: while an app holds Secure Input (Terminal with Secure Keyboard Entry, which can keep it from the + /// background) macOS gives no keys to a global hook, only to the key window and 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 (), + /// releasing key window status so typing returns to the previous app while the conversion ring finishes, 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) @@ -759,7 +761,10 @@ private void TakeKeyboard(RadialWindow window) return; platform.RememberFrontApp(); _tookKeyboard = true; - window.Activate(); + var handle = window.TryGetPlatformHandle()?.Handle ?? 0; + platform.TakeKeyWindow(handle); + if (handle == 0) + window.Activate(); } /// The drop opens a window of Filee's own: the keyboard stays with Filee for it, not given back on close. @@ -771,6 +776,8 @@ private void ReturnKeyboard() if (!_tookKeyboard) return; _tookKeyboard = false; + var handle = _window?.TryGetPlatformHandle()?.Handle ?? 0; + platform.ReleaseKeyWindow(handle); platform.ReturnFocus(); } diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 102f609b..ff86e3d6 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -174,6 +174,17 @@ void RememberFrontApp() { } /// didn't click elsewhere meanwhile) and that app still runs; forgets it either way. /// void ReturnFocus() { } + + /// + /// Makes the window key without activating the entire application, so the window can receive key presses + /// (such as the flip key during a drag) while leaving macOS Desktop Reveal / Exposé mode uninterrupted. + /// + void TakeKeyWindow(nint windowHandle) { } + + /// + /// Resigns key window status on a window previously made key, returning key events to the frontmost application. + /// + void ReleaseKeyWindow(nint windowHandle) { } } /// diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index d2af32fc..9c8f5834 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -140,11 +140,19 @@ public void RaiseTopmost(nint windowHandle) public void RestoreWindow(nint windowHandle) { if (windowHandle != 0) + { + if (_keyWindow == windowHandle) + { + _keyWindow = 0; + NativeMethods.ReleaseKeyWindow(windowHandle); + } NativeMethods.RestoreWindow(windowHandle); + } } // The app the donut took the keyboard from (its process id), 0 for none. private int _focusFrom; + private nint _keyWindow; public void RememberFrontApp() { @@ -154,12 +162,34 @@ public void RememberFrontApp() public void ReturnFocus() { + var keyWindow = _keyWindow; + _keyWindow = 0; + if (keyWindow != 0) + NativeMethods.ReleaseKeyWindow(keyWindow); + var from = _focusFrom; _focusFrom = 0; if (from != 0 && NativeMethods.FrontmostProcessId() == Environment.ProcessId) NativeMethods.ActivateProcess(from); } + public void TakeKeyWindow(nint windowHandle) + { + if (windowHandle == 0) + return; + _keyWindow = windowHandle; + NativeMethods.TakeKeyWindow(windowHandle); + } + + public void ReleaseKeyWindow(nint windowHandle) + { + if (windowHandle == 0) + return; + if (_keyWindow == windowHandle) + _keyWindow = 0; + NativeMethods.ReleaseKeyWindow(windowHandle); + } + /// /// 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 f70ad0f0..7ef07628 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -153,10 +153,10 @@ internal static bool PrefersReducedMotion() internal static void RaiseTopmost(nint window) { - const nint canJoinAllSpaces = 1, moveToActiveSpace = 1 << 1; + const nint canJoinAllSpaces = 1, moveToActiveSpace = 1 << 1, stationary = 1 << 4; const nint fullScreenPrimary = 1 << 7, fullScreenAuxiliary = 1 << 8, fullScreenNone = 1 << 9; var behavior = Send(window, sel_registerName("collectionBehavior")); - behavior = (behavior & ~(moveToActiveSpace | fullScreenPrimary | fullScreenNone)) | canJoinAllSpaces | fullScreenAuxiliary; + behavior = (behavior & ~(moveToActiveSpace | fullScreenPrimary | fullScreenNone)) | canJoinAllSpaces | stationary | fullScreenAuxiliary; // macOS 13's primary, auxiliary and canJoinAllApplications: with canJoinAllApplications set the donut still // stays off a full-screen Finder Space, even as a panel (#251). behavior &= ~((1 << 16) | (1 << 17) | (1 << 18)); @@ -509,4 +509,38 @@ internal static bool RequestAccessibility() [DllImport(ObjC, EntryPoint = "objc_msgSend")] [return: MarshalAs(UnmanagedType.I1)] private static extern bool SendBoolWithInteger(nint receiver, nint selector, nint value); + + internal static void TakeKeyWindow(nint window) + { + var selector = sel_registerName("makeKeyAndOrderFront:"); + if (SendBool(window, sel_registerName("respondsToSelector:"), selector)) + SendVoid(window, selector, 0); + } + + internal static void ReleaseKeyWindow(nint window) + { + if (!IsKeyWindow(window)) + return; + + var isVisibleSelector = sel_registerName("isVisible"); + var wasVisible = SendBool(window, sel_registerName("respondsToSelector:"), isVisibleSelector) + && SendBool(window, isVisibleSelector); + + var orderOutSelector = sel_registerName("orderOut:"); + if (SendBool(window, sel_registerName("respondsToSelector:"), orderOutSelector)) + SendVoid(window, orderOutSelector, 0); + + if (wasVisible) + { + var orderFrontSelector = sel_registerName("orderFrontRegardless"); + if (SendBool(window, sel_registerName("respondsToSelector:"), orderFrontSelector)) + SendVoid(window, orderFrontSelector); + } + } + + internal static bool IsKeyWindow(nint window) + { + var selector = sel_registerName("isKeyWindow"); + return SendBool(window, sel_registerName("respondsToSelector:"), selector) && SendBool(window, selector); + } } diff --git a/tests/Filee.App.Tests/RadialControllerTests.cs b/tests/Filee.App.Tests/RadialControllerTests.cs index e1581ba1..cfe63f4c 100644 --- a/tests/Filee.App.Tests/RadialControllerTests.cs +++ b/tests/Filee.App.Tests/RadialControllerTests.cs @@ -9,6 +9,7 @@ using Filee.App.Services; using Filee.App.Services.Triggers; using Filee.App.ViewModels; +using Filee.App.Views; using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Settings; @@ -73,6 +74,175 @@ public void Closing_a_visible_donut_restores_it_before_the_native_window_is_rele } } + [AvaloniaFact] + public void Files_entering_the_donut_during_drag_takes_and_releases_key_window() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS."); + return; + } + + TestServices.EnsureInitialized("en"); + var platform = new WindowPlatform(); + var radial = Create(platform); + try + { + radial.ShowForDrag(400, 400); + var window = radial.Window!; + var handle = window.TryGetPlatformHandle()?.Handle ?? 0; + Assert.Empty(platform.KeyWindowsTaken); + Assert.Empty(platform.KeyWindowsReleased); + + var filesEntered = (Action>?)typeof(RadialWindow) + .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)? + .GetValue(window); + Assert.NotNull(filesEntered); + + filesEntered([@"/tmp/filee-test-drag.png"]); + + Assert.Equal([handle], platform.KeyWindowsTaken); + Assert.Equal(1, platform.RememberFrontAppCalls); + Assert.Empty(platform.KeyWindowsReleased); + + radial.Close(animate: false); + + Assert.Equal([handle], platform.KeyWindowsReleased); + Assert.Equal(1, platform.ReturnFocusCalls); + } + finally + { + radial.Close(animate: false); + } + } + + [AvaloniaFact] + public void Dropping_files_on_the_donut_releases_key_window_while_progress_ring_shows() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS."); + return; + } + + TestServices.EnsureInitialized("en"); + var platform = new WindowPlatform(); + var radial = Create(platform); + var tempFile = Path.Combine(Path.GetTempPath(), "filee-test-drop.png"); + File.WriteAllBytes(tempFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + try + { + radial.ShowForDrag(400, 400); + var window = radial.Window!; + var handle = window.TryGetPlatformHandle()?.Handle ?? 0; + + var filesEntered = (Action>?)typeof(RadialWindow) + .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)? + .GetValue(window); + Assert.NotNull(filesEntered); + + filesEntered([tempFile]); + Assert.Equal([handle], platform.KeyWindowsTaken); + Assert.Empty(platform.KeyWindowsReleased); + + var dropped = (Action?)typeof(RadialWindow) + .GetField("Dropped", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)? + .GetValue(window); + Assert.NotNull(dropped); + + var model = AppHost.Get(); + var index = -1; + for (var i = 0; i < model.Items.Count; i++) + { + var preset = model.PresetAt(i, -1); + if (preset is not null && !preset.IsTool && !preset.Output.AskEveryTime) + { + index = i; + break; + } + } + + Assert.True(index >= 0); + dropped(new DonutHit(DonutHitKind.Segment, index, -1), new DraggedItems([tempFile], null)); + + // Key window status is released immediately on drop so typing returns to previous app + Assert.Equal([handle], platform.KeyWindowsReleased); + Assert.Equal(1, platform.ReturnFocusCalls); + } + finally + { + radial.Close(animate: false); + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [AvaloniaFact] + public void Drop_opening_owned_window_keeps_keyboard_for_it() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS."); + return; + } + + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var profile = store.Profiles.First(p => p.Id == "images"); + var beforePresets = profile.PresetIds; + profile.PresetIds = ["tool-crop", .. beforePresets]; + var platform = new WindowPlatform(); + var radial = Create(platform); + var tempFile = Path.Combine(Path.GetTempPath(), "filee-test-tool.png"); + File.WriteAllBytes(tempFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + try + { + radial.ShowForDrag(400, 400); + var window = radial.Window!; + var handle = window.TryGetPlatformHandle()?.Handle ?? 0; + + var filesEntered = (Action>?)typeof(RadialWindow) + .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)? + .GetValue(window); + Assert.NotNull(filesEntered); + + filesEntered([tempFile]); + Assert.Equal([handle], platform.KeyWindowsTaken); + Assert.Empty(platform.KeyWindowsReleased); + + var dropped = (Action?)typeof(RadialWindow) + .GetField("Dropped", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)? + .GetValue(window); + Assert.NotNull(dropped); + + var model = AppHost.Get(); + var toolIndex = -1; + for (var i = 0; i < model.Items.Count; i++) + { + var preset = model.PresetAt(i, -1); + if (preset is not null && preset.IsTool && !preset.IsCopyToFolder) + { + toolIndex = i; + break; + } + } + + Assert.True(toolIndex >= 0); + dropped(new DonutHit(DonutHitKind.Segment, toolIndex, -1), new DraggedItems([tempFile], null)); + + // Keyboard stays with Filee for the tool window + Assert.Empty(platform.KeyWindowsReleased); + Assert.Equal(0, platform.ReturnFocusCalls); + } + finally + { + profile.PresetIds = beforePresets; + radial.Close(animate: false); + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + private static RadialController Create(IPlatformServices platform) => new(AppHost.Get(), AppHost.Get(), AppHost.Get(), AppHost.Get(), platform, AppHost.Get(), NullLogger.Instance); @@ -80,8 +250,17 @@ private static RadialController Create(IPlatformServices platform) => private sealed class WindowPlatform : IPlatformServices { public int Restores { get; private set; } + public List KeyWindowsTaken { get; } = []; + public List KeyWindowsReleased { get; } = []; + public int RememberFrontAppCalls { get; private set; } + public int ReturnFocusCalls { get; private set; } + public void RestoreWindow(nint windowHandle) => Restores++; public void RaiseTopmost(nint windowHandle) { } + public void TakeKeyWindow(nint windowHandle) => KeyWindowsTaken.Add(windowHandle); + public void ReleaseKeyWindow(nint windowHandle) => KeyWindowsReleased.Add(windowHandle); + public void RememberFrontApp() => RememberFrontAppCalls++; + public void ReturnFocus() => ReturnFocusCalls++; public bool IsFileManagerAt(int x, int y) => false; public string? ProcessNameAt(int x, int y) => null; public IReadOnlyList GetFileManagerSelection() => []; diff --git a/tests/Filee.Platform.Tests/MacOSWindowTests.cs b/tests/Filee.Platform.Tests/MacOSWindowTests.cs index fbbc688f..4c22abe2 100644 --- a/tests/Filee.Platform.Tests/MacOSWindowTests.cs +++ b/tests/Filee.Platform.Tests/MacOSWindowTests.cs @@ -89,7 +89,7 @@ public void Raising_and_restoring_a_window_repeatedly_restores_its_native_class_ { platform.RaiseTopmost(window); Assert.Equal(panelClass, ObjectClass(window)); - Assert.Equal((nint)(1 | (1 << 6) | (1 << 8)), behavior); + Assert.Equal((nint)(1 | (1 << 4) | (1 << 6) | (1 << 8)), behavior); Assert.Equal(unrelatedStyle | nonactivatingPanel, style); Assert.False(hidesOnDeactivate); Assert.Equal((nint)3, level); @@ -141,6 +141,111 @@ public void Raising_and_restoring_a_window_repeatedly_restores_its_native_class_ } } + [Fact] + public void TakeKeyWindow_and_ReleaseKeyWindow_manage_key_window_state() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS."); + return; + } + + var isKey = false; + var isVisible = false; + var makeKeyCalls = 0; + var orderOutCalls = 0; + var orderFrontCalls = 0; + var resignKeyCalls = 0; + + SendActionWithArg makeKey = (_, _, _) => { isKey = true; isVisible = true; makeKeyCalls++; }; + SendActionWithArg orderOut = (_, _, _) => { isKey = false; isVisible = false; orderOutCalls++; }; + SendAction orderFront = (_, _) => { isVisible = true; orderFrontCalls++; }; + SendAction resignKey = (_, _) => resignKeyCalls++; + GetBool isKeyWindow = (_, _) => isKey; + GetBool getIsVisible = (_, _) => isVisible; + Delegate[] callbacks = [makeKey, orderOut, orderFront, resignKey, isKeyWindow, getIsVisible]; + + var testClass = AllocateClass(GetClass("NSObject"), "FileeKeyWindowTestWindow", 0); + Assert.NotEqual(0, testClass); + nint window = 0; + try + { + AddMethod(testClass, "makeKeyAndOrderFront:", makeKey, "v@:@"); + AddMethod(testClass, "orderOut:", orderOut, "v@:@"); + AddMethod(testClass, "orderFrontRegardless", orderFront, "v@:"); + AddMethod(testClass, "resignKeyWindow", resignKey, "v@:"); + AddMethod(testClass, "isKeyWindow", isKeyWindow, "B@:"); + AddMethod(testClass, "isVisible", getIsVisible, "B@:"); + RegisterClass(testClass); + + window = Send(Send(testClass, Selector("alloc")), Selector("init")); + Assert.NotEqual(0, window); + + var platform = new MacOSPlatformServices(); + Assert.False(isKey); + Assert.False(NativeMethods.IsKeyWindow(window)); + + // 1. TakeKeyWindow sends makeKeyAndOrderFront: + platform.TakeKeyWindow(window); + Assert.True(isKey); + Assert.True(isVisible); + Assert.True(NativeMethods.IsKeyWindow(window)); + Assert.Equal(1, makeKeyCalls); + Assert.Equal(0, orderOutCalls); + Assert.Equal(0, resignKeyCalls); + + // 2. ReleaseKeyWindow ends key status via orderOut: and restores visibility via orderFrontRegardless + platform.ReleaseKeyWindow(window); + Assert.False(isKey); + Assert.True(isVisible); + Assert.False(NativeMethods.IsKeyWindow(window)); + Assert.Equal(1, makeKeyCalls); + Assert.Equal(1, orderOutCalls); + Assert.Equal(1, orderFrontCalls); + Assert.Equal(0, resignKeyCalls); // resignKeyWindow is never invoked directly per Apple guidelines + + // 3. ReturnFocus releases key window if TakeKeyWindow was called + platform.TakeKeyWindow(window); + Assert.True(isKey); + Assert.True(NativeMethods.IsKeyWindow(window)); + Assert.Equal(2, makeKeyCalls); + + platform.ReturnFocus(); + Assert.False(isKey); + Assert.True(isVisible); + Assert.False(NativeMethods.IsKeyWindow(window)); + Assert.Equal(2, orderOutCalls); + Assert.Equal(2, orderFrontCalls); + Assert.Equal(0, resignKeyCalls); + + // 4. RestoreWindow releases key window if still tracked + platform.TakeKeyWindow(window); + Assert.True(isKey); + Assert.True(NativeMethods.IsKeyWindow(window)); + Assert.Equal(3, makeKeyCalls); + + platform.RestoreWindow(window); + Assert.False(isKey); + Assert.False(NativeMethods.IsKeyWindow(window)); + Assert.Equal(3, orderOutCalls); + Assert.Equal(0, resignKeyCalls); + + // 5. Handles zero safely + platform.TakeKeyWindow(0); + platform.ReleaseKeyWindow(0); + platform.RestoreWindow(0); + platform.ReturnFocus(); + } + finally + { + if (window != 0) + Send(window, Selector("release")); + if (testClass != 0) + DisposeClass(testClass); + GC.KeepAlive(callbacks); + } + } + private static nint RegisterSubclass(nint superclass, string name) { var result = AllocateClass(superclass, name, 0); @@ -161,6 +266,13 @@ private static void AddMethod(nint cls, string name, Delegate callback, string s [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void SendAction(nint receiver, nint selector); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SendActionWithArg(nint receiver, nint selector, nint arg); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private delegate bool GetBool(nint receiver, nint selector); + private const string ObjC = "/usr/lib/libobjc.A.dylib"; [DllImport(ObjC, EntryPoint = "objc_getClass")] @@ -188,4 +300,7 @@ private static extern bool AddNativeMethod(nint cls, nint selector, nint impleme [DllImport(ObjC, EntryPoint = "objc_msgSend")] private static extern nint Send(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint arg); }