feat(globalmenu): update

This commit is contained in:
sankalp committed 2026-07-28 08:24:53 +05:45
1 parent a989c72ad7
commit 7fdeb7beba
10 files changed
+652 -469

No files matched your search

+85
View File
@@ -275,6 +275,91 @@ class DBusMenuClient:
f"[dbusmenu] click_item: Event call failed for item_id={item_id}: {e}"
)
self._try_gtk_actions_fallback(item_id, pid)
def _try_gtk_actions_fallback(self, item_id, pid=0):
item = None
if self._cache:
item = self._find_in_cache(item_id)
if not item or not item.action_name:
return
bus = _get_bus()
action_name = item.action_name
for path in (self.object_path,):
try:
desc = bus.call_sync(
self.service_name,
path,
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
Gio.DBusCallFlags.NONE,
1000,
None,
)
if not desc:
continue
enabled, param_type_v, state_v = desc.unpack()
if not enabled:
continue
has_state = state_v and state_v.n_children() > 0
if has_state:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
new_val = (
not st.get_boolean() if state_type == "b" else st.get_value()
)
bus.call_sync(
self.service_name,
path,
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(action_name, GLib.Variant(state_type, new_val)),
),
None,
Gio.DBusCallFlags.NONE,
1000,
None,
)
else:
param_type = str(param_type_v) if param_type_v else ""
params = GLib.Variant("av", [])
if param_type and param_type != "()":
pt = GLib.VariantType(param_type)
params = GLib.Variant(
"av", [GLib.Variant.new_tuple(GLib.Variant(pt, None))]
)
bus.call_sync(
self.service_name,
path,
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params, {})),
None,
Gio.DBusCallFlags.NONE,
1000,
None,
)
self.invalidate()
return
except Exception:
continue
def _find_in_cache(self, item_id):
def walk(items):
for i in items:
if i.id == item_id:
return i
found = walk(i.children)
if found:
return found
return None
return walk(self._cache) if self._cache else None
def invalidate(self):
self._cache_valid = False
+188
View File
@@ -0,0 +1,188 @@
"""AppMenu Registrar — D-Bus service for com.canonical.AppMenu.Registrar.
GTK apps with appmenu-gtk-module loaded call RegisterWindow(windowId,
objectPath) on this service to announce that they export a dbusmenu at the
given object path on their own bus name. We store those registrations and
expose GetMenuForWindow so the global menu bar can look them up.
Follows NovaBar's clean Registrar pattern — standalone singleton with
hash-table storage and proper D-Bus skeleton.
"""
from dataclasses import dataclass
from typing import Optional
from fabric.utils import Gio, GLib, logger
@dataclass
class MenuRegistration:
"""Registration entry for a window's menu."""
window_id: int
bus_name: str
object_path: str
REGISTRAR_XML = """
<node>
<interface name="com.canonical.AppMenu.Registrar">
<method name="RegisterWindow">
<arg type="u" name="windowId" direction="in"/>
<arg type="o" name="menuObjectPath" direction="in"/>
</method>
<method name="UnregisterWindow">
<arg type="u" name="windowId" direction="in"/>
</method>
<method name="GetMenuForWindow">
<arg type="u" name="windowId" direction="in"/>
<arg type="s" name="service" direction="out"/>
<arg type="o" name="menuObjectPath" direction="out"/>
</method>
<method name="GetMenus">
<arg type="a(uso)" name="menus" direction="out"/>
</method>
</interface>
</node>
"""
class Registrar:
"""Singleton D-Bus service implementing com.canonical.AppMenu.Registrar.
Mirrors NovaBar's ``GlobalMenu.Registrar`` — owns the well-known bus
name, stores window→menu registrations, and exposes ``GetMenuForWindow``.
"""
_instance: Optional["Registrar"] = None
@classmethod
def get_instance(cls) -> "Registrar":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self._registrations: dict[int, MenuRegistration] = {}
self._bus_owner_id: Optional[int] = None
self._registration_id: Optional[int] = None
self._connection: Optional[Gio.DBusConnection] = None
# ---- Public API ----
def start(self):
"""Acquire the bus name and export the D-Bus object."""
try:
bus = _get_bus()
node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
self._registration_id = bus.register_object(
"/com/canonical/AppMenu/Registrar",
node.interfaces[0],
self._handle_method,
None,
None,
)
Gio.bus_own_name_on_connection(
bus,
"com.canonical.AppMenu.Registrar",
Gio.BusNameOwnerFlags.NONE,
None,
None,
)
self._connection = bus
logger.info("[Registrar] AppMenu Registrar D-Bus service started")
except Exception as e:
logger.error(f"[Registrar] Failed to start: {e}")
def stop(self):
"""Release the bus name and unregister the object."""
if self._connection and self._registration_id is not None:
try:
self._connection.unregister_object(self._registration_id)
except Exception as e:
logger.warning(f"[Registrar] Failed to unregister object: {e}")
self._registration_id = None
def get_menu_for_window(self, window_id: int) -> Optional[MenuRegistration]:
"""Look up the menu registration for a window."""
return self._registrations.get(window_id)
def get_menu_for_bus_name(self, bus_name: str) -> Optional[MenuRegistration]:
"""Look up the menu registration by D-Bus bus name."""
for reg in self._registrations.values():
if reg.bus_name == bus_name:
return reg
return None
def get_all_registrations(self) -> dict[int, MenuRegistration]:
"""Return a copy of all current registrations."""
return dict(self._registrations)
# ---- Internal ----
def register_window(self, window_id: int, sender: str, menu_path: str):
"""Store a window→menu registration."""
reg = MenuRegistration(
window_id=window_id,
bus_name=sender,
object_path=menu_path,
)
self._registrations[window_id] = reg
logger.debug(
f"[Registrar] Registered window {window_id} -> {sender} {menu_path}"
)
def unregister_window(self, window_id: int):
"""Remove a window registration."""
self._registrations.pop(window_id, None)
logger.debug(f"[Registrar] Unregistered window {window_id}")
def _handle_method(
self,
connection,
sender,
object_path,
_interface_name,
method_name,
parameters,
invocation,
):
"""D-Bus method dispatcher."""
try:
if method_name == "RegisterWindow":
window_id, menu_path = parameters.unpack()
self.register_window(window_id, sender, str(menu_path))
invocation.return_value(None)
elif method_name == "UnregisterWindow":
window_id = parameters.unpack()[0]
self.unregister_window(window_id)
invocation.return_value(None)
elif method_name == "GetMenuForWindow":
window_id = parameters.unpack()[0]
reg = self.get_menu_for_window(window_id)
if reg:
invocation.return_value(
GLib.Variant("(so)", (reg.bus_name, reg.object_path))
)
else:
invocation.return_value(
GLib.Variant("(so)", (sender, "/com/canonical/menu/0"))
)
elif method_name == "GetMenus":
menus = [
(0, reg.bus_name, reg.object_path)
for reg in self._registrations.values()
]
invocation.return_value(GLib.Variant("(a(uso))", (menus,)))
except Exception as e:
logger.error(f"[Registrar] D-Bus method {method_name} failed: {e}")
invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e))
def _get_bus() -> Gio.DBusConnection:
"""Get or create the shared session bus connection."""
return Gio.bus_get_sync(Gio.BusType.SESSION, None)
+46 -110
View File
@@ -20,6 +20,7 @@ from fabric.utils import (
from window.globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
from window.globalmenu.environment import setup_global_menu_environment
from window.globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient
from window.globalmenu.registrar import Registrar
_NODE_NAME_RE = re.compile(r'<node name="([^"]+)"')
@@ -133,29 +134,6 @@ def _dbus_get_pid(bus, service: str, timeout: int) -> int:
return 0
REGISTRAR_XML = """
<node>
<interface name="com.canonical.AppMenu.Registrar">
<method name="RegisterWindow">
<arg type="u" name="windowId" direction="in"/>
<arg type="o" name="menuObjectPath" direction="in"/>
</method>
<method name="UnregisterWindow">
<arg type="u" name="windowId" direction="in"/>
</method>
<method name="GetMenuForWindow">
<arg type="u" name="windowId" direction="in"/>
<arg type="s" name="service" direction="out"/>
<arg type="o" name="menuObjectPath" direction="out"/>
</method>
<method name="GetMenus">
<arg type="a(uso)" name="menus" direction="out"/>
</method>
</interface>
</node>
"""
class GlobalMenuService(Service):
"""Service that extracts and provides application menus for the global menu bar."""
@@ -189,9 +167,7 @@ class GlobalMenuService(Service):
self._registry_lock = threading.Lock()
self._state_lock = threading.Lock()
self._registered_menus: dict[str, tuple[str, str]] = {}
self._setup_registrar()
Registrar.get_instance().start()
self._subscribe_name_owner_changed()
threading.Thread(target=self._setup_environment, daemon=True).start()
threading.Thread(target=self._scan_initial_services, daemon=True).start()
@@ -269,7 +245,6 @@ class GlobalMenuService(Service):
return
if entry.pid:
self._pid_to_service.pop(entry.pid, None)
self._registered_menus.pop(service_name, None)
if isinstance(entry.importer, DBusMenuClient):
try:
entry.importer.disconnect_signals()
@@ -288,79 +263,9 @@ class GlobalMenuService(Service):
f"[GlobalMenuService] Cleaned up disappeared service: {service_name}"
)
def _setup_registrar(self):
try:
self._bus = _get_bus()
self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
self._bus.register_object(
"/com/canonical/AppMenu/Registrar",
self._node.interfaces[0],
self._handle_registrar_method,
None,
None,
)
Gio.bus_own_name_on_connection(
self._bus,
"com.canonical.AppMenu.Registrar",
Gio.BusNameOwnerFlags.NONE,
None,
None,
)
logger.info("[GlobalMenuService] AppMenu Registrar DBus service started")
except Exception as e:
logger.error(f"[GlobalMenuService] Failed to start Registrar: {e}")
def _setup_environment(self):
setup_global_menu_environment()
def _handle_registrar_method(
self,
connection,
sender,
object_path,
_interface_name,
method_name,
parameters,
invocation,
):
try:
if method_name == "RegisterWindow":
window_id, menu_path = parameters.unpack()
logger.debug(
f"[GlobalMenuService] App {sender} registered menu {menu_path} for window {window_id}"
)
with self._registry_lock:
self._registered_menus[sender] = (sender, menu_path)
invocation.return_value(None)
elif method_name == "UnregisterWindow":
with self._registry_lock:
self._registered_menus.pop(sender, None)
invocation.return_value(None)
elif method_name == "GetMenuForWindow":
window_id = parameters.unpack()[0]
with self._registry_lock:
entry = self._registered_menus.get(sender)
if entry:
svc_name, svc_path = entry
invocation.return_value(GLib.Variant("(so)", (svc_name, svc_path)))
else:
invocation.return_value(
GLib.Variant("(so)", (sender, "/com/canonical/menu/0"))
)
elif method_name == "GetMenus":
with self._registry_lock:
menus = [
(0, svc, path)
for sender, (svc, path) in self._registered_menus.items()
]
invocation.return_value(GLib.Variant("(a(uso))", (menus,)))
except Exception as e:
logger.error(f"[GlobalMenuService] Registrar error {method_name}: {e}")
invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e))
def _uid(self, wm_class: str, pid: int) -> str:
return f"{wm_class}:{pid}"
@@ -657,9 +562,10 @@ class GlobalMenuService(Service):
def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
bus = _get_bus()
with self._registry_lock:
registered = dict(self._registered_menus)
for sender, (service, path) in registered.items():
registrar = Registrar.get_instance()
registered = registrar.get_all_registrations()
for window_id, reg in registered.items():
service, path = reg.bus_name, reg.object_path
try:
pid = _dbus_get_pid(bus, service, _DBUS_TIMEOUT_FAST)
if pid == target_pid or self._is_same_app(pid, target_pid):
@@ -678,8 +584,7 @@ class GlobalMenuService(Service):
return client
except Exception as e:
if "NameHasNoOwner" in str(e):
with self._registry_lock:
self._registered_menus.pop(sender, None)
registrar.unregister_window(window_id)
return None
def _find_fallback_scan(
@@ -719,14 +624,17 @@ class GlobalMenuService(Service):
if not (pid == target_pid or self._is_same_app(pid, target_pid)):
continue
with self._registry_lock:
if reg_entry := self._registered_menus.get(svc):
logger.info(
f"[GlobalMenuService] Found Registrar path {reg_entry[1]} for {svc}"
)
client = DBusMenuClient(reg_entry[0], reg_entry[1])
self._cache_discovery(svc, reg_entry[1], pid, wm_class, client)
return client
registrar = Registrar.get_instance()
reg_entry = registrar.get_menu_for_bus_name(svc)
if reg_entry:
logger.info(
f"[GlobalMenuService] Found Registrar path {reg_entry.object_path} for {svc}"
)
client = DBusMenuClient(reg_entry.bus_name, reg_entry.object_path)
self._cache_discovery(
svc, reg_entry.object_path, pid, wm_class, client
)
return client
_safe_cls = re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else ""
_safe_cap = _safe_cls.capitalize() if _safe_cls else ""
@@ -1090,6 +998,34 @@ class GlobalMenuService(Service):
)
return False
def get_current_dbusmenu_info(self) -> tuple[Optional[str], Optional[str]]:
"""Return (unique_bus_name, object_path) for the current app if it has a DBusMenu server."""
entry, _pid = self._resolve_current_entry()
if entry and entry.importer and isinstance(entry.importer, DBusMenuClient):
unique_name = self._resolve_unique_name(entry.importer.service_name)
return unique_name, entry.importer.object_path
return None, None
def _resolve_unique_name(self, well_known_name: str) -> Optional[str]:
"""Resolve a well-known D-Bus name to its unique name (e.g. :1.123)."""
try:
bus = _get_bus()
res = bus.call_sync(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"GetNameOwner",
GLib.Variant("(s)", (well_known_name,)),
GLib.VariantType("(s)"),
Gio.DBusCallFlags.NONE,
_DBUS_TIMEOUT_FAST,
None,
)
return res.get_child_value(0).get_string() if res else well_known_name
except Exception as e:
logger.warning(f"[service] GetNameOwner failed for {well_known_name}: {e}")
return well_known_name
def about_to_show(self, item_id: int) -> bool:
entry, _pid = self._resolve_current_entry()
if entry and entry.importer: