refactor(src): lazy‑load Launcher and refine GlobalMenuService cache invalidation
Prevents premature instance creation and stale empty‑menu cache entries
This commit is contained in:
5 files changed
+335
-202
No files matched your search
+21
-3
@@ -7,7 +7,7 @@ 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.launcher.main import Launcher
|
||||
|
||||
from window.notification.notification import ModusNoti
|
||||
from window.osd.main import OSDWindow
|
||||
from window.panel.main import Panel
|
||||
@@ -22,6 +22,25 @@ for log in [
|
||||
logger.disable(log)
|
||||
|
||||
|
||||
class LazyLauncher:
|
||||
def __init__(self):
|
||||
self._instance = None
|
||||
|
||||
def _get_instance(self):
|
||||
if self._instance is None:
|
||||
from window.launcher.main import Launcher
|
||||
|
||||
self._instance = Launcher()
|
||||
import __main__
|
||||
|
||||
if hasattr(__main__, "app") and __main__.app:
|
||||
__main__.app.add_window(self._instance)
|
||||
return self._instance
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._get_instance(), name)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
# Generate colors.css if it doesn't exist
|
||||
@@ -39,7 +58,7 @@ def main():
|
||||
switcher = ApplicationSwitcher()
|
||||
panel = Panel()
|
||||
modusnoti = ModusNoti()
|
||||
launcher = Launcher()
|
||||
launcher = LazyLauncher()
|
||||
deskwidget = Deskwidgets()
|
||||
dock = Dock()
|
||||
osd = OSDWindow()
|
||||
@@ -55,7 +74,6 @@ def main():
|
||||
deskwidget.top_left,
|
||||
deskwidget.bottom_left,
|
||||
osd,
|
||||
launcher,
|
||||
switcher,
|
||||
dock,
|
||||
)
|
||||
|
||||
+42
-48
@@ -1,6 +1,6 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import shutil
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import GLib, exec_shell_command_async, logger, os, re
|
||||
@@ -81,25 +81,17 @@ class Brightness(Service):
|
||||
try:
|
||||
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
|
||||
if os.path.exists(file_path):
|
||||
current_mtime = os.path.getmtime(file_path)
|
||||
if current_mtime > self._last_file_mtime:
|
||||
self._last_file_mtime = current_mtime
|
||||
with open(file_path) as f:
|
||||
raw = int(f.readline().strip())
|
||||
with open(file_path) as f:
|
||||
raw = int(f.readline().strip())
|
||||
|
||||
if raw != self._last_raw:
|
||||
self._last_raw = raw
|
||||
percent = (
|
||||
int((raw / self.max_screen) * 100)
|
||||
if self.max_screen > 0
|
||||
else 0
|
||||
)
|
||||
if (
|
||||
abs(percent - self._last_percent)
|
||||
>= self.MIN_CHANGE_THRESHOLD
|
||||
):
|
||||
self._last_percent = percent
|
||||
self.emit("screen", percent)
|
||||
if raw != self._last_raw:
|
||||
self._last_raw = raw
|
||||
percent = (
|
||||
int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0
|
||||
)
|
||||
if abs(percent - self._last_percent) >= self.MIN_CHANGE_THRESHOLD:
|
||||
self._last_percent = percent
|
||||
self.emit("screen", percent)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking brightness file: {e}")
|
||||
@@ -227,36 +219,39 @@ class Brightness(Service):
|
||||
):
|
||||
return self._last_raw
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[
|
||||
"ddcutil",
|
||||
"--bus",
|
||||
str(self.ddcutil_bus),
|
||||
*self.DDCUTIL_PARAMS.split(),
|
||||
"getvcp",
|
||||
"10",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2,
|
||||
def on_ddcutil_success(stdout):
|
||||
match = re.search(
|
||||
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
|
||||
stdout,
|
||||
)
|
||||
|
||||
if process.returncode == 0:
|
||||
match = re.search(
|
||||
r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)",
|
||||
process.stdout,
|
||||
)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
if current != self._last_raw:
|
||||
self._last_raw = current
|
||||
self._last_update_time = time.time()
|
||||
return current
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing ddcutil: {e}")
|
||||
percent = (
|
||||
int((current / self.max_screen) * 100)
|
||||
if self.max_screen > 0
|
||||
else 0
|
||||
)
|
||||
# Emit to update UI when background fetch completes
|
||||
self.emit("screen", percent)
|
||||
|
||||
return self._last_raw if self._last_raw != -1 else -1
|
||||
# Update cache timestamp immediately to prevent spamming async commands
|
||||
self._last_update_time = time.time()
|
||||
|
||||
try:
|
||||
exec_shell_command_async(
|
||||
f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} getvcp 10",
|
||||
lambda exit_code, stdout, stderr: (
|
||||
on_ddcutil_success(stdout)
|
||||
if exit_code == 0
|
||||
else logger.error(f"ddcutil error (code {exit_code}): {stderr}")
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing ddcutil async: {e}")
|
||||
|
||||
return self._last_raw if self._last_raw != -1 else 0
|
||||
|
||||
@screen_brightness.setter
|
||||
def screen_brightness(self, value: int):
|
||||
@@ -281,6 +276,7 @@ class Brightness(Service):
|
||||
return
|
||||
|
||||
self._pending_raw = value
|
||||
self._last_raw = value
|
||||
|
||||
if self._timer_id:
|
||||
GLib.source_remove(self._timer_id)
|
||||
@@ -303,8 +299,6 @@ class Brightness(Service):
|
||||
self._lock.unlock()
|
||||
|
||||
try:
|
||||
self._last_raw = raw
|
||||
|
||||
percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0
|
||||
|
||||
if self.backend == "brightnessctl":
|
||||
|
||||
@@ -49,12 +49,12 @@ class CapsLock(Service):
|
||||
self._start_efficient_polling()
|
||||
|
||||
def _start_efficient_polling(self):
|
||||
"""Start efficient polling with 100ms intervals."""
|
||||
"""Start efficient polling with 500ms intervals."""
|
||||
if self._timeout_id is None:
|
||||
self._timeout_id = GLib.timeout_add(100, self._efficient_poll)
|
||||
self._timeout_id = GLib.timeout_add(500, self._efficient_poll)
|
||||
|
||||
def _efficient_poll(self) -> bool:
|
||||
"""Monitor CapsLock state with 100ms polling intervals.
|
||||
"""Monitor CapsLock state with 500ms polling intervals.
|
||||
|
||||
Returns:
|
||||
bool: True to continue polling, False to stop.
|
||||
|
||||
@@ -402,6 +402,15 @@ class CachedNotifications(Notifications):
|
||||
f"Pruned {len(ids_to_remove)} old notifications for limited app {notification.app_name}"
|
||||
)
|
||||
|
||||
# Enforce global max history size (50 notifications max) to prevent memory leak
|
||||
MAX_HISTORY = 50
|
||||
while len(self._cached_notifications) >= MAX_HISTORY:
|
||||
if not self._cached_notifications:
|
||||
break
|
||||
oldest_id = min(self._cached_notifications.keys())
|
||||
self.remove_cached_notification(oldest_id)
|
||||
logger.debug(f"History full, pruned oldest notification {oldest_id}")
|
||||
|
||||
# GUARANTEED STORAGE: Always create and store notification to history first
|
||||
cache_id = self._next_cache_id
|
||||
self._next_cache_id += 1
|
||||
@@ -474,7 +483,7 @@ class CachedNotifications(Notifications):
|
||||
notification.id, image_pixbuf
|
||||
)
|
||||
cache_notification_image(
|
||||
notification.id, image_pixbuf, (35, 35)
|
||||
notification.id, image_pixbuf, (128, 128)
|
||||
)
|
||||
cached_notification.cache_metadata[
|
||||
"notification_image_cache_key"
|
||||
|
||||
+259
-147
@@ -1,9 +1,7 @@
|
||||
"""Global Menu Service - extracts and manages application menus.
|
||||
|
||||
Uses the pure Python DBusMenu extractor via Gio.DBus.
|
||||
"""
|
||||
"""Global Menu Service - extracts and manages application menus."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -17,10 +15,10 @@ from fabric.utils import (
|
||||
os,
|
||||
)
|
||||
|
||||
from utils.dbusmenu import DBusMenuClient, DBusMenuItem
|
||||
from utils.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
|
||||
from utils.gtkmenu import GtkMenuClient
|
||||
|
||||
_MAX_CACHE_SIZE = 32
|
||||
_MAX_CACHE_SIZE = 5
|
||||
|
||||
REGISTRAR_XML = """
|
||||
<node>
|
||||
@@ -71,17 +69,13 @@ class GlobalMenuService(Service):
|
||||
self._current_menu: Optional[List[DBusMenuItem]] = None
|
||||
self._current_app: str = ""
|
||||
self._current_wm_class: str = ""
|
||||
self._current_pid: int = 0
|
||||
|
||||
# Cache holds DBusMenuClient instances
|
||||
# Bounded LRU caches — deliberately small to save memory
|
||||
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]] = {}
|
||||
|
||||
@@ -94,9 +88,6 @@ class GlobalMenuService(Service):
|
||||
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"
|
||||
)
|
||||
@@ -104,7 +95,7 @@ class GlobalMenuService(Service):
|
||||
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._bus = _get_bus() # Shared connection — no extra allocation
|
||||
self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
|
||||
self._reg_id = self._bus.register_object(
|
||||
"/com/canonical/AppMenu/Registrar",
|
||||
@@ -124,64 +115,13 @@ class GlobalMenuService(Service):
|
||||
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"
|
||||
# NOTE: We do NOT set os.environ here — that would affect Modus's own GTK windows,
|
||||
# causing gdk_wayland_window_set_dbus_properties_libgtk_only assertion failures.
|
||||
# The hyprctl/dbus-update-activation-environment calls set it for future apps instead.
|
||||
exec_shell_command("hyprctl keyword env GTK_MODULES,appmenu-gtk-module")
|
||||
exec_shell_command(
|
||||
"dbus-update-activation-environment --systemd GTK_MODULES=appmenu-gtk-module"
|
||||
@@ -250,17 +190,15 @@ class GlobalMenuService(Service):
|
||||
)
|
||||
|
||||
elif method_name == "GetMenus":
|
||||
invocation.return_value(GLib.Variant("(a(uso))", ([])))
|
||||
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))
|
||||
invocation.return_error_literal(Gio.DBusError.FAILED, "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
|
||||
|
||||
# Capture the PID right now at focus time — don't re-query later in the thread
|
||||
target_pid = 0
|
||||
@@ -272,11 +210,21 @@ 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
|
||||
|
||||
# Re-focus of EXACT same window instance → just re-emit what we have
|
||||
if same_class and same_pid:
|
||||
if self._current_menu is not None:
|
||||
self.menu_changed(self._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._seq_lock:
|
||||
self._extraction_seq += 1
|
||||
@@ -284,16 +232,20 @@ class GlobalMenuService(Service):
|
||||
|
||||
self.active_app_changed(app_name, wm_class)
|
||||
|
||||
# Check cache first
|
||||
# Check cache — but ONLY serve cached result if it's non-empty.
|
||||
# An empty cache entry means discovery failed last time; try again.
|
||||
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
|
||||
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
|
||||
)
|
||||
cached_menu = self._menu_cache.get(wm_class) if has_good_cache else None
|
||||
|
||||
if has_client and has_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 cached_menu else [])
|
||||
self.menu_changed(cached_menu)
|
||||
return
|
||||
|
||||
# Clear menu immediately for new app while extraction runs
|
||||
@@ -328,7 +280,7 @@ class GlobalMenuService(Service):
|
||||
if target_pid <= 0:
|
||||
return None
|
||||
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
bus = _get_bus()
|
||||
|
||||
# =========================
|
||||
# 1. FAST PATH: Registrar cache
|
||||
@@ -367,16 +319,29 @@ class GlobalMenuService(Service):
|
||||
# 2. FALLBACK: Scan DBus services matching the target PID
|
||||
# =========================
|
||||
|
||||
# Static well-known paths (Electron, XFCE, legacy apps)
|
||||
static_paths = (
|
||||
# Static well-known paths (all known DEs and toolkits)
|
||||
static_paths: list[str] = [
|
||||
# Unity/Canonical
|
||||
"/com/canonical/menu/0",
|
||||
"/appmenu",
|
||||
"/com/canonical/AppMenu/Registrar/0",
|
||||
"/com/canonical/Unity/Panel/Service",
|
||||
"/appmenu",
|
||||
# GTK generic
|
||||
"/org/gtk/Application/menus/appmenu",
|
||||
"/org/gtk/Application/menus/menubar",
|
||||
"/org/xfce/Thunar/menus/menubar/0",
|
||||
"/org/gtk/Application/menus/appmenu/0",
|
||||
"/org/gtk/Application/menus/menubar/0",
|
||||
# appmenu-gtk-module
|
||||
"/org/appmenu/gtk/window/menus/menubar",
|
||||
)
|
||||
"/org/appmenu/gtk/window/menus/appmenu",
|
||||
"/org/appmenu/gtk/window/menus/menubar/0",
|
||||
"/org/appmenu/gtk/window/menus/appmenu/0",
|
||||
# KDE/Qt
|
||||
"/MenuBar",
|
||||
"/KDEAppMenu",
|
||||
# XFCE
|
||||
"/org/xfce/Thunar/menus/menubar/0",
|
||||
]
|
||||
|
||||
res = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
@@ -421,37 +386,84 @@ class GlobalMenuService(Service):
|
||||
if not (pid == target_pid or self._is_same_app(pid, target_pid)):
|
||||
continue
|
||||
|
||||
# Build dynamic path list: static + numbered children
|
||||
fallback_paths = list(static_paths)
|
||||
|
||||
# These base paths may have numbered children (XID or window index)
|
||||
# e.g. /MenuBar/2, /com/canonical/menu/5, /org/xfce/Thunar/menus/menubar/18
|
||||
# Sanitize wm_class for use in DBus paths (only alphanumeric + underscore allowed)
|
||||
import re as _re
|
||||
# Check if this app explicitly registered its menu via Registrar
|
||||
with self._registry_lock:
|
||||
reg_entry = self._registered_menus.get(svc)
|
||||
if reg_entry:
|
||||
reg_svc, reg_path = reg_entry
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Found Registrar path {reg_path} for {svc}"
|
||||
)
|
||||
return DBusMenuClient(reg_svc, reg_path)
|
||||
|
||||
# Sanitize wm_class for use in DBus paths
|
||||
_safe_cls = (
|
||||
_re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else ""
|
||||
re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else ""
|
||||
)
|
||||
_safe_cap = _safe_cls.capitalize() if _safe_cls else ""
|
||||
|
||||
dynamic_bases = [
|
||||
# Dynamic bases — introspected for child nodes to expand
|
||||
# Ordered by likelihood to minimize DBus calls
|
||||
dynamic_bases: list[str] = [
|
||||
"/MenuBar",
|
||||
"/com/canonical/menu",
|
||||
"/org/appmenu/gtk/window/menus/menubar",
|
||||
"/org/appmenu/gtk/window/menus/appmenu",
|
||||
"/org/appmenu/gtk/window",
|
||||
"/org/gtk/Application/menus",
|
||||
"/org/gtk/Application",
|
||||
# DE namespace roots — DFS handles deeper traversal
|
||||
"/org/libreoffice",
|
||||
"/org/xfce",
|
||||
"/org/mate",
|
||||
"/org/kde",
|
||||
"/org/gnome",
|
||||
"/org/enlightenment",
|
||||
"/org/pantheon",
|
||||
"/org/elementary",
|
||||
"/com/deepin",
|
||||
"/com/solus-project",
|
||||
"/com/canonical",
|
||||
]
|
||||
# Only add wm_class derived paths if the class is non-empty and valid
|
||||
|
||||
# wm_class-derived paths for each known DE namespace
|
||||
if _safe_cls:
|
||||
dynamic_bases += [
|
||||
f"/org/xfce/{_safe_cap}/menus/menubar",
|
||||
f"/org/xfce/{_safe_cls}/menus/menubar",
|
||||
f"/org/{_safe_cls.lower()}/{_safe_cap}/menus/menubar",
|
||||
]
|
||||
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), # generic fallback
|
||||
]:
|
||||
for suffix in ("menus/menubar", "menus/appmenu", "menus"):
|
||||
p = f"/org/{ns}/{app_part}/{suffix}"
|
||||
if GLib.Variant.is_object_path(p):
|
||||
dynamic_bases.append(p)
|
||||
|
||||
fallback_paths = list(static_paths)
|
||||
seen_fallback: set[str] = set(fallback_paths)
|
||||
|
||||
# Multi-level child introspection from dynamic bases
|
||||
for base in dynamic_bases:
|
||||
if not GLib.Variant.is_object_path(base):
|
||||
continue
|
||||
try:
|
||||
mb_xml_res = bus.call_sync(
|
||||
xml_res = _get_bus().call_sync(
|
||||
svc,
|
||||
base,
|
||||
"org.freedesktop.DBus.Introspectable",
|
||||
@@ -462,18 +474,50 @@ class GlobalMenuService(Service):
|
||||
300,
|
||||
None,
|
||||
)
|
||||
if mb_xml_res:
|
||||
xml_text = mb_xml_res.get_child_value(0).get_string()
|
||||
for child in _re.findall(
|
||||
r'<node name="(\w+)"', xml_text
|
||||
if not xml_res:
|
||||
continue
|
||||
xml_text = xml_res.get_child_value(0).get_string()
|
||||
for child in re.findall(r'<node name="([^"]+)"', xml_text):
|
||||
child_path = f"{base}/{child}"
|
||||
if (
|
||||
not GLib.Variant.is_object_path(child_path)
|
||||
or child_path in seen_fallback
|
||||
):
|
||||
child_path = f"{base}/{child}"
|
||||
if GLib.Variant.is_object_path(child_path):
|
||||
fallback_paths.insert(0, child_path)
|
||||
continue
|
||||
seen_fallback.add(child_path)
|
||||
fallback_paths.insert(0, child_path)
|
||||
# Recurse one more level for deeply nested structures
|
||||
try:
|
||||
cxml_res = _get_bus().call_sync(
|
||||
svc,
|
||||
child_path,
|
||||
"org.freedesktop.DBus.Introspectable",
|
||||
"Introspect",
|
||||
None,
|
||||
GLib.VariantType("(s)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
if cxml_res:
|
||||
for gchild in re.findall(
|
||||
r'<node name="([^"]+)"',
|
||||
cxml_res.get_child_value(0).get_string(),
|
||||
):
|
||||
gp = f"{child_path}/{gchild}"
|
||||
if (
|
||||
GLib.Variant.is_object_path(gp)
|
||||
and gp not in seen_fallback
|
||||
):
|
||||
seen_fallback.add(gp)
|
||||
fallback_paths.insert(0, gp)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# check all known paths
|
||||
# Collect ALL GTK menu paths via both fallback_paths and DFS
|
||||
gtk_candidates: list[tuple[str, str]] = []
|
||||
for path in fallback_paths:
|
||||
try:
|
||||
introspect = bus.call_sync(
|
||||
@@ -498,15 +542,49 @@ class GlobalMenuService(Service):
|
||||
self._registered_menus[svc] = (svc, path)
|
||||
return DBusMenuClient(svc, path)
|
||||
elif "org.gtk.Menus" in xml:
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] GTK menu at {path} (deferring for DBusMenu)"
|
||||
)
|
||||
gtk_candidates.append((svc, path))
|
||||
else:
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] {svc} {path}: no menu (interfaces: {re.findall(r'interface name=\"([^\"]+)\"', xml)})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] {svc} {path}: introspection error: {e}"
|
||||
)
|
||||
|
||||
# Also search the full object tree via DFS for GTK menus
|
||||
all_dfs_paths = self._dfs_find_gtk_menus(svc)
|
||||
for dfs_path in all_dfs_paths:
|
||||
if dfs_path not in {p for _, p in gtk_candidates}:
|
||||
gtk_candidates.append((svc, dfs_path))
|
||||
|
||||
# Validate each candidate — find one with actual menu items
|
||||
for gsvc, gpath in gtk_candidates:
|
||||
try:
|
||||
test_client = GtkMenuClient(gsvc, gpath)
|
||||
test_items = test_client.get_layout()
|
||||
if test_items:
|
||||
logger.info(
|
||||
f"[GlobalMenuService] GtkMenu found: {svc} {path}"
|
||||
f"[GlobalMenuService] GTK menu found: {gsvc} {gpath} ({len(test_items)} items)"
|
||||
)
|
||||
with self._registry_lock:
|
||||
self._registered_menus[svc] = (svc, path)
|
||||
return GtkMenuClient(svc, path)
|
||||
self._registered_menus[gsvc] = (gsvc, gpath)
|
||||
return test_client
|
||||
else:
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] GTK menu {gpath} returned 0 items — trying next"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] GTK menu {gpath} validation failed: {e}"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
logger.info(
|
||||
f"[GlobalMenuService] No menu found for {svc} wm={wm_class}"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
@@ -516,43 +594,68 @@ class GlobalMenuService(Service):
|
||||
|
||||
return None
|
||||
|
||||
def _dfs_find_gtk_menus(
|
||||
self,
|
||||
service: str,
|
||||
path: str = "/",
|
||||
_depth: int = 0,
|
||||
_visited: Optional[set] = None,
|
||||
) -> list[str]:
|
||||
"""Bounded DFS returning ALL org.gtk.Menus paths in a service's object tree."""
|
||||
if _depth > 6 or (_visited is not None and path in _visited):
|
||||
return []
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
_visited.add(path)
|
||||
results: list[str] = []
|
||||
try:
|
||||
xml_res = _get_bus().call_sync(
|
||||
service,
|
||||
path,
|
||||
"org.freedesktop.DBus.Introspectable",
|
||||
"Introspect",
|
||||
None,
|
||||
GLib.VariantType("(s)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
500,
|
||||
None,
|
||||
)
|
||||
xml = xml_res.get_child_value(0).get_string()
|
||||
if 'interface name="org.gtk.Menus"' in xml:
|
||||
results.append(path)
|
||||
for child in re.findall(r'<node name="([^"]+)"', xml):
|
||||
child_path = (
|
||||
path.rstrip("/") + "/" + child if path != "/" else "/" + child
|
||||
)
|
||||
if not GLib.Variant.is_object_path(child_path):
|
||||
continue
|
||||
results.extend(
|
||||
self._dfs_find_gtk_menus(service, child_path, _depth + 1, _visited)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
def _is_same_app(self, pid1: int, pid2: int) -> bool:
|
||||
if pid1 == pid2:
|
||||
return True
|
||||
try:
|
||||
exe1 = os.path.realpath(f"/proc/{pid1}/exe")
|
||||
exe2 = os.path.realpath(f"/proc/{pid2}/exe")
|
||||
if not (exe1 and exe2 and exe1 == exe2):
|
||||
# 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:
|
||||
try:
|
||||
with open(f"/proc/{p}/stat") as f:
|
||||
return int(f.read().split()[3])
|
||||
except Exception:
|
||||
return 0
|
||||
if exe1 and exe2 and exe1 == exe2:
|
||||
return True
|
||||
|
||||
if _ppid(pid1) == pid2 or _ppid(pid2) == pid1:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Same executable — also confirm start time to avoid matching two
|
||||
# independent instances of the same binary (e.g. two terminals)
|
||||
def _start_time(pid: int) -> Optional[str]:
|
||||
# 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:
|
||||
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
|
||||
with open(f"/proc/{p}/stat") as f:
|
||||
return int(f.read().split()[3])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
st1 = _start_time(pid1)
|
||||
st2 = _start_time(pid2)
|
||||
|
||||
if st1 is not None and st2 is not None:
|
||||
return st1 == st2
|
||||
|
||||
return True
|
||||
if _ppid(pid1) == pid2 or _ppid(pid2) == pid1:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
@@ -571,6 +674,11 @@ class GlobalMenuService(Service):
|
||||
def _extract_menu(self, wm_class: str, seq: int, target_pid: int = 0):
|
||||
"""Extract menu for a given WM class in a background thread."""
|
||||
try:
|
||||
# Bail early if a newer extraction has already superseded this one
|
||||
with self._seq_lock:
|
||||
if seq != self._extraction_seq:
|
||||
return
|
||||
|
||||
client = self._discover_dbus_client(wm_class, target_pid)
|
||||
items = []
|
||||
|
||||
@@ -578,15 +686,19 @@ class GlobalMenuService(Service):
|
||||
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)
|
||||
|
||||
# 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._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
|
||||
self._client_cache.pop(wm_class, None)
|
||||
self._menu_cache.pop(wm_class, None)
|
||||
logger.info(
|
||||
f"[GlobalMenuService] No DBus menu found for '{wm_class}'"
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user