feat(src/utils): centralize SESSION bus and shrink DBusMenuItem with __slots__
Prevent duplicate bus connections and cut per-instance memory usage.
This commit is contained in:
2 files changed
+206
-160
No files matched your search
+120
-78
@@ -1,36 +1,30 @@
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from gi.repository import Gio, GLib
|
||||
from fabric.utils import logger
|
||||
from utils.dbusmenu import DBusMenuItem
|
||||
|
||||
from fabric.utils import Gio, GLib, logger
|
||||
|
||||
from utils.dbusmenu import DBusMenuItem, _get_bus
|
||||
|
||||
_SUBMENU_RE = re.compile(r"submenu")
|
||||
|
||||
|
||||
class GtkMenuClient:
|
||||
__slots__ = ("service_name", "object_path", "_action_path", "_cached_items")
|
||||
|
||||
def __init__(self, service_name: str, object_path: str):
|
||||
self.service_name = service_name
|
||||
self.object_path = object_path
|
||||
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
self._action_group_path = self._guess_action_group_path(object_path)
|
||||
# Actions live at the parent of /menus/...
|
||||
self._action_path = (
|
||||
object_path.split("/menus/")[0] if "/menus/" in object_path else object_path
|
||||
)
|
||||
self._cached_items: List[DBusMenuItem] = []
|
||||
|
||||
def _guess_action_group_path(self, menu_path: str) -> str:
|
||||
# Menus are usually /org/appmenu/gtk/window/menus/menubar/10
|
||||
# Actions are usually /org/appmenu/gtk/window/10
|
||||
if "/menus/" in menu_path:
|
||||
return menu_path.split("/menus/")[0]
|
||||
# Some apps export actions at the exact same path
|
||||
return menu_path
|
||||
|
||||
def _call_method(
|
||||
self,
|
||||
interface: str,
|
||||
method: str,
|
||||
params: Optional[GLib.Variant],
|
||||
reply_type: Optional[GLib.VariantType],
|
||||
path_override: Optional[str] = None,
|
||||
) -> Optional[GLib.Variant]:
|
||||
def _call(self, interface, method, params, reply_type=None, path=None):
|
||||
try:
|
||||
return self.bus.call_sync(
|
||||
return _get_bus().call_sync(
|
||||
self.service_name,
|
||||
path_override or self.object_path,
|
||||
path or self.object_path,
|
||||
interface,
|
||||
method,
|
||||
params,
|
||||
@@ -40,93 +34,141 @@ class GtkMenuClient:
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[GtkMenuClient] Call to {method} failed: {e}")
|
||||
logger.debug(f"[GtkMenuClient] {method} failed: {e}")
|
||||
return None
|
||||
|
||||
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
|
||||
return self._fetch_menu(parent_id)
|
||||
items = self._fetch_menu(parent_id)
|
||||
self._cached_items = items
|
||||
return items
|
||||
|
||||
def _fetch_menu(self, menu_id: int) -> List[DBusMenuItem]:
|
||||
params = GLib.Variant("(au)", ([menu_id],))
|
||||
res = self._call_method(
|
||||
"org.gtk.Menus", "Start", params, GLib.VariantType("(a(uuaa{sv}))")
|
||||
def _find_item(
|
||||
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
|
||||
) -> Optional[DBusMenuItem]:
|
||||
if items is None:
|
||||
items = self._cached_items
|
||||
for item in items:
|
||||
if item.id == item_id:
|
||||
return item
|
||||
if item.children:
|
||||
found = self._find_item(item_id, item.children)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
def _fetch_menu(
|
||||
self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None
|
||||
) -> List[DBusMenuItem]:
|
||||
if _depth > 10:
|
||||
return []
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
if menu_id in _visited:
|
||||
return []
|
||||
_visited.add(menu_id)
|
||||
res = self._call(
|
||||
"org.gtk.Menus",
|
||||
"Start",
|
||||
GLib.Variant("(au)", ([menu_id],)),
|
||||
GLib.VariantType("(a(uuaa{sv}))"),
|
||||
)
|
||||
if not res:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Unpack the response
|
||||
# res is a tuple containing one element: the array
|
||||
data = res.get_child_value(0).unpack()
|
||||
|
||||
if not data or len(data) == 0:
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# data[0] is (subscription_id, menu_id, groups)
|
||||
groups = data[0][2]
|
||||
entry_map = {}
|
||||
for entry in data:
|
||||
gid, mid, sections = entry
|
||||
entry_map[(gid, mid)] = sections
|
||||
|
||||
items = []
|
||||
# each group is an array of dicts (items)
|
||||
for group_idx, group in enumerate(groups):
|
||||
# insert separator between groups
|
||||
if group_idx > 0 and len(items) > 0 and len(group) > 0:
|
||||
items.append(DBusMenuItem(id=-1, type="separator"))
|
||||
|
||||
for idx, props in enumerate(group):
|
||||
item_id = hash(f"{menu_id}_{group_idx}_{idx}")
|
||||
item = DBusMenuItem(id=item_id)
|
||||
items: List[DBusMenuItem] = []
|
||||
|
||||
def resolve(sections, visited=None):
|
||||
if visited is None:
|
||||
visited = set()
|
||||
for props in sections:
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
if ":section" in props:
|
||||
ref = props[":section"]
|
||||
if isinstance(ref, (list, tuple)) and len(ref) == 2:
|
||||
key = (int(ref[0]), int(ref[1]))
|
||||
if key not in visited:
|
||||
visited.add(key)
|
||||
child = entry_map.get(key)
|
||||
if child is not None:
|
||||
resolve(child, visited)
|
||||
continue
|
||||
if not props.get("label") and not any(
|
||||
k in props for k in ("action", ":submenu", "submenu")
|
||||
):
|
||||
continue
|
||||
item = DBusMenuItem(id=hash((menu_id, len(items))))
|
||||
item.label = props.get("label", "").replace("_", "")
|
||||
|
||||
if "submenu" in props:
|
||||
item.enabled = props.get("enabled", True)
|
||||
item.visible = props.get("visible", True)
|
||||
sub_ref = props.get(":submenu") or props.get("submenu")
|
||||
if (
|
||||
sub_ref
|
||||
and isinstance(sub_ref, (list, tuple))
|
||||
and len(sub_ref) == 2
|
||||
):
|
||||
item.has_submenu = True
|
||||
submenu_id = props["submenu"]
|
||||
item.children = self._fetch_menu(submenu_id)
|
||||
|
||||
item.children = self._fetch_menu(
|
||||
int(sub_ref[0]), _depth + 1, _visited
|
||||
)
|
||||
if "action" in props:
|
||||
item.action_name = props["action"]
|
||||
if "target" in props:
|
||||
# Variant target
|
||||
item.action_target = GLib.Variant.new_variant(
|
||||
GLib.Variant("v", props["target"])
|
||||
)
|
||||
|
||||
try:
|
||||
item.action_target = GLib.Variant.new_variant(
|
||||
GLib.Variant("v", props["target"])
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
items.append(item)
|
||||
|
||||
if data:
|
||||
resolve(data[0][2])
|
||||
|
||||
return items
|
||||
except Exception as e:
|
||||
logger.error(f"[GtkMenuClient] Parse error: {e}")
|
||||
return []
|
||||
|
||||
def about_to_show(self, item_id: int) -> bool:
|
||||
# org.gtk.Menus doesn't have AboutToShow
|
||||
return False
|
||||
|
||||
def click_item(
|
||||
self, item_id: int, action_name: str = "", action_target: GLib.Variant = None
|
||||
self, item_id: int, action_name: str = "", action_target=None
|
||||
) -> None:
|
||||
if not action_name:
|
||||
item = self._find_item(item_id)
|
||||
if item and item.action_name:
|
||||
action_name = item.action_name
|
||||
action_target = item.action_target
|
||||
if not action_name:
|
||||
return
|
||||
|
||||
# Strip the unity. prefix added by appmenu-gtk-module
|
||||
if action_name.startswith("unity."):
|
||||
action_name = action_name[6:]
|
||||
try:
|
||||
# Action activation
|
||||
# Signature: Activate (sava{sv})
|
||||
# s: action_name
|
||||
# av: parameter (array of variants, empty if no target)
|
||||
# a{sv}: platform data
|
||||
|
||||
params_arr = []
|
||||
if action_target is not None:
|
||||
params_arr = [action_target]
|
||||
|
||||
params = GLib.Variant("(sava{sv})", (action_name, params_arr, {}))
|
||||
|
||||
self._call_method(
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
params,
|
||||
None,
|
||||
path_override=self._action_group_path,
|
||||
)
|
||||
params_arr = [action_target] if action_target is not None else []
|
||||
# Try menu path first (Thunar), fall back to action path (LibreOffice)
|
||||
for act_path in (self.object_path, self._action_path):
|
||||
try:
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
|
||||
path=act_path,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"[GtkMenuClient] Failed to click item: {e}")
|
||||
logger.error(f"[GtkMenuClient] click failed: {e}")
|
||||
Reference in new issue
Block a user