From ef702642366d58560e22ccaed2efa46b3e6bf37a Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Thu, 19 Feb 2026 20:01:50 +0900 Subject: [PATCH] [macOS] Add NativeDock.Menu API for adding menu items to macOS dock icon (#20634) * Native DockMenu code * Add Native Interop * Update ControlCatalog sample * Add unit tests * Add Action to AvaloniaNativeMenuExporter * Add dynamic dock item demo * Move s_dockMenu reference to App * Use Appium tests * Revert INativeMenuExporterResetHandler * Properly set the button for the checkbox * Add dock test * I hate Appium * Rename NativeMenu.DockMenu to NativeDock.Menu * Make static * Remove Dock Click Test * Add white space back for cleaner diff * Reduce MenuExporter back to one * Revert UpdateIfNeeded to private * Revert QueueReset to private too... and fix some whitespace * Revert IAvnMenuItem/IAvnMenu back * That's what I get not comparing it to master * And update this too * Add documentation --- native/Avalonia.Native/src/OSX/app.mm | 12 ++ native/Avalonia.Native/src/OSX/common.h | 1 + native/Avalonia.Native/src/OSX/main.mm | 19 ++- samples/ControlCatalog/App.xaml | 9 ++ samples/ControlCatalog/App.xaml.cs | 34 +++++ samples/IntegrationTestApp/App.axaml | 7 + samples/IntegrationTestApp/App.axaml.cs | 25 ++++ .../Pages/DesktopPage.axaml | 6 + .../Pages/DesktopPage.axaml.cs | 10 ++ src/Avalonia.Controls/NativeDock.cs | 28 ++++ .../AvaloniaNativeMenuExporter.cs | 135 +++++++++++++++--- src/Avalonia.Native/AvaloniaNativePlatform.cs | 5 + .../AvaloniaNativePlatformExtensions.cs | 1 + src/Avalonia.Native/avn.idl | 1 + .../NativeMenuTests.cs | 41 +++++- 15 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 src/Avalonia.Controls/NativeDock.cs diff --git a/native/Avalonia.Native/src/OSX/app.mm b/native/Avalonia.Native/src/OSX/app.mm index 5dc994fb6b..092bde9c07 100644 --- a/native/Avalonia.Native/src/OSX/app.mm +++ b/native/Avalonia.Native/src/OSX/app.mm @@ -1,11 +1,13 @@ #include "common.h" #include "AvnString.h" +#include "menu.h" @interface AvnAppDelegate : NSObject -(AvnAppDelegate* _Nonnull) initWithEvents: (IAvnApplicationEvents* _Nonnull) events; -(void) releaseEvents; @end NSApplicationActivationPolicy AvnDesiredActivationPolicy = NSApplicationActivationPolicyRegular; +static NSMenu* s_dockMenu = nil; @implementation AvnAppDelegate ComPtr _events; @@ -86,6 +88,11 @@ ComPtr _events; return _events->TryShutdown() ? NSTerminateNow : NSTerminateCancel; } +- (NSMenu *)applicationDockMenu:(NSApplication *)sender +{ + return s_dockMenu; +} + @end @interface AvnApplication : NSApplication @@ -180,3 +187,8 @@ extern IAvnApplicationCommands* CreateApplicationCommands() { return new AvnApplicationCommands(); } + +extern void SetDockMenu(NSMenu* menu) +{ + s_dockMenu = menu; +} diff --git a/native/Avalonia.Native/src/OSX/common.h b/native/Avalonia.Native/src/OSX/common.h index fae03984fd..a993784fc4 100644 --- a/native/Avalonia.Native/src/OSX/common.h +++ b/native/Avalonia.Native/src/OSX/common.h @@ -38,6 +38,7 @@ extern void SetAppMenu(IAvnMenu *menu); extern void SetServicesMenu (IAvnMenu* menu); extern IAvnMenu* GetAppMenu (); extern NSMenuItem* GetAppMenuItem (); +extern void SetDockMenu(NSMenu* menu); extern void InitializeAvnApp(IAvnApplicationEvents* events, bool disableAppDelegate); extern void ReleaseAvnAppEvents(); diff --git a/native/Avalonia.Native/src/OSX/main.mm b/native/Avalonia.Native/src/OSX/main.mm index 2a92eb3bcf..2f7e15c8ed 100644 --- a/native/Avalonia.Native/src/OSX/main.mm +++ b/native/Avalonia.Native/src/OSX/main.mm @@ -1,6 +1,7 @@ //This file will contain actual IID structures #define COM_GUIDS_MATERIALIZE #include "common.h" +#include "menu.h" static NSString* s_appTitle = @"Avalonia"; static int disableSetProcessName = 0; @@ -475,14 +476,24 @@ public: return *ppv != nullptr ? S_OK : E_FAIL; } - HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override { + HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override { START_COM_CALL; *ppv = ::CreateMemoryManagementHelper(); return S_OK; } - - - + + virtual HRESULT SetDockMenu(IAvnMenu* dockMenu) override + { + START_COM_CALL; + + @autoreleasepool + { + auto nativeMenu = dynamic_cast(dockMenu); + ::SetDockMenu(nativeMenu != nullptr ? nativeMenu->GetNative() : nil); + return S_OK; + } + } + }; extern "C" IAvaloniaNativeFactory* CreateAvaloniaNative() diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 022118d3ab..179f64233e 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -59,6 +59,15 @@ + + + + + + + + + diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index b08df1223d..f14fbb1fa3 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -64,6 +64,40 @@ namespace ControlCatalog base.OnFrameworkInitializationCompleted(); } + public void OnDockNewWindowClicked(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + { + var window = new MainWindow(); + window.Show(); + } + } + + public void OnDockShowMainWindowClicked(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + desktopLifetime.MainWindow?.Activate(); + } + } + + private int _dockMenuItemCount; + + public void OnDockAddItemClicked(object? sender, EventArgs e) + { + var dockMenu = NativeDock.GetMenu(this); + if (dockMenu is not null) + { + _dockMenuItemCount++; + var item = new NativeMenuItem($"New item {_dockMenuItemCount}"); + item.Click += (_, _) => + { + dockMenu.Items.Remove(item); + }; + dockMenu.Items.Insert(0, item); + } + } + private CatalogTheme _prevTheme; public static CatalogTheme CurrentTheme => ((App)Current!)._prevTheme; public static void SetCatalogThemes(CatalogTheme theme) diff --git a/samples/IntegrationTestApp/App.axaml b/samples/IntegrationTestApp/App.axaml index 60a2c56542..e8c91fe580 100644 --- a/samples/IntegrationTestApp/App.axaml +++ b/samples/IntegrationTestApp/App.axaml @@ -7,6 +7,13 @@ + + + + + (name).IsChecked = true; }); + DockMenuCommand = MiniCommand.Create(name => + { + // This is for the "Show Main Window" dock menu item in the test. + // It doesn't actually show the main window, but sets the checkbox to true in the page. + var checkbox = _mainWindow!.GetLogicalDescendants().OfType().FirstOrDefault(x => x.Name == name); + if (checkbox != null) checkbox.IsChecked = true; + }); DataContext = this; } @@ -37,5 +46,21 @@ namespace IntegrationTestApp } public ICommand TrayIconCommand { get; } + public ICommand DockMenuCommand { get; } + + public void AddDockMenuItem(string header) + { + var dockMenu = NativeDock.GetMenu(this); + if (dockMenu is not null) + { + dockMenu.Items.Insert(0, new NativeMenuItem(header)); + } + } + + public int GetDockMenuItemCount() + { + var dockMenu = NativeDock.GetMenu(this); + return dockMenu?.Items.Count ?? 0; + } } } diff --git a/samples/IntegrationTestApp/Pages/DesktopPage.axaml b/samples/IntegrationTestApp/Pages/DesktopPage.axaml index a5495bd347..d7044df525 100644 --- a/samples/IntegrationTestApp/Pages/DesktopPage.axaml +++ b/samples/IntegrationTestApp/Pages/DesktopPage.axaml @@ -10,5 +10,11 @@