chore: refactor imports and adopt new utils utilities

This commit is contained in:
sankalp committed 2026-07-14 18:45:52 +05:45
1 parent b2114f52b1
commit 6f2d0bf444
65 files changed
+221 -266

No files matched your search

View File
Whitespace-only changes.
+407
View File
@@ -0,0 +1,407 @@
"""
Universal DBus + GTK Global Menu Client
Supports:
- com.canonical.dbusmenu (KDE / Unity / XFCE)
- GTK appmenu-module DBusMenu
- GTK GMenuModel fallback (best-effort)
- Incremental updates via DBusMenu signals
"""
import threading
from typing import Callable, Dict, List, Optional, Tuple
from fabric.utils import Any, Gio, GLib, logger
# SHARED BUS — one connection for the entire process
_SESSION_BUS: Optional[Gio.DBusConnection] = None
_BUS_LOCK = threading.Lock()
def _get_bus() -> Gio.DBusConnection:
global _SESSION_BUS
if _SESSION_BUS is None:
with _BUS_LOCK:
if _SESSION_BUS is None:
_SESSION_BUS = Gio.bus_get_sync(Gio.BusType.SESSION, None)
return _SESSION_BUS
class DBusMenuItem:
__slots__ = (
"action_name",
"action_target",
"children",
"enabled",
"has_submenu",
"icon_name",
"id",
"label",
"parent_id",
"shortcut",
"type",
"visible",
)
def __init__(
self,
id: int,
label: str = "",
enabled: bool = True,
visible: bool = True,
type: str = "standard",
shortcut: str = "",
icon_name: str = "",
has_submenu: bool = False,
children: Optional[List["DBusMenuItem"]] = None,
action_name: str = "",
action_target: Optional[Any] = None,
parent_id: int = -1,
):
self.id = id
self.label = label
self.enabled = enabled
self.visible = visible
self.type = type
self.shortcut = shortcut
self.icon_name = icon_name
self.has_submenu = has_submenu
self.children: List[DBusMenuItem] = children if children is not None else []
self.action_name = action_name
self.action_target = action_target
self.parent_id = parent_id
class DBusMenuClient:
__slots__ = (
"_cache",
"_cache_valid",
"_fetching",
"_hash_cache",
"_lock",
"_on_item_activated_cb",
"_on_items_updated_cb",
"_on_layout_updated_cb",
"_revision",
"_signal_ids",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._cache: Optional[List[DBusMenuItem]] = None
self._cache_valid = False
self._hash_cache: Optional[str] = None
self._revision: int = 0
self._lock = threading.Lock()
self._fetching = False
self._signal_ids: List[int] = []
self._on_layout_updated_cb: Optional[Callable] = None
self._on_items_updated_cb: Optional[Callable] = None
self._on_item_activated_cb: Optional[Callable] = None
def _call(self, method, params, reply_type=None):
try:
return _get_bus().call_sync(
self.service_name,
self.object_path,
"com.canonical.dbusmenu",
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.warning(
f"[dbusmenu] return _get_bus().call_sync( self.service_name, self.obje... failed: {e}"
)
return None
def get_layout(self, force_refresh=False):
if self._cache and self._cache_valid and not force_refresh:
return self._cache
with self._lock:
if self._fetching:
return self._cache or []
self._fetching = True
try:
return self._fetch()
finally:
with self._lock:
self._fetching = False
def _fetch(self):
params = GLib.Variant("(iias)", (0, -1, []))
res = self._call("GetLayout", params)
if not res:
return []
try:
revision = res.get_child_value(0).get_uint32()
self._revision = revision
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
layout = layout.get_variant()
parsed = self._parse_dbusmenu(layout).children
h = self._hash(parsed)
if h == self._hash_cache:
return self._cache or parsed
self._hash_cache = h
self._cache = parsed
self._cache_valid = True
return parsed
except Exception as e:
logger.error(f"[Menu] parse error: {e}")
return []
def _hash(self, items):
flat: List[str] = []
def walk(nodes):
for n in nodes:
flat.append(f"{n.id}:{n.label}:{n.enabled}")
walk(n.children)
walk(items)
return str(hash("".join(flat)))
def _parse_dbusmenu(self, node, _depth: int = 0, _parent_id: int = -1):
if _depth > 20:
return DBusMenuItem(id=0)
item = DBusMenuItem(id=0)
try:
if node.n_children() != 3:
return item
item.id = node.get_child_value(0).get_int32()
item.parent_id = _parent_id
try:
props = node.get_child_value(1).unpack()
except Exception:
props = {}
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
item.type = props.get("type", "standard")
item.icon_name = props.get("icon-name", "")
if props.get("children-display") == "submenu":
item.has_submenu = True
sc = props.get("shortcut")
if sc:
try:
parts = []
if isinstance(sc, list):
flat = sc[0] if isinstance(sc[0], list) else sc
mapping = {
"Control": "Ctrl",
"Shift": "Shift",
"Alt": "Alt",
"Meta": "Super",
}
for s in flat:
parts.append(mapping.get(s, str(s).upper()))
item.shortcut = "+".join(parts)
except Exception:
item.shortcut = ""
children = node.get_child_value(2)
for i in range(children.n_children()):
if _depth >= 20:
break
c = children.get_child_value(i)
if c.is_of_type(GLib.VariantType("v")):
c = c.get_variant()
item.children.append(self._parse_dbusmenu(c, _depth + 1, item.id))
if item.has_submenu and not item.children:
try:
self.about_to_show(item.id)
except Exception as e:
logger.warning(
f"[dbusmenu] self.about_to_show(item.id) failed: {e}"
)
except Exception as e:
logger.warning(
f"[dbusmenu] if node.n_children() != 3: return item failed: {e}"
)
return item
def about_to_show(self, item_id):
try:
res = self._call(
"AboutToShow",
GLib.Variant("(i)", (item_id,)),
GLib.VariantType("(b)"),
)
return res.get_child_value(0).get_boolean() if res else False
except Exception as e:
logger.warning(
f"[dbusmenu] res = self._call( 'AboutToShow', GLib.Variant('(i)', (ite... failed: {e}"
)
return False
def click_item(self, item_id, pid=0):
try:
self._call(
"Event",
GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("s", ""), 0)),
)
self.invalidate()
except Exception as e:
logger.warning(
f"[dbusmenu] self._call( 'Event', GLib.Variant('(isvu)', (item_id, 'cl... failed: {e}"
)
def invalidate(self):
self._cache_valid = False
def prefetch_async(self):
if self._fetching:
return
threading.Thread(
target=self.get_layout, kwargs={"force_refresh": True}, daemon=True
).start()
def connect_signals(
self,
on_layout_updated: Optional[Callable] = None,
on_items_updated: Optional[Callable] = None,
on_item_activated: Optional[Callable] = None,
):
self._on_layout_updated_cb = on_layout_updated
self._on_items_updated_cb = on_items_updated
self._on_item_activated_cb = on_item_activated
bus = _get_bus()
def handle_layout_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
parent_id, revision, properties = params.unpack()
self._revision = revision
if self._on_layout_updated_cb:
self._on_layout_updated_cb(parent_id, revision, properties)
except Exception as e:
logger.debug(f"[DBusMenuClient] LayoutUpdated handler error: {e}")
def handle_items_properties_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
updated_props, removed_ids = params.unpack()
if self._on_items_updated_cb:
self._on_items_updated_cb(updated_props, removed_ids)
self._cache_valid = False
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemsPropertiesUpdated error: {e}")
def handle_item_activation_requested(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
item_id, timestamp = params.unpack()
if self._on_item_activated_cb:
self._on_item_activated_cb(item_id, timestamp)
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemActivationRequested error: {e}")
for sig_name, handler in [
("LayoutUpdated", handle_layout_updated),
("ItemsPropertiesUpdated", handle_items_properties_updated),
("ItemActivationRequested", handle_item_activation_requested),
]:
sid = bus.signal_subscribe(
self.service_name,
"com.canonical.dbusmenu",
sig_name,
self.object_path,
None,
Gio.DBusCallFlags.NONE,
handler,
None,
)
if sid:
self._signal_ids.append(sid)
def disconnect_signals(self):
if not self._signal_ids:
return
bus = _get_bus()
for sid in self._signal_ids:
try:
bus.signal_unsubscribe(sid)
except Exception as e:
logger.warning(f"[dbusmenu] bus.signal_unsubscribe(sid) failed: {e}")
self._signal_ids.clear()
def update_layout(
self, parent_id: int, revision: int
) -> Optional[List[DBusMenuItem]]:
self._revision = revision
params = GLib.Variant("(iias)", (parent_id, revision, []))
res = self._call("GetLayout", params)
if not res:
return None
try:
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
layout = layout.get_variant()
parsed = self._parse_dbusmenu(layout).children
if parent_id == 0:
self._hash_cache = self._hash(parsed)
self._cache = parsed
self._cache_valid = True
return parsed
except Exception as e:
logger.error(f"[Menu] update_layout error: {e}")
return None
def apply_items_update(
self, updated_props: List[Tuple[int, Dict]], removed_ids: List[int]
):
if not self._cache:
return
removed_set = set(removed_ids)
def walk(items):
result = []
for item in items:
if item.id in removed_set:
continue
for uid, props in updated_props:
if item.id == uid:
if "label" in props:
item.label = str(props["label"]).replace("_", "")
if "enabled" in props:
item.enabled = bool(props["enabled"])
if "visible" in props:
item.visible = bool(props["visible"])
if "type" in props:
item.type = str(props["type"])
item.children = walk(item.children)
result.append(item)
return result
self._cache = walk(self._cache)
self._hash_cache = None
+239
View File
@@ -0,0 +1,239 @@
"""Detect whether an executable is built against GTK3 or GTK4.
The global menu compat shim (libmenu_button_shim.so) links against GTK3 and
must only be loaded into GTK3 processes. Loading it into a GTK4 process pulls
GTK 2/3 symbols into the same process and GTK4 aborts with:
GTK-ERROR: "GTK 2/3 symbols detected. Using GTK 2/3 and GTK 4 in the same
process is not supported"
We therefore inspect an executable's ELF ``DT_NEEDED`` entries to learn which
GTK it depends on, and only inject the shim for GTK3 binaries. Results are
cached on disk (keyed by resolved path + mtime) so the lookup is cheap and is
never run on every launch for unchanged binaries.
"""
import json
import os
import shutil
from collections import deque
from pathlib import Path
from fabric.utils import logger
# SONAMEs we care about.
GTK3_SONAME = "libgtk-3.so.0"
GTK4_SONAME = "libgtk-4.so.1"
_CACHE_DIR = Path.home() / ".cache" / "modus"
_CACHE_FILE = _CACHE_DIR / "gtk_class_cache.json"
# Directories searched when resolving a NEEDED SONAME to a file. The binary's
# own directory is always tried first, then these (LD_LIBRARY_PATH wins).
_LIB_DIRS = [
*os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep),
"/usr/lib",
"/usr/lib64",
"/lib",
"/lib64",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/i386-linux-gnu",
]
def _parse_dynamic(path: Path) -> tuple[set[str], list[Path]]:
"""Parse an ELF's dynamic section.
Returns ``(needed_sonames, runpath_dirs)``. ``runpath_dirs`` are the
directories named by DT_RUNPATH/DT_RPATH with ``$ORIGIN`` expanded to the
directory containing ``path``. Private libraries (e.g. gedit's
libgedit-50.so) live there and must be searched to follow the dependency
tree correctly.
"""
try:
with open(path, "rb") as f:
ident = f.read(20)
if len(ident) < 20 or ident[:4] != b"\x7fELF":
return set(), []
is_64 = ident[4] == 2
data = path.read_bytes()
except OSError as e:
logger.warning(
f"[detection] with open(path, 'rb') as f: ident = f.read(20) failed: {e}"
)
return set(), []
try:
if is_64:
e_phoff = int.from_bytes(data[0x20:0x28], "little")
e_phentsize = int.from_bytes(data[0x36:0x38], "little")
e_phnum = int.from_bytes(data[0x38:0x3A], "little")
else:
e_phoff = int.from_bytes(data[0x1C:0x20], "little")
e_phentsize = int.from_bytes(data[0x2A:0x2C], "little")
e_phnum = int.from_bytes(data[0x2C:0x2E], "little")
except (IndexError, ValueError) as e:
logger.warning(
f"[detection] if is_64: e_phoff = int.from_bytes(data[0x20:0x28], 'litt... failed: {e}"
)
return set(), []
PT_DYNAMIC = 2
word_size = 8 if is_64 else 4
def unpack(b: bytes) -> int:
return int.from_bytes(b[:word_size], "little")
dynamic_off = None
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
if unpack(data[base : base + 4]) == PT_DYNAMIC:
dynamic_off = unpack(data[base + word_size : base + word_size * 2])
dyn_strtab = unpack(data[base + word_size * 2 : base + word_size * 3])
break
if dynamic_off is None:
return set(), []
DT_NEEDED = 1
DT_STRTAB = 5
DT_RUNPATH = 0x1D
DT_RPATH = 0x0F
needed_offsets: list[int] = []
strtab_off = dyn_strtab
runpath_str = ""
step = word_size * 2
pos = dynamic_off
while pos + step <= len(data):
d_tag = unpack(data[pos : pos + word_size])
d_val = unpack(data[pos + word_size : pos + step])
if d_tag == 0: # DT_NULL
break
if d_tag == DT_NEEDED:
needed_offsets.append(d_val)
elif d_tag == DT_STRTAB:
strtab_off = d_val
elif d_tag in (DT_RUNPATH, DT_RPATH):
end = data.find(b"\x00", strtab_off + d_val)
if end != -1:
runpath_str = data[strtab_off + d_val : end].decode("utf-8", "replace")
pos += step
needed: set[str] = set()
for off in needed_offsets:
end = data.find(b"\x00", strtab_off + off)
if end == -1:
continue
needed.add(data[strtab_off + off : end].decode("utf-8", "replace"))
origin = path.parent
runpath_dirs: list[Path] = []
for entry in runpath_str.split(":"):
entry = entry.strip()
if not entry:
continue
entry = entry.replace("$ORIGIN", str(origin)).replace("${ORIGIN}", str(origin))
runpath_dirs.append(Path(entry))
return needed, runpath_dirs
def _find_lib(soname: str, search_dirs: list[Path]) -> Path | None:
"""Resolve a NEEDED SONAME to a file within ``search_dirs``."""
for directory in search_dirs:
candidate = directory / soname
if candidate.is_file():
return candidate
return None
def _transitive_needed(root: Path) -> set[str]:
"""Collect every DT_NEEDED SONAME in the dependency tree of ``root``.
Follows NEEDED entries transitively (including private libs reached via
RUNPATH) so that GTK pulled in indirectly is still detected.
"""
all_needed: set[str] = set()
seen_paths: set[Path] = set()
queue: deque[Path] = deque([root])
while queue:
lib = queue.popleft()
if lib in seen_paths:
continue
seen_paths.add(lib)
needed, runpath_dirs = _parse_dynamic(lib)
search_dirs = [lib.parent, *runpath_dirs, *[Path(d) for d in _LIB_DIRS if d]]
for soname in needed:
all_needed.add(soname)
if soname in (GTK3_SONAME, GTK4_SONAME):
continue
dep = _find_lib(soname, search_dirs)
if dep and dep not in seen_paths:
queue.append(dep)
return all_needed
def _load_cache() -> dict:
from utils.functions import read_json_file
return read_json_file(str(_CACHE_FILE)) or {}
def _save_cache(cache: dict) -> None:
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_CACHE_FILE.write_text(json.dumps(cache))
except OSError as e:
logger.debug(f"[GlobalMenu] Could not write gtk class cache: {e}")
def executable_gtk_class(executable: str | None) -> str | None:
"""Classify an executable as ``"gtk3"``, ``"gtk4"`` or ``None``.
``None`` means "unable to determine" (e.g. a shell script, a wrapper, or a
non-GTK binary). In that case the caller should NOT inject the GTK3 shim,
which keeps GTK4 applications safe.
"""
if not executable:
return None
token = executable.strip().split()[0] if executable.strip() else ""
if not token:
return None
resolved = shutil.which(token) or token
path = Path(resolved).expanduser()
if not path.is_absolute() or not path.exists():
# Try as a direct path too.
path = Path(token)
if not path.exists():
return None
try:
mtime = path.stat().st_mtime_ns
except OSError as e:
logger.warning(f"[detection] mtime = path.stat().st_mtime_ns failed: {e}")
return None
cache = _load_cache()
key = f"{path}"
cached = cache.get(key)
if cached and cached.get("mtime") == mtime:
return cached.get("class")
needed = _transitive_needed(path)
if GTK4_SONAME in needed:
klass = "gtk4"
elif GTK3_SONAME in needed:
klass = "gtk3"
else:
klass = None
cache[key] = {"mtime": mtime, "class": klass}
_save_cache(cache)
return klass
+193
View File
@@ -0,0 +1,193 @@
"""Global menu environment setup — GTK_MODULES, UBUNTU_MENUPROXY, settings.ini, Flatpak."""
import os
import subprocess
from pathlib import Path
from fabric.utils import exec_shell_command, logger
SHIM_SRC = Path(__file__).resolve().parent / "libmenu_button_shim.c"
SHIM_SO = Path(__file__).resolve().parent / "libmenu_button_shim.so"
ENV_VARS = {
"GTK_MODULES": "appmenu-gtk-module",
"UBUNTU_MENUPROXY": "1",
}
# Marker lines previously written around the global LD_PRELOAD export in shell
# rc files. Kept so we can strip the stale block from already-configured systems.
_SHIM_BEGIN = "# >>> modus global-menu shim >>>"
_SHIM_END = "# <<< modus global-menu shim <<<"
def _remove_global_ld_preload():
"""Strip the legacy global LD_PRELOAD export from previously configured systems.
The shim is now injected per-launch into GTK3 apps only (see
globalmenu.launch). Any globally exported LD_PRELOAD must be removed so it
no longer breaks GTK4 processes.
"""
home = Path.home()
shell = os.environ.get("SHELL", "")
rc_files = [
home / ".zshrc",
home / ".bashrc",
home / ".profile",
home / ".config" / "fish" / "config.fish",
]
rc_path = {
"zsh": home / ".zshrc",
"bash": home / ".bashrc",
"fish": home / ".config" / "fish" / "config.fish",
}.get(Path(shell).name)
if rc_path:
rc_files.append(rc_path)
rc_files = list(dict.fromkeys(rc_files)) # de-dupe, keep order
for rc in rc_files:
try:
if not rc.is_file():
continue
lines = rc.read_text().splitlines(keepends=True)
if not any(_SHIM_BEGIN in ln for ln in lines):
continue
out = []
drop = False
for ln in lines:
if _SHIM_BEGIN in ln:
drop = True
continue
if _SHIM_END in ln:
drop = False
continue
if drop:
continue
out.append(ln)
rc.write_text("".join(out))
logger.info(f"[GlobalMenu] Removed stale LD_PRELOAD block from {rc}")
except OSError as e:
logger.warning(f"[environment] if not rc.is_file(): continue failed: {e}")
# Strip the LD_PRELOAD line from environment.d/appmenu.conf.
env_path = home / ".config" / "environment.d" / "appmenu.conf"
try:
if env_path.is_file():
kept = [
ln
for ln in env_path.read_text().splitlines(keepends=True)
if not ln.strip().startswith("LD_PRELOAD=")
]
env_path.write_text("".join(kept))
except OSError as e:
logger.warning(
f"[environment] if env_path.is_file(): kept = [ ln for ln in env_path.rea... failed: {e}"
)
# Clear it from the activation environment for future launches.
exec_shell_command("dbus-update-activation-environment --systemd LD_PRELOAD=")
def get_compiled_shim() -> Path | None:
"""Compile the GTK3 menu-button shim if needed. Returns path to .so or None."""
try:
if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime:
return SHIM_SO
if not SHIM_SRC.exists():
logger.debug("[GlobalMenu] Shim source not found, skipping")
return None
gtk_cflags = subprocess.check_output(
["pkg-config", "--cflags", "--libs", "gtk+-3.0"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
cmd = f"gcc -shared -fPIC -O2 -o {SHIM_SO} {SHIM_SRC} {gtk_cflags} -ldl"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
logger.warning(f"[GlobalMenu] Shim compilation failed:\n{result.stderr}")
return None
logger.info(f"[GlobalMenu] Compiled menu shim -> {SHIM_SO}")
return SHIM_SO
except Exception as e:
logger.warning(f"[GlobalMenu] Shim compilation error: {e}")
return None
def setup_global_menu_environment():
"""Write env config files so GTK apps register with AppMenu at next login.
Does NOT set env via hyprctl keyword env — that would cause Fabric's own
GTK windows to load appmenu-gtk-module and trigger Gdk-CRITICAL assertions.
Uses session-level config files that take effect at login.
"""
try:
shim_path = get_compiled_shim()
# Remove any legacy global LD_PRELOAD export from older installs.
_remove_global_ld_preload()
# NOTE: The menu-button shim is compiled against GTK3 and must NOT be
# exported globally. Injecting it into a GTK4 process aborts the app
# ("GTK 2/3 symbols detected"). It is injected per-launch into GTK3
# apps only — see globalmenu.launch.
if shim_path:
logger.debug(f"[GlobalMenu] GTK3 shim ready at {shim_path}")
exec_shell_command(
"dbus-update-activation-environment --systemd "
"GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1"
)
exec_shell_command(
"flatpak override --user --talk-name=com.canonical.AppMenu.Registrar"
)
env_dir = Path.home() / ".config" / "environment.d"
try:
env_dir.mkdir(parents=True, exist_ok=True)
env_path = env_dir / "appmenu.conf"
with open(env_path, "w") as f:
for k, v in ENV_VARS.items():
f.write(f"{k}={v}\n")
except OSError as e:
logger.warning(
f"[environment] env_dir.mkdir(parents=True, exist_ok=True) failed: {e}"
)
settings_path = Path.home() / ".config" / "gtk-3.0" / "settings.ini"
try:
settings_path.parent.mkdir(parents=True, exist_ok=True)
content = ""
if settings_path.exists():
content = settings_path.read_text()
if "gtk-shell-shows-menubar" not in content:
with open(settings_path, "a") as f:
if content and not content.endswith("\n"):
f.write("\n")
f.write("gtk-shell-shows-menubar=1\n")
except OSError as e:
logger.warning(
f"[environment] settings_path.parent.mkdir(parents=True, exist_ok=True) failed: {e}"
)
pam_path = Path.home() / ".pam_environment"
try:
existing = ""
if pam_path.exists():
existing = pam_path.read_text()
lines = existing.splitlines(True)
for k, v in ENV_VARS.items():
line = f"{k} DEFAULT={v}\n"
if not any(ln.startswith(f"{k} ") for ln in lines):
with open(pam_path, "a") as f:
f.write(line)
except OSError as e:
logger.warning(f"[environment] existing = '' failed: {e}")
logger.info("[GlobalMenu] GTK environment injected")
except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+760
View File
@@ -0,0 +1,760 @@
from typing import Any, Dict, List, Optional
from fabric.utils import Gio, GLib, logger
from window.globalmenu.dbusmenu import DBusMenuItem, _get_bus
def atspi_activate_menu_item(pid: int, label: str) -> bool:
"""Activate a menu item by label via AT-SPI accessibility interface.
Used as a fallback when D-Bus org.gtk.Actions.Activate doesn't reach the
app's real menu item handler (common with appmenu-gtk-module on Wayland
where gdk_threads_add_idle_full dispatch may not fire).
"""
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except (ImportError, ValueError) as e:
logger.warning(f"[gtkmenu] import gi failed: {e}")
return False
try:
desktop = Atspi.get_desktop(0)
for i in range(desktop.get_child_count()):
app = desktop.get_child_at_index(i)
if app is None:
continue
try:
app_pid = app.get_process_id()
except Exception as e:
logger.warning(f"[gtkmenu] app_pid = app.get_process_id() failed: {e}")
continue
if app_pid != pid:
continue
def _find_menubar(obj, depth=0):
if depth > 8:
return None
try:
if obj.get_role_name() == "menu bar":
return obj
for j in range(obj.get_child_count()):
result = _find_menubar(obj.get_child_at_index(j), depth + 1)
if result:
return result
except Exception as e:
logger.warning(
f"[gtkmenu] if obj.get_role_name() == 'menu bar': return obj failed: {e}"
)
return None
def _find_frame(obj, depth=0):
if depth > 4:
return None
try:
if obj.get_role_name() == "frame":
return obj
for j in range(obj.get_child_count()):
result = _find_frame(obj.get_child_at_index(j), depth + 1)
if result:
return result
except Exception as e:
logger.warning(
f"[gtkmenu] if obj.get_role_name() == 'frame': return obj failed: {e}"
)
return None
frame = _find_frame(app)
if not frame:
continue
menubar = _find_menubar(frame)
if not menubar:
continue
label_lower = label.lower().strip()
for j in range(menubar.get_child_count()):
item = menubar.get_child_at_index(j)
if item is None:
continue
try:
item_name = (item.get_name() or "").lower().strip()
except Exception as e:
logger.warning(
f"[gtkmenu] item_name = (item.get_name() or '').lower().strip() failed: {e}"
)
continue
if item_name == label_lower or label_lower in item_name:
try:
n_actions = item.get_n_actions()
if n_actions > 0:
result = item.do_action(0)
if result:
logger.debug(
f"[ATSPI] Activated menu item '{label}' "
f"via do_action on pid {pid}"
)
return True
except Exception as e:
logger.debug(f"[ATSPI] do_action failed: {e}")
return False
break
except Exception as e:
logger.debug(f"[ATSPI] activate_menu_item error: {e}")
return False
class _ActionInfo:
__slots__ = ("enabled", "parameter_type", "state", "state_type")
def __init__(
self,
enabled: bool = True,
parameter_type: str = "",
state: Optional[Any] = None,
state_type: str = "",
):
self.enabled = enabled
self.parameter_type = parameter_type
self.state = state
self.state_type = state_type
class GtkMenuClient:
__slots__ = (
"_action_cache",
"_action_path",
"_cached_items",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._action_path = (
object_path.split("/menus/")[0] if "/menus/" in object_path else object_path
)
self._cached_items: List[DBusMenuItem] = []
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
path or self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.debug(f"[GtkMenuClient] {method} failed: {e}")
return None
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
self._refresh_action_cache()
if force_refresh or not self._cached_items:
items = self._fetch_menu(parent_id)
self._cached_items = items
return self._cached_items
def _refresh_action_cache(self):
for path in dict.fromkeys([self._action_path, self.object_path]):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
path=path,
)
if not res:
continue
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack()
if state_v and state_v.n_children() > 0
else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
return
except Exception as e:
logger.debug(f"[GtkMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
for path in dict.fromkeys([self._action_path, self.object_path]):
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
path=path,
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack()
if state_v and state_v.n_children() > 0
else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[GtkMenuClient] Describe error for {action_name}: {e}")
return None
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:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
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("_", "")
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
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:
item.action_target = props["target"]
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:
return False
def click_item(
self,
item_id: int,
action_name: str = "",
action_target=None,
pid: int = 0,
) -> None:
item_label = ""
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
item_label = item.label or ""
if not action_name:
return
if action_name.startswith("unity."):
action_name = action_name[6:]
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b" or action_info.parameter_type:
use_change_state = True
try:
for act_path in (self.object_path, self._action_path):
try:
if (
use_change_state
and action_info
and action_info.state is not None
):
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(
action_name,
GLib.Variant(action_info.state_type, new_state),
),
),
path=act_path,
)
return
else:
params_arr = self._build_activate_params(
action_info, action_target
)
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
path=act_path,
)
if pid and item_label:
atspi_activate_menu_item(pid, item_label)
return
except Exception as e:
logger.warning(
f"[gtkmenu] if ( use_change_state and action_info and action_info.sta... failed: {e}"
)
continue
except Exception as e:
logger.error(f"[GtkMenuClient] click failed: {e}")
if not use_change_state and pid and item_label:
atspi_activate_menu_item(pid, item_label)
@staticmethod
def _build_activate_params(action_info, action_target):
"""Build the parameters array for Activate call."""
if action_target is not None:
try:
return GLib.Variant("av", [action_target])
except Exception as e:
logger.warning(
f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}"
)
if action_info and action_info.parameter_type:
param_type_str = action_info.parameter_type
if param_type_str and param_type_str != "()":
try:
param_type = GLib.VariantType(param_type_str)
default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None))
return GLib.Variant("av", [default_val])
except Exception as e:
logger.warning(
f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}"
)
return GLib.Variant("av", [])
class ActionMenuClient:
__slots__ = (
"_action_cache",
"_action_map",
"_action_path",
"_cached_items",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._action_path = object_path
self._cached_items: List[DBusMenuItem] = []
self._action_map: Dict[int, str] = {}
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
path or self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.warning(
f"[gtkmenu] return _get_bus().call_sync( self.service_name, path or s... failed: {e}"
)
return None
def _refresh_action_cache(self):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
except Exception as e:
logger.debug(f"[ActionMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[ActionMenuClient] Describe error for {action_name}: {e}")
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:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
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("_", "")
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
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:
item.action_target = props["target"]
items.append(item)
if data:
resolve(data[0][2])
return items
except Exception as e:
logger.error(f"[ActionMenuClient] Parse error: {e}")
return []
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
if parent_id != 0:
return []
self._refresh_action_cache()
menu_items = self._fetch_menu(0)
if menu_items:
self._cached_items = menu_items
return menu_items
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return self._cached_items
try:
data = res.get_child_value(0).unpack()
items: List[DBusMenuItem] = []
self._action_map = {}
for i, (name, (enabled, param_type_v, state_v)) in enumerate(data.items()):
label = name.replace("-", " ").replace("_", " ").strip().title()
param_type = str(param_type_v) if param_type_v else ""
state = None
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
try:
state = st.get_value()
except Exception:
state = state_v.unpack() if state_v else None
self._action_cache[name] = _ActionInfo(
enabled, param_type, state, state_type
)
item = DBusMenuItem(id=hash((0, i)))
item.action_name = name
item.visible = not bool(param_type) or param_type == "b"
can_activate = not bool(param_type)
item.enabled = enabled if can_activate else False
item.label = label
if param_type == "b":
item.type = "toggle"
item.enabled = enabled
self._action_map[item.id] = name
items.append(item)
self._cached_items = items
return items
except Exception as e:
logger.error(f"[ActionMenuClient] parse error: {e}")
return self._cached_items
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 about_to_show(self, item_id: int) -> bool:
return False
def invalidate(self):
self._cached_items = []
self._action_map = {}
self._action_cache.clear()
def click_item(
self,
item_id: int,
action_name: str = "",
action_target=None,
pid: int = 0,
) -> None:
item_label = ""
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
item_label = item.label or ""
if not action_name:
return
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b" or action_info.parameter_type:
use_change_state = True
try:
if use_change_state and action_info and action_info.state is not None:
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(action_name, GLib.Variant(action_info.state_type, new_state)),
),
)
return
else:
params_arr = self._build_activate_params(action_info, action_target)
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
)
if pid and item_label:
atspi_activate_menu_item(pid, item_label)
return
except Exception as e:
logger.error(f"[ActionMenuClient] click failed: {e}")
if not use_change_state and pid and item_label:
atspi_activate_menu_item(pid, item_label)
@staticmethod
def _build_activate_params(action_info, action_target):
"""Build the parameters array for Activate call."""
if action_target is not None:
try:
return GLib.Variant("av", [action_target])
except Exception as e:
logger.warning(
f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}"
)
if action_info and action_info.parameter_type:
param_type_str = action_info.parameter_type
if param_type_str and param_type_str != "()":
try:
param_type = GLib.VariantType(param_type_str)
default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None))
return GLib.Variant("av", [default_val])
except Exception as e:
logger.warning(
f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}"
)
return GLib.Variant("av", [])
+84
View File
@@ -0,0 +1,84 @@
"""GTK3-aware application launching for the global menu compatibility shim.
The legacy LD_PRELOAD approach exported ``libmenu_button_shim.so`` globally.
That shim links against GTK3, so loading it into a GTK4 process crashes the
app. Instead we detect the toolkit of the target executable and inject the
shim only into GTK3 processes, leaving GTK4 (and other) apps untouched.
Two launch paths are supported:
* :func:`launch_command` — for raw command lines (used by the dock launcher
and forwarded through ``uwsm app``). The shim is prepended via ``env`` so it
is set only for the spawned process.
* :func:`launch_desktop_app` — for :class:`fabric.utils.DesktopApp` objects,
which launch through GIO and inherit the current process environment. We
temporarily set ``LD_PRELOAD`` around the launch call and restore it
immediately afterwards.
"""
import os
import re
from fabric.utils import exec_shell_command_async, logger
from window.globalmenu.detection import executable_gtk_class
from window.globalmenu.environment import get_compiled_shim
_PERCENT_RE = re.compile(r"%\w+")
def _gtk3_shim() -> str | None:
"""Return the shim path if it is compiled, else ``None``."""
shim = get_compiled_shim()
return str(shim) if shim else None
def shim_env_for_executable(executable: str | None) -> dict[str, str] | None:
"""Return ``{"LD_PRELOAD": <shim>}`` for a GTK3 executable, else ``None``."""
if executable_gtk_class(executable) != "gtk3":
return None
shim = _gtk3_shim()
if not shim:
return None
return {"LD_PRELOAD": shim}
def launch_command(command_line: str) -> None:
"""Launch a command through ``uwsm app``, injecting the GTK3 shim if needed."""
if not command_line:
return
cleaned = _PERCENT_RE.sub("", command_line).strip()
if not cleaned:
return
shim_env = shim_env_for_executable(cleaned)
if shim_env:
# Scope the shim to the launched process only.
cleaned = f"env LD_PRELOAD={shim_env['LD_PRELOAD']} {cleaned}"
logger.debug(f"[GlobalMenu] Injecting GTK3 shim for: {cleaned}")
exec_shell_command_async(
f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'"
)
def launch_desktop_app(app) -> None:
"""Launch a :class:`fabric.utils.DesktopApp`, injecting the GTK3 shim if needed."""
shim_env = shim_env_for_executable(getattr(app, "executable", None))
if shim_env:
prev = os.environ.get("LD_PRELOAD")
os.environ["LD_PRELOAD"] = shim_env["LD_PRELOAD"]
logger.debug(
f"[GlobalMenu] Injecting GTK3 shim for desktop app: "
f"{getattr(app, 'display_name', app)}"
)
try:
app.launch()
finally:
if prev is None:
os.environ.pop("LD_PRELOAD", None)
else:
os.environ["LD_PRELOAD"] = prev
else:
app.launch()
@@ -0,0 +1,51 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <gtk/gtk.h>
typedef void (*orig_gtk_menu_item_activate)(GtkMenuItem *menu_item);
static int in_hook = 0;
typedef struct {
GtkWidget *widget;
} WidgetRef;
static gboolean do_emit(gpointer data) {
WidgetRef *ref = data;
GtkWidget *w = ref->widget;
g_free(ref);
if (!GTK_IS_WIDGET(w))
return G_SOURCE_REMOVE;
GdkEvent event;
memset(&event, 0, sizeof(event));
event.type = GDK_BUTTON_RELEASE;
event.any.window = gtk_widget_get_window(w);
event.any.send_event = TRUE;
gboolean handled = FALSE;
g_signal_emit_by_name(w, "button-release-event", &event, &handled);
return G_SOURCE_REMOVE;
}
void gtk_menu_item_activate(GtkMenuItem *menu_item) {
if (in_hook) {
orig_gtk_menu_item_activate orig =
(orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate");
orig(menu_item);
return;
}
in_hook = 1;
orig_gtk_menu_item_activate orig =
(orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate");
orig(menu_item);
in_hook = 0;
/* After real activate, schedule synthetic button-release-event so apps
that connect to button-release-event instead of activate respond. */
WidgetRef *ref = g_new(WidgetRef, 1);
ref->widget = GTK_WIDGET(menu_item);
g_timeout_add(50, do_emit, ref);
}
File diff suppressed because it is too large. Load diff