"""Global Menu Service - extracts and manages application menus. Uses the pure Python DBusMenu extractor via Gio.DBus. """ import json import threading from typing import List, Optional from fabric.core.service import Property, Service, Signal from fabric.utils import ( Gio, GLib, exec_shell_command, idle_add, logger, os, ) from utils.dbusmenu import DBusMenuClient, DBusMenuItem from utils.gtkmenu import GtkMenuClient _MAX_CACHE_SIZE = 32 REGISTRAR_XML = """ """ class GlobalMenuService(Service): """Service that extracts and provides application menus for the global menu bar.""" @Signal def menu_changed(self, menu_items: object) -> None: ... @Signal def active_app_changed(self, app_name: str, wm_class: str) -> None: ... @Property(object, flags="read-write") def current_menu(self) -> Optional[List[DBusMenuItem]]: return self._current_menu @Property(str, flags="read-write") def current_app(self) -> str: return self._current_app @Property(str, flags="read-write") def current_wm_class(self) -> str: return self._current_wm_class def __init__(self): super().__init__() self._current_menu: Optional[List[DBusMenuItem]] = None self._current_app: str = "" self._current_wm_class: str = "" # Cache holds DBusMenuClient instances self._client_cache: dict[str, Optional[DBusMenuClient]] = {} # Cache holds the extracted top-level menus self._menu_cache: dict[str, Optional[List[DBusMenuItem]]] = {} # LRU-style insertion-order tracking for cache eviction self._cache_order: list[str] = [] # Cache DBus service -> PID to make discovery instant self._pid_cache: dict[str, int] = {} # Registrar mapping: sender -> (service_name, object_path) self._registered_menus: dict[str, tuple[str, str]] = {} self._seq_lock = threading.Lock() self._extraction_seq = 0 self._lock = threading.Lock() self._registry_lock = threading.Lock() self._setup_registrar() self._setup_environment() # Prewarm the PID cache in the background threading.Thread(target=self._prewarm_dbus_cache, daemon=True).start() logger.info( "[GlobalMenuService] Initialized native Python DBusMenu parser successfully" ) def _setup_registrar(self): """Host the AppMenu Registrar on DBus to allow GTK/KDE apps to register menus.""" try: self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML) self._reg_id = 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 _prewarm_dbus_cache(self): """Pre-fetches PIDs for all current DBus services so discovery is instant.""" try: bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) res = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", None, GLib.VariantType("(as)"), Gio.DBusCallFlags.NONE, -1, None, ) names = res.get_child_value(0).unpack() def fetch_pid(svc): with self._registry_lock: cached = svc in self._pid_cache if not cached: try: pid_res = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", GLib.Variant("(s)", (svc,)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, 500, None, ) with self._registry_lock: self._pid_cache[svc] = pid_res.get_child_value( 0 ).get_uint32() except Exception: with self._registry_lock: self._pid_cache[svc] = 0 for svc in names: with self._registry_lock: if svc in self._pid_cache: continue threading.Thread(target=fetch_pid, args=(svc,), daemon=True).start() logger.info( f"[GlobalMenuService] Pre-warmed DBus PID cache with {len(self._pid_cache)} entries" ) except Exception as e: logger.debug(f"[GlobalMenuService] Pre-warm failed: {e}") def _setup_environment(self): """Automatically setup environment variables and system overrides for global menus.""" try: # 1. Inject GTK Modules for standard GTK3 apps os.environ["GTK_MODULES"] = "appmenu-gtk-module" exec_shell_command("hyprctl keyword env GTK_MODULES,appmenu-gtk-module") exec_shell_command( "dbus-update-activation-environment --systemd GTK_MODULES=appmenu-gtk-module" ) # 2. Automatically grant Flatpak apps permission to talk to the DBus Registrar exec_shell_command( "flatpak override --user --talk-name=com.canonical.AppMenu.Registrar" ) # 3. Force XFCE / GTK3 apps to show menubars globally by writing to settings.ini settings_path = os.path.expanduser("~/.config/gtk-3.0/settings.ini") if os.path.exists(settings_path): with open(settings_path, "r") as f: content = f.read() if "gtk-shell-shows-menubar" not in content: with open(settings_path, "a") as f: f.write("\ngtk-shell-shows-menubar=1\n") logger.info( "[GlobalMenuService] Injected GTK environment and Flatpak overrides automatically" ) except Exception as e: logger.debug( f"[GlobalMenuService] Failed to set up global menu environment overrides: {e}" ) def _handle_registrar_method( self, connection, sender, object_path, interface_name, method_name, parameters, invocation, ): """Handles incoming DBus requests from apps registering their menus.""" 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: # the real bus name instead of echoing back the caller's address. 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: # Fallback — nothing registered for this sender invocation.return_value( GLib.Variant("(so)", (sender, "/com/canonical/menu/0")) ) elif method_name == "GetMenus": invocation.return_value(GLib.Variant("(a(uso))", ([]))) except Exception as e: logger.error( f"[GlobalMenuService] Error handling Registrar method {method_name}: {e}" ) invocation.return_error_literal(Gio.DBusError.FAILED, str(e)) def update_active_window(self, app_name: str, wm_class: str): """Called when the active window changes. Triggers menu extraction.""" if wm_class == self._current_wm_class: return logger.info( f"[GlobalMenuService] Active window changed: {app_name} ({wm_class})" ) self._current_app = app_name self._current_wm_class = wm_class with self._seq_lock: self._extraction_seq += 1 seq = self._extraction_seq self.active_app_changed(app_name, wm_class) # Check cache first with self._lock: has_client = wm_class in self._client_cache has_menu = wm_class in self._menu_cache cached_menu = self._menu_cache.get(wm_class) if has_menu else None if has_client and has_menu: self._current_menu = cached_menu logger.info(f"[GlobalMenuService] Cache hit for '{wm_class}'") self.menu_changed(cached_menu if cached_menu else []) return # Clear menu immediately for new app while extraction runs self._current_menu = None self.menu_changed([]) # Extract menu in background thread logger.info(f"[GlobalMenuService] Starting extraction for '{wm_class}'") thread = threading.Thread( target=self._extract_menu, args=(wm_class, seq), daemon=True, ) thread.start() def _discover_dbus_client(self, wm_class: str) -> Optional[DBusMenuClient]: """Fast and deterministic DBusMenu discovery (Wayland-safe).""" try: out = exec_shell_command("hyprctl activewindow -j") if not out: logger.warning("[GlobalMenuService] hyprctl returned no output") return None data = json.loads(out) target_pid = data.get("pid", 0) if target_pid <= 0: return None bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) # ========================= # 1. FAST PATH: Registrar cache # ========================= with self._registry_lock: registered = dict(self._registered_menus) for sender, (service, path) in registered.items(): try: pid_res = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", GLib.Variant("(s)", (service,)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, 100, None, ) pid = pid_res.get_child_value(0).get_uint32() if pid == target_pid or self._is_same_app(pid, target_pid): logger.info( f"[GlobalMenuService] Registrar match: {service} {path}" ) return DBusMenuClient(service, path) except Exception: # stale entry cleanup with self._registry_lock: self._registered_menus.pop(sender, None) # ========================= # 2. SIMPLE FALLBACK PATHS ONLY # ========================= fallback_paths = ( "/com/canonical/menu/0", "/MenuBar", "/appmenu", "/com/canonical/AppMenu/Registrar/0", "/org/gtk/Application/menus/appmenu", "/org/gtk/Application/menus/menubar", "/org/xfce/Thunar/menus/menubar/0", ) res = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", None, GLib.VariantType("(as)"), Gio.DBusCallFlags.NONE, -1, None, ) names = res.get_child_value(0).unpack() # filter only real app services (cheap filter) services = [ n for n in names if not n.startswith("org.freedesktop.") and not n.startswith(":") ] for svc in services: try: pid_res = bus.call_sync( "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", GLib.Variant("(s)", (svc,)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, 200, None, ) pid = pid_res.get_child_value(0).get_uint32() if not (pid == target_pid or self._is_same_app(pid, target_pid)): continue # check only known paths (NO brute force) for path in fallback_paths: try: introspect = bus.call_sync( svc, path, "org.freedesktop.DBus.Introspectable", "Introspect", None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, 300, None, ) xml = introspect.get_child_value(0).get_string() if "com.canonical.dbusmenu" in xml: logger.info( f"[GlobalMenuService] DBusMenu found: {svc} {path}" ) with self._registry_lock: self._registered_menus[svc] = (svc, path) return DBusMenuClient(svc, path) elif "org.gtk.Menus" in xml: logger.info( f"[GlobalMenuService] GtkMenu found: {svc} {path}" ) with self._registry_lock: self._registered_menus[svc] = (svc, path) return GtkMenuClient(svc, path) except Exception: continue except Exception: continue except Exception as e: logger.debug(f"[GlobalMenuService] Discovery failed: {e}") return None def _is_same_app(self, pid1: int, pid2: int) -> bool: if pid1 == pid2: return True try: # multiple instances of the same executable (e.g. two terminals). exe1 = os.path.realpath(f"/proc/{pid1}/exe") exe2 = os.path.realpath(f"/proc/{pid2}/exe") if not (exe1 and exe2 and exe1 == exe2): return False # Read start time from /proc//stat field 22 (0-indexed: field index 21) def _start_time(pid: int) -> Optional[str]: try: with open(f"/proc/{pid}/stat") as f: fields = f.read().split() return fields[21] if len(fields) > 21 else None except OSError: return None st1 = _start_time(pid1) st2 = _start_time(pid2) # If we can read start times, they must also match if st1 is not None and st2 is not None: return st1 == st2 # Fall back to exe-only match if /proc/stat is unreadable return True except OSError: pass return False def _evict_cache_if_needed(self, key: str): """Evict oldest cache entries when over the size limit. Must be called under _lock.""" if key not in self._cache_order: self._cache_order.append(key) while len(self._cache_order) > _MAX_CACHE_SIZE: oldest = self._cache_order.pop(0) self._client_cache.pop(oldest, None) self._menu_cache.pop(oldest, None) logger.debug(f"[GlobalMenuService] Evicted cache for '{oldest}'") def _extract_menu(self, wm_class: str, seq: int): """Extract menu for a given WM class in a background thread.""" try: client = self._discover_dbus_client(wm_class) items = [] if client: items = client.get_layout() with self._lock: self._client_cache[wm_class] = client self._menu_cache[wm_class] = items self._evict_cache_if_needed(wm_class) if items: logger.info( f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items" ) else: logger.info( f"[GlobalMenuService] No DBus menu found for '{wm_class}'" ) with self._seq_lock: current_seq = self._extraction_seq if seq != current_seq: return # Update UI on main thread via GLib idle idle_add(self._emit_menu_changed, items) except Exception as e: logger.error( f"[GlobalMenuService] Error extracting menu for '{wm_class}': {e}" ) def _emit_menu_changed(self, items: List[DBusMenuItem]): """Emit menu_changed signal on the main thread.""" self._current_menu = items self.menu_changed(items) return False # Remove from idle queue def click_item(self, item_id: int) -> bool: """Click a menu item by ID.""" with self._lock: client = self._client_cache.get(self._current_wm_class) if client: client.click_item(item_id) return True return False def about_to_show(self, item_id: int) -> bool: """Triggers the AboutToShow event on a DBusMenu item.""" with self._lock: client = self._client_cache.get(self._current_wm_class) if client: return client.about_to_show(item_id) return False def refresh_menu_sync(self) -> List[DBusMenuItem]: """Synchronously refetches the menu for the current app.""" with self._lock: client = self._client_cache.get(self._current_wm_class) if client: items = client.get_layout() with self._lock: self._menu_cache[self._current_wm_class] = items return items return [] _global_menu_svc_instance = None def get_global_menu_service() -> GlobalMenuService: global _global_menu_svc_instance if _global_menu_svc_instance is None: _global_menu_svc_instance = GlobalMenuService() return _global_menu_svc_instance