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
This commit is contained in:
6 files changed
+161
-75
No files matched your search
+9
-10
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+139
-48
@@ -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 = """
|
||||
<node>
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user