#572

macOS: a menu item that only has a submenu is built as a clickable command

OpenmortalYoungopened 21d ago

Summary

On macOS, a menu item whose only job is to hold a submenu is turned into a normal command item. It gets an action and a target, so it highlights like a command instead of acting like a plain submenu parent.

Everything else in the menu is fine. Windows and Linux behave correctly. macOS is the only platform that does this.

Environment

  • electrobun 2.0.1 (macOS arm64). The same code is on main.
  • Menu is set with ApplicationMenu.setApplicationMenu(...).

Reproduce

ApplicationMenu.setApplicationMenu([
  {
    label: "View",
    submenu: [
      {
        label: "Switch to",          // no action, only a submenu
        submenu: [
          { label: "Chat", action: "view-chat" },
          { label: "Skills", action: "view-skills" },
          { label: "Dashboard", action: "view-dashboard" },
        ],
      },
    ],
  },
]);

Open the View menu. The "Switch to" item highlights like a command, even though it should just be a submenu parent.

Cause

Two places give the container a command.

  1. package/src/sdks/main/core/ApplicationMenu.ts, line 53 and lines 55-57. The code only knows two cases: a role, or "everything else gets an action".
type: menuItem.type || "normal",
// application menus can either have an action or a role. not both.
...(menuItem.role ? { role: menuItem.role } : { action: actionWithDataId }),

An item with only a submenu falls into the second case. So the JSON that reaches the native side looks like this:

{"label":"Switch to","type":"normal","action":"|EB|menuData_3|","enabled":true,"checked":false,"hidden":false,"submenu":[...]}

The container now carries an action, and type says "normal". That also blocks the fallback in json_menu_parser.h, which only guesses "submenu" when type is empty.

  1. package/src/native/macos/nativeWrapper.mm, createMenuFromConfig (v2.0.1: lines 1141-1142, 1180, 1202). The item is created with a fixed selector, and it gets a target whenever it has no role. The submenu is attached only afterwards.
menuItem = [[NSMenuItem alloc] initWithTitle:label ?: @""
                                      action:@selector(menuItemClicked:)
                               keyEquivalent:@"";
...
} else {
    menuItem.target = target;
}
...
if (submenuConfig) {
    NSMenu *submenu = createMenuFromConfig(submenuConfig, target);
    [menu setSubmenu:submenu forItem:menuItem];
}

So the result is a real command item with a submenu attached. macOS never checks for type == "submenu"; the only type check in that function is type == "divider".

For comparison: Windows converts the item to MF_POPUP when a submenu exists (win/nativeWrapper.cpp:6637-6646), and Linux connects the click signal only when there is no submenu (linux/nativeWrapper.cpp:6201-6206).

Suggested fix

In ApplicationMenu.ts, do not send an action for an item that has a submenu:

+			const hasSubmenu = Array.isArray(menuItem.submenu) && menuItem.submenu.length > 0;
-			const actionWithDataId = ffi.internal.serializeMenuAction(
-				menuItem.action || "",
-				menuItem.data,
-			);
+			const actionWithDataId = hasSubmenu
+				? undefined
+				: ffi.internal.serializeMenuAction(menuItem.action || "", menuItem.data);
-				type: menuItem.type || "normal",
+				type: menuItem.type || (hasSubmenu ? "submenu" : "normal"),
 				...(menuItem.role
 					? { role: menuItem.role }
-					: { action: actionWithDataId }),
+					: hasSubmenu ? {} : { action: actionWithDataId }),

On macOS the change above is not enough on its own, because the item is still built with a command. That item should be created with nil instead of @selector(menuItemClicked:) when submenuConfig.count > 0, it should not get menuItem.target, and the submenu should be set directly with menuItem.submenu = ....

Small extra: serializeMenuAction("", undefined) never returns an empty string, and storeMenuData keeps its entry until the item is clicked. A container is never clicked, so every menu build leaks one entry.

Verified on main and v2.0.1: ApplicationMenu.ts is byte-identical in both (sha256 0f5879ee3ba09d35cad0caeb2445df47a1f4c26508863c614b86d71f2070b5fe).

Activity

0

No activity yet.