feat(globalmenu): update
This commit is contained in:
10 files changed
+652
-469
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user