From 0594759fed6456a8e40109618449d4dcaf6bef3a Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Wed, 24 Jun 2026 12:52:36 +0545 Subject: [PATCH] feat(services): streamline DBus menu caching and notification handling - Consolidate logger disabling, cache empty menus with TTL, and move name resolution to a dedicated resolver to reduce duplication and improve performance --- src/main.py | 19 ++- src/services/bluetooth.py | 15 +-- src/services/custom_notification.py | 2 + src/services/globalmenu.py | 187 +++++++++++++++++++++------- src/services/keyboard_layout.py | 2 +- src/services/modus.py | 11 +- 6 files changed, 161 insertions(+), 75 deletions(-) diff --git a/src/main.py b/src/main.py index 80a04d6..fbb747d 100644 --- a/src/main.py +++ b/src/main.py @@ -3,16 +3,6 @@ import os from fabric import Application from fabric.utils import get_relative_path, logger, monitor_file -from shared.data import APP_NAME, load_config -from utils.functions import set_process_name -from window.desktop.widget import Deskwidgets -from window.dock import Dock - -from window.notification.notification import ModusNoti -from window.osd.main import OSDWindow -from window.panel.main import Panel -from window.switcher import ApplicationSwitcher - for log in [ "fabric", "services", @@ -21,6 +11,15 @@ for log in [ ]: logger.disable(log) +from shared.data import APP_NAME, load_config +from utils.functions import set_process_name +from window.desktop.widget import Deskwidgets +from window.dock import Dock +from window.notification.notification import ModusNoti +from window.osd.main import OSDWindow +from window.panel.main import Panel +from window.switcher import ApplicationSwitcher + class LazyLauncher: def __init__(self): diff --git a/src/services/bluetooth.py b/src/services/bluetooth.py index 2f2459f..9858345 100644 --- a/src/services/bluetooth.py +++ b/src/services/bluetooth.py @@ -225,8 +225,7 @@ class BluetoothAdapter(Service): @Property(list, "readable") def devices(self) -> list: - - return [d for d in self._devices.values() if d.paired or d.trusted] + return list(self._devices.values()) @Property(list, "readable") def connected_devices(self) -> list: @@ -378,15 +377,13 @@ class BluetoothAdapter(Service): if not device: return - was_visible = device.paired or device.trusted logger.info(f"[Bluetooth:{self.name}] Removing device: {addr}") - if was_visible: - self.emit("device-removed", addr) - if device.connected: - self.notify("connected-devices") - self.notify("devices") - self.emit("changed") + self.emit("device-removed", addr) + if device.connected: + self.notify("connected-devices") + self.notify("devices") + self.emit("changed") device.close() diff --git a/src/services/custom_notification.py b/src/services/custom_notification.py index d3a21ba..4933d4b 100644 --- a/src/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -21,6 +21,7 @@ class CachedNotification(Service): def create_from_dict(cls, data, **kwargs): """Create CachedNotification from enhanced JSON data""" data["timeout"] = 0 + data.setdefault("time", float(time.time())) self = cls.__new__(cls) Service.__init__(self, **kwargs) self._notification = Notification.deserialize(data) @@ -150,6 +151,7 @@ class CachedNotification(Service): "image-file": self.image_file, # Only store image-pixmap if no cache key is available (fallback) "image-pixmap": None, # Don't store image data, only cache key + "time": float(self._notification.time), "timestamp": int(time.time()), "group": self.app_name, # Group notifications by app name # Enhanced cache metadata - store only cache keys diff --git a/src/services/globalmenu.py b/src/services/globalmenu.py index 75c96b9..1adbe0a 100644 --- a/src/services/globalmenu.py +++ b/src/services/globalmenu.py @@ -3,6 +3,7 @@ import json import re import threading +import time from typing import List, Optional from fabric.core.service import Property, Service, Signal @@ -20,6 +21,10 @@ from utils.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus from utils.gtkmenu import ActionMenuClient, GtkMenuClient _MAX_CACHE_SIZE = 5 +_EMPTY_CACHE_TTL = 30.0 # re-check apps with empty menus after 30s +_DBUS_TIMEOUT_INTROSPECT = 500 # ms for introspection calls +_DBUS_TIMEOUT_PID = 200 # ms for PID lookups +_DBUS_TIMEOUT_FAST = 100 # ms for expected-fast calls REGISTRAR_XML = """ @@ -44,6 +49,11 @@ REGISTRAR_XML = """ """ +_EMPTY_SENTINEL: List[ + DBusMenuItem +] = [] # distinct sentinel for "discovery returned empty" + + class GlobalMenuService(Service): """Service that extracts and provides application menus for the global menu bar.""" @@ -76,6 +86,7 @@ class GlobalMenuService(Service): self._client_cache: dict[str, Optional[DBusMenuClient]] = {} self._menu_cache: dict[str, Optional[List[DBusMenuItem]]] = {} self._cache_order: list[str] = [] + self._empty_cache_ts: dict[str, float] = {} # wm_class -> monotonic timestamp # Registrar mapping: sender -> (service_name, object_path) self._registered_menus: dict[str, tuple[str, str]] = {} @@ -86,6 +97,7 @@ class GlobalMenuService(Service): self._lock = threading.Lock() self._registry_lock = threading.Lock() + self._state_lock = threading.Lock() self._setup_registrar() threading.Thread(target=self._setup_environment, daemon=True).start() @@ -147,8 +159,8 @@ class GlobalMenuService(Service): "[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}" + logger.warning( + f"[GlobalMenuService] Global menu environment setup failed: {e}" ) def _handle_registrar_method( @@ -212,26 +224,33 @@ class GlobalMenuService(Service): except Exception: pass - same_class = wm_class == self._current_wm_class - same_pid = target_pid != 0 and target_pid == self._current_pid + # Snapshot current state under lock for race-free comparison + with self._state_lock: + same_class = wm_class == self._current_wm_class + same_pid = target_pid != 0 and target_pid == self._current_pid # Re-focus of EXACT same window instance → use cached menu, not stale _current_menu if same_class and same_pid: with self._lock: cached = self._menu_cache.get(wm_class) if cached: - self._current_menu = cached + with self._state_lock: + self._current_menu = cached self.menu_changed(cached) - elif self._current_menu is not None: - self.menu_changed(self._current_menu) + else: + with self._state_lock: + current_menu = self._current_menu + if current_menu is not None: + self.menu_changed(current_menu) return logger.info( f"[GlobalMenuService] Active window changed: {app_name} ({wm_class}) pid={target_pid}" ) - self._current_app = app_name - self._current_wm_class = wm_class - self._current_pid = target_pid + with self._state_lock: + self._current_app = app_name + self._current_wm_class = wm_class + self._current_pid = target_pid with self._seq_lock: self._extraction_seq += 1 @@ -239,23 +258,46 @@ class GlobalMenuService(Service): self.active_app_changed(app_name, wm_class) - # Check cache — but ONLY serve cached result if it's non-empty. - # An empty cache entry means discovery failed last time; try again. + # Check cache — snapshot under lock, serve non-empty + non-expired results with self._lock: + cached_menu = self._menu_cache.get(wm_class) has_good_cache = ( wm_class in self._client_cache - and wm_class in self._menu_cache - and self._menu_cache.get(wm_class) # non-empty + and cached_menu is not None + and cached_menu # non-empty + and cached_menu is not _EMPTY_SENTINEL ) - cached_menu = self._menu_cache.get(wm_class) if has_good_cache else None + if has_good_cache: + # non-empty, serve it + pass + elif cached_menu is _EMPTY_SENTINEL: + # last attempt returned empty — check TTL + empty_ts = self._empty_cache_ts.get(wm_class, 0) + if time.monotonic() - empty_ts < _EMPTY_CACHE_TTL: + has_good_cache = True # still within TTL, skip re-scan + cached_menu = [] - if has_good_cache and cached_menu: - self._current_menu = cached_menu - logger.info(f"[GlobalMenuService] Cache hit for '{wm_class}'") - self.menu_changed(cached_menu) + if has_good_cache: + with self._state_lock: + self._current_menu = cached_menu + self._current_app = app_name + self._current_wm_class = wm_class + self._current_pid = target_pid + logger.info( + f"[GlobalMenuService] Cache {'hit' if cached_menu else 'skip (empty TTL)'} for '{wm_class}'" + ) + self.menu_changed(cached_menu or []) return - # Dedup in-flight extractions — skip if already running for this class+pid + # Clear menu IMMEDIATELY for the new app (before in_flight check below). + # This ensures the old app's menu doesn't persist if an extraction is + # already running for this wm_class (e.g. user focused Kitty, then Zen, + # then Kitty again before the first extraction finished). + with self._state_lock: + self._current_menu = None + self.menu_changed([]) + + # Dedup in-flight extractions — atomic check+add under lock inflight_key = f"{wm_class}:{target_pid}" with self._lock: if inflight_key in self._extracting_in_flight: @@ -265,10 +307,6 @@ class GlobalMenuService(Service): return self._extracting_in_flight.add(inflight_key) - # Clear menu immediately for new app while extraction runs - self._current_menu = None - self.menu_changed([]) - # Extract menu in background thread, passing captured pid logger.info( f"[GlobalMenuService] Starting extraction for '{wm_class}' pid={target_pid}" @@ -315,7 +353,7 @@ class GlobalMenuService(Service): GLib.Variant("(s)", (service,)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, - 100, + _DBUS_TIMEOUT_FAST, None, ) @@ -327,12 +365,17 @@ class GlobalMenuService(Service): ) return DBusMenuClient(service, path) - except Exception: - # stale entry cleanup — sender no longer on bus - with self._registry_lock: - self._registered_menus.pop(sender, None) + except Exception as e: + # Only remove Registrar entry on NameHasNoOwner, not transient errors + if "NameHasNoOwner" in str(e): + with self._registry_lock: + self._registered_menus.pop(sender, None) + logger.debug( + f"[GlobalMenuService] Cleaned stale Registrar entry: {sender}" + ) + else: logger.debug( - f"[GlobalMenuService] Cleaned stale Registrar entry: {sender}" + f"[GlobalMenuService] Registrar PID lookup error for {sender}: {e}" ) # ========================= @@ -397,7 +440,7 @@ class GlobalMenuService(Service): GLib.Variant("(s)", (svc,)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, - 200, + _DBUS_TIMEOUT_PID, None, ) @@ -491,7 +534,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 300, + _DBUS_TIMEOUT_INTROSPECT, None, ) if not xml_res: @@ -516,7 +559,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 200, + _DBUS_TIMEOUT_FAST, None, ) if cxml_res: @@ -548,7 +591,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 300, + _DBUS_TIMEOUT_INTROSPECT, None, ) @@ -617,6 +660,32 @@ class GlobalMenuService(Service): f"/org/{_safe_cls.lower()}/{_safe_cap}" ) action_candidates.append(f"/org/{_safe_cls.lower()}") + # Add namespace-prefixed paths (e.g. /org/xfce/Thunar) + cls_lower = _safe_cls.lower() + for ns, app_part in [ + ("xfce", _safe_cap), + ("xfce", _safe_cls), + ("mate", _safe_cap), + ("mate", _safe_cls), + ("kde", _safe_cap), + ("kde", _safe_cls), + ("gnome", _safe_cap), + ("gnome", _safe_cls), + ("enlightenment", _safe_cap), + ("enlightenment", _safe_cls), + ("pantheon", _safe_cap), + ("pantheon", _safe_cls), + ("elementary", _safe_cap), + ("elementary", _safe_cls), + ("budgie", _safe_cap), + ("budgie", _safe_cls), + ("deepin", _safe_cap), + ("deepin", _safe_cls), + (cls_lower, _safe_cap), + ]: + p = f"/org/{ns}/{app_part}" + if GLib.Variant.is_object_path(p): + action_candidates.append(p) # DFS for org.gtk.Actions in object tree action_candidates.extend(self._dfs_find_actions(svc)) for act_base in action_candidates: @@ -629,7 +698,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 200, + _DBUS_TIMEOUT_FAST, None, ) xml = ax.get_child_value(0).get_string() @@ -690,7 +759,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 500, + _DBUS_TIMEOUT_INTROSPECT, None, ) xml = xml_res.get_child_value(0).get_string() @@ -745,7 +814,7 @@ class GlobalMenuService(Service): None, GLib.VariantType("(s)"), Gio.DBusCallFlags.NONE, - 500, + _DBUS_TIMEOUT_INTROSPECT, None, ) xml = xml_res.get_child_value(0).get_string() @@ -770,11 +839,24 @@ class GlobalMenuService(Service): if pid1 == pid2: return True try: + # Compare resolved executable paths exe1 = os.path.realpath(f"/proc/{pid1}/exe") exe2 = os.path.realpath(f"/proc/{pid2}/exe") if exe1 and exe2 and exe1 == exe2: return True + # Compare process names (comm) — handles cases where exe differs + # but the app is the same (e.g., launcher wrappers) + try: + with open(f"/proc/{pid1}/comm") as f: + comm1 = f.read().strip() + with open(f"/proc/{pid2}/comm") as f: + comm2 = f.read().strip() + if comm1 and comm2 and comm1 == comm2: + return True + except OSError: + pass + # Also allow: one pid is the direct parent of the other # (Qt apps register DBus under a child thread pid) def _ppid(p: int) -> int: @@ -787,7 +869,7 @@ class GlobalMenuService(Service): if _ppid(pid1) == pid2 or _ppid(pid2) == pid1: return True - except OSError: + except (OSError, PermissionError, FileNotFoundError): pass return False @@ -800,6 +882,7 @@ class GlobalMenuService(Service): oldest = self._cache_order.pop(0) self._client_cache.pop(oldest, None) self._menu_cache.pop(oldest, None) + self._empty_cache_ts.pop(oldest, None) logger.debug(f"[GlobalMenuService] Evicted cache for '{oldest}'") def _extract_menu( @@ -819,21 +902,22 @@ class GlobalMenuService(Service): items = client.get_layout() with self._lock: - # Only cache successful (non-empty) results. - # Empty results are NOT cached so the next focus triggers a fresh discovery. if items: self._client_cache[wm_class] = client self._menu_cache[wm_class] = items + self._empty_cache_ts.pop(wm_class, None) self._evict_cache_if_needed(wm_class) logger.info( f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items" ) else: - # Clear any stale cached empty entry so next focus retries + # Cache empty result with TTL to avoid repeated DBus scanning self._client_cache.pop(wm_class, None) - self._menu_cache.pop(wm_class, None) + self._menu_cache[wm_class] = _EMPTY_SENTINEL + self._empty_cache_ts[wm_class] = time.monotonic() + self._evict_cache_if_needed(wm_class) logger.info( - f"[GlobalMenuService] No DBus menu found for '{wm_class}'" + f"[GlobalMenuService] No DBus menu found for '{wm_class}' (cached empty)" ) with self._seq_lock: @@ -861,14 +945,17 @@ class GlobalMenuService(Service): with self._seq_lock: if seq != self._extraction_seq: return False - self._current_menu = items + with self._state_lock: + 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._state_lock: + wm_class = self._current_wm_class with self._lock: - client = self._client_cache.get(self._current_wm_class) + client = self._client_cache.get(wm_class) if client: client.click_item(item_id) return True @@ -876,20 +963,24 @@ class GlobalMenuService(Service): def about_to_show(self, item_id: int) -> bool: """Triggers the AboutToShow event on a DBusMenu item.""" + with self._state_lock: + wm_class = self._current_wm_class with self._lock: - client = self._client_cache.get(self._current_wm_class) + client = self._client_cache.get(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._state_lock: + wm_class = self._current_wm_class with self._lock: - client = self._client_cache.get(self._current_wm_class) + client = self._client_cache.get(wm_class) if client: items = client.get_layout() with self._lock: - self._menu_cache[self._current_wm_class] = items + self._menu_cache[wm_class] = items return items return [] diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py index 717672a..e642587 100644 --- a/src/services/keyboard_layout.py +++ b/src/services/keyboard_layout.py @@ -67,7 +67,7 @@ class KeyboardLayout(Service): def _sync_with_hyprland(self): try: result = run_command([HYPRCTL_BIN, "devices", "-j"]) - devices = json.loads(result) + devices = json.loads(result.stdout) keyboards = devices.get("keyboards", []) for k in keyboards: if k.get("main"): diff --git a/src/services/modus.py b/src/services/modus.py index 43f0fab..f06919a 100644 --- a/src/services/modus.py +++ b/src/services/modus.py @@ -316,13 +316,10 @@ class ModusService(Service): self.current_active_app_name = "Finder" return - name = wmclass if wmclass else title - if name: - name = str(name).title() - if "." in name: - name = name.split(".")[-1] - else: - name = "Finder" + # Delegate name resolution to app_name_resolver (lazy import to avoid circular) + from utils.app_name_resolver import app_name_resolver + + name = app_name_resolver.format_app_name(title, wmclass) # Set wm_class FIRST so that signal handlers reading it get the new value self.current_active_wm_class = wmclass