From 236c478c03b775b3665083ff70173aa831be5faf Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 29 Jan 2026 10:40:57 +0100 Subject: [PATCH] feat: add `menuItem.badge` support on macOS Adds a `badge` property to `MenuItem` on macOS, backed by AppKit's `NSMenuItemBadge` API (macOS 14+). The property accepts a `MenuItemBadge` object with a `type` (`alerts`, `updates`, `new-items`, or `none`) and an optional `count` or custom `content` string. The badge can be set at construction time or updated dynamically after the item has been added to a menu. --- docs/api/menu-item.md | 13 ++++++ docs/api/structures/menu-item-badge.md | 5 +++ filenames.auto.gni | 1 + lib/browser/api/menu-item.ts | 18 ++++++++ lib/browser/api/menu.ts | 3 ++ shell/browser/api/electron_api_menu.cc | 43 +++++++++++++++++- shell/browser/api/electron_api_menu.h | 5 +++ .../ui/cocoa/electron_menu_controller.mm | 44 ++++++++++++++++++- shell/browser/ui/electron_menu_model.cc | 24 ++++++++++ shell/browser/ui/electron_menu_model.h | 17 +++++++ typings/internal-electron.d.ts | 2 + 11 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 docs/api/structures/menu-item-badge.md diff --git a/docs/api/menu-item.md b/docs/api/menu-item.md index 7458069f0c..0e86e07833 100644 --- a/docs/api/menu-item.md +++ b/docs/api/menu-item.md @@ -63,10 +63,17 @@ See [`Menu`](menu.md) for examples. * `afterGroupContaining` string[] (optional) - Provides a means for a single context menu to declare the placement of their containing group after the containing group of the item with the specified id. + * `badge` Object (optional) _macOS_ + * `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`. + * `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`. + * `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`. > [!NOTE] > `acceleratorWorksWhenHidden` is specified as being macOS-only because accelerators always work when items are hidden on Windows and Linux. The option is exposed to users to give them the option to turn it off, as this is possible in native macOS development. +> [!NOTE] +> If you use one of the predefined badge types on macOS (not 'none'), the system localizes and pluralizes the string for you. If you create your own custom badge string, you need to localize and pluralize that string yourself. + ### Instance Properties The following properties are available on instances of `MenuItem`: @@ -181,3 +188,9 @@ A `number` indicating an item's sequential unique id. #### `menuItem.menu` A [`Menu`](menu.md) that the item is a part of. + +#### `menuItem.badge` _macOS_ + +An [`MenuItemBadge`](structures/menu-item-badge.md) indicating the badge for the menu item. + +This property can be dynamically changed. diff --git a/docs/api/structures/menu-item-badge.md b/docs/api/structures/menu-item-badge.md new file mode 100644 index 0000000000..dae58f5b65 --- /dev/null +++ b/docs/api/structures/menu-item-badge.md @@ -0,0 +1,5 @@ +# MenuItemBadge Object + +* `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`. +* `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`. +* `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`. diff --git a/filenames.auto.gni b/filenames.auto.gni index 9d11950ede..0765cf9469 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -112,6 +112,7 @@ auto_filenames = { "docs/api/structures/media-access-permission-request.md", "docs/api/structures/memory-info.md", "docs/api/structures/memory-usage-details.md", + "docs/api/structures/menu-item-badge.md", "docs/api/structures/mime-typed-buffer.md", "docs/api/structures/mouse-input-event.md", "docs/api/structures/mouse-wheel-input-event.md", diff --git a/lib/browser/api/menu-item.ts b/lib/browser/api/menu-item.ts index d75ae0fc4d..ae0ce8cfe5 100644 --- a/lib/browser/api/menu-item.ts +++ b/lib/browser/api/menu-item.ts @@ -38,6 +38,24 @@ const MenuItem = function (this: any, options: any) { this.overrideProperty('acceleratorWorksWhenHidden', true); this.overrideProperty('registerAccelerator', roles.shouldRegisterAccelerator(this.role)); + if (process.platform === 'darwin') { + let badgeValue = options.badge; + Object.defineProperty(this, 'badge', { + get: () => badgeValue, + set: (newValue) => { + badgeValue = newValue; + // Update native badge if this item is already in a menu + if (this.menu) { + const index = this.menu.getIndexOfCommandId(this.commandId); + if (index !== -1 && badgeValue) { + this.menu.setBadge(index, badgeValue); + } + } + }, + enumerable: true + }); + } + if (!MenuItem.types.includes(this.type)) { throw new Error(`Unknown menu item type: ${this.type}`); } diff --git a/lib/browser/api/menu.ts b/lib/browser/api/menu.ts index c861ca32a1..4e259c26fc 100644 --- a/lib/browser/api/menu.ts +++ b/lib/browser/api/menu.ts @@ -176,6 +176,9 @@ Menu.prototype.insert = function (pos, item) { if (item.type === 'palette' || item.type === 'header') { this.setCustomType(pos, item.type); } + if (process.platform === 'darwin' && item.badge) { + this.setBadge(pos, item.badge); + } // Make menu accessible to items. item.overrideReadOnlyProperty('menu', this); diff --git a/shell/browser/api/electron_api_menu.cc b/shell/browser/api/electron_api_menu.cc index 05a8a0a8b7..7358383bfe 100644 --- a/shell/browser/api/electron_api_menu.cc +++ b/shell/browser/api/electron_api_menu.cc @@ -18,7 +18,6 @@ #include "shell/common/gin_converters/gurl_converter.h" #include "shell/common/gin_converters/image_converter.h" #include "shell/common/gin_converters/optional_converter.h" -#include "shell/common/gin_helper/dictionary.h" #include "shell/common/gin_helper/object_template_builder.h" #include "shell/common/gin_helper/wrappable_pointer_tags.h" #include "shell/common/node_includes.h" @@ -30,6 +29,7 @@ namespace gin { using SharingItem = electron::ElectronMenuModel::SharingItem; +using Badge = electron::ElectronMenuModel::Badge; template <> struct Converter { @@ -46,6 +46,28 @@ struct Converter { } }; +template <> +struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + Badge* out) { + gin_helper::Dictionary dict; + if (!ConvertFromV8(isolate, val, &dict)) + return false; + + std::string type_str; + if (dict.Get("type", &type_str)) { + out->type = base::UTF8ToUTF16(type_str); + } else { + out->type = u"none"; + } + + dict.GetOptional("count", &(out->count)); + dict.GetOptional("content", &(out->content)); + return true; + } +}; + } // namespace gin #endif @@ -250,6 +272,21 @@ void Menu::SetCustomType(int index, const std::u16string& customType) { model_->SetCustomType(index, customType); } +#if BUILDFLAG(IS_MAC) +void Menu::SetBadge(int index, const gin_helper::Dictionary& badge_dict) { + ElectronMenuModel::Badge badge; + std::string type_str; + if (badge_dict.Get("type", &type_str)) { + badge.type = base::UTF8ToUTF16(type_str); + } else { + badge.type = u"none"; + } + badge_dict.GetOptional("count", &badge.count); + badge_dict.GetOptional("content", &badge.content); + model_->SetBadge(index, std::move(badge)); +} +#endif + void Menu::Clear() { model_->Clear(); } @@ -289,8 +326,12 @@ void Menu::FillObjectTemplate(v8::Isolate* isolate, .SetMethod("setToolTip", &Menu::SetToolTip) .SetMethod("setRole", &Menu::SetRole) .SetMethod("setCustomType", &Menu::SetCustomType) +#if BUILDFLAG(IS_MAC) + .SetMethod("setBadge", &Menu::SetBadge) +#endif .SetMethod("clear", &Menu::Clear) .SetMethod("getItemCount", &Menu::GetItemCount) + .SetMethod("getIndexOfCommandId", &Menu::GetIndexOfCommandId) .SetMethod("popupAt", &Menu::PopupAt) .SetMethod("closePopupAt", &Menu::ClosePopupAt) .SetMethod("_getAcceleratorTextAt", &Menu::GetAcceleratorTextAtForTesting) diff --git a/shell/browser/api/electron_api_menu.h b/shell/browser/api/electron_api_menu.h index 0c96e81618..15ab752b34 100644 --- a/shell/browser/api/electron_api_menu.h +++ b/shell/browser/api/electron_api_menu.h @@ -12,6 +12,8 @@ #include "shell/browser/event_emitter_mixin.h" #include "shell/browser/ui/electron_menu_model.h" #include "shell/common/gin_helper/constructible.h" +#include "shell/common/gin_helper/dictionary.h" +#include "shell/common/gin_helper/self_keep_alive.h" #include "ui/base/mojom/menu_source_type.mojom-forward.h" #include "v8/include/cppgc/member.h" @@ -125,6 +127,9 @@ class Menu : public gin::Wrappable, void SetToolTip(int index, const std::u16string& toolTip); void SetRole(int index, const std::u16string& role); void SetCustomType(int index, const std::u16string& customType); +#if BUILDFLAG(IS_MAC) + void SetBadge(int index, const gin_helper::Dictionary& badge); +#endif void Clear(); int GetIndexOfCommandId(int command_id) const; int GetItemCount() const; diff --git a/shell/browser/ui/cocoa/electron_menu_controller.mm b/shell/browser/ui/cocoa/electron_menu_controller.mm index e8a0e3d902..829234e4ab 100644 --- a/shell/browser/ui/cocoa/electron_menu_controller.mm +++ b/shell/browser/ui/cocoa/electron_menu_controller.mm @@ -117,6 +117,30 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) { return result; } +// Convert a Badge to an NSMenuItemBadge. +NSMenuItemBadge* CreateBadge(const electron::ElectronMenuModel::Badge& badge) + API_AVAILABLE(macos(14.0)) { + NSString* badgeType = base::SysUTF16ToNSString(badge.type); + + if ([badgeType isEqualToString:@"alerts"]) { + if (badge.count.has_value()) + return [NSMenuItemBadge alertsWithCount:badge.count.value()]; + } else if ([badgeType isEqualToString:@"updates"]) { + if (badge.count.has_value()) + return [NSMenuItemBadge updatesWithCount:badge.count.value()]; + } else if ([badgeType isEqualToString:@"new-items"]) { + if (badge.count.has_value()) + return [NSMenuItemBadge newItemsWithCount:badge.count.value()]; + } else if ([badgeType isEqualToString:@"none"]) { + if (badge.content.has_value()) { + NSString* content = base::SysUTF8ToNSString(badge.content.value()); + return [[NSMenuItemBadge alloc] initWithString:content]; + } + } + + return nil; +} + } // namespace // This class stores a base::WeakPtr as an @@ -341,14 +365,12 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) { electron::ElectronMenuModel::ItemType type = model->GetTypeAt(index); std::u16string customType = model->GetCustomTypeAt(index); - // The sectionHeaderWithTitle menu item is only available in macOS 14.0+. if (@available(macOS 14, *)) { if (customType == u"header") { item = [NSMenuItem sectionHeaderWithTitle:label]; } } - // If the menu item has an icon, set it. ui::ImageModel icon = model->GetIconAt(index); if (icon.IsImage()) item.image = icon.GetImage().ToNSImage(); @@ -356,6 +378,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) { std::u16string toolTip = model->GetToolTipAt(index); item.toolTip = base::SysUTF16ToNSString(toolTip); + if (@available(macOS 14, *)) { + electron::ElectronMenuModel::Badge badge; + if (model->GetBadgeAt(index, &badge)) { + NSMenuItemBadge* nsBadge = CreateBadge(badge); + if (nsBadge) + item.badge = nsBadge; + } + } + if (role == u"services") { std::u16string title = u"Services"; NSString* sub_label = l10n_util::FixUpWindowsStyleLabel(title); @@ -517,6 +548,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) { } else { item.image = nil; } + + if (@available(macOS 14, *)) { + electron::ElectronMenuModel::Badge badge; + if (model->GetBadgeAt(index, &badge)) { + item.badge = CreateBadge(badge); + } else { + item.badge = nil; + } + } } - (void)refreshMenuTree:(NSMenu*)menu { diff --git a/shell/browser/ui/electron_menu_model.cc b/shell/browser/ui/electron_menu_model.cc index 0e9a5347ac..480b1ef1f1 100644 --- a/shell/browser/ui/electron_menu_model.cc +++ b/shell/browser/ui/electron_menu_model.cc @@ -12,6 +12,15 @@ namespace electron { ElectronMenuModel::SharingItem::SharingItem() = default; ElectronMenuModel::SharingItem::SharingItem(SharingItem&&) = default; ElectronMenuModel::SharingItem::~SharingItem() = default; + +ElectronMenuModel::Badge::Badge() = default; +ElectronMenuModel::Badge::Badge(Badge&&) = default; +ElectronMenuModel::Badge::Badge(const Badge&) = default; +ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(const Badge&) = + default; +ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(Badge&&) = + default; +ElectronMenuModel::Badge::~Badge() = default; #endif bool ElectronMenuModel::Delegate::GetAcceleratorForCommandId( @@ -115,6 +124,21 @@ bool ElectronMenuModel::GetSharingItemAt(size_t index, void ElectronMenuModel::SetSharingItem(SharingItem item) { sharing_item_.emplace(std::move(item)); } + +void ElectronMenuModel::SetBadge(size_t index, Badge badge) { + int command_id = GetCommandIdAt(index); + badges_[command_id] = std::move(badge); +} + +bool ElectronMenuModel::GetBadgeAt(size_t index, Badge* badge) const { + int command_id = GetCommandIdAt(index); + const auto iter = badges_.find(command_id); + if (iter != badges_.end()) { + *badge = iter->second; + return true; + } + return false; +} #endif void ElectronMenuModel::MenuWillClose() { diff --git a/shell/browser/ui/electron_menu_model.h b/shell/browser/ui/electron_menu_model.h index 76a6bbed9b..b58df964e3 100644 --- a/shell/browser/ui/electron_menu_model.h +++ b/shell/browser/ui/electron_menu_model.h @@ -33,6 +33,19 @@ class ElectronMenuModel : public ui::SimpleMenuModel { std::optional> urls; std::optional> file_paths; }; + + struct Badge { + Badge(); + Badge(Badge&&); + Badge(const Badge&); + Badge& operator=(const Badge&); + Badge& operator=(Badge&&); + ~Badge(); + + std::u16string type; // "alerts", "updates", "new-items", or "none" + std::optional count; + std::optional content; + }; #endif class Delegate : public ui::SimpleMenuModel::Delegate { @@ -105,6 +118,9 @@ class ElectronMenuModel : public ui::SimpleMenuModel { return sharing_item_; } + // Set/Get the Badge of a menu item. + void SetBadge(size_t index, Badge badge); + bool GetBadgeAt(size_t index, Badge* badge) const; #endif // ui::SimpleMenuModel: @@ -123,6 +139,7 @@ class ElectronMenuModel : public ui::SimpleMenuModel { #if BUILDFLAG(IS_MAC) std::optional sharing_item_; + base::flat_map badges_; // command id -> badge #endif base::flat_map toolTips_; // command id -> tooltip diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index da5846af77..dd47f35492 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -166,6 +166,7 @@ declare namespace Electron { commandsMap: Record; groupsMap: Record; getItemCount(): number; + getIndexOfCommandId(commandId: number): number; popupAt(window: BaseWindow, frame: WebFrameMain | undefined, x: number, y: number, positioning: number, sourceType: Required['sourceType'], callback: () => void): void; closePopupAt(id: number): void; setSublabel(index: number, label: string): void; @@ -173,6 +174,7 @@ declare namespace Electron { setIcon(index: number, image: string | NativeImage): void; setRole(index: number, role: string): void; setCustomType(index: number, customType: string): void; + setBadge(index: number, badge: MenuItemBadge): void; insertItem(index: number, commandId: number, label: string): void; insertCheckItem(index: number, commandId: number, label: string): void; insertRadioItem(index: number, commandId: number, label: string, groupId: number): void;