feat(notifications): increase app icon size to 64x64 and refactor service retrieval

This commit is contained in:
sankalp committed 2026-07-12 02:44:15 +05:45
1 parent aee72d98fa
commit 40b8962232
10 files changed
+1360 -81

No files matched your search

View File
Whitespace-only changes.
+395
View File
@@ -0,0 +1,395 @@
"""
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__ = (
"id",
"label",
"enabled",
"visible",
"type",
"shortcut",
"icon_name",
"has_submenu",
"children",
"action_name",
"action_target",
"parent_id",
)
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__ = (
"service_name",
"object_path",
"_cache",
"_cache_valid",
"_hash_cache",
"_lock",
"_fetching",
"_revision",
"_signal_ids",
"_on_layout_updated_cb",
"_on_items_updated_cb",
"_on_item_activated_cb",
)
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:
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:
pass
except Exception:
pass
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:
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:
pass
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:
pass
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
+156
View File
@@ -0,0 +1,156 @@
"""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",
}
_SHIM_BEGIN = "# >>> modus global-menu shim >>>"
_SHIM_END = "# <<< modus global-menu shim <<<"
def _compile_shim() -> Path | None:
"""Compile the 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 _write_shell_rc(shim_path: Path | None):
"""Append LD_PRELOAD export to the user's shell rc file."""
if not shim_path:
return
shell = os.environ.get("SHELL", "")
home = Path.home()
rc_map = {
"zsh": home / ".zshrc",
"bash": home / ".bashrc",
"fish": home / ".config" / "fish" / "config.fish",
}
rc_path = rc_map.get(Path(shell).name)
if rc_path is None:
rc_path = home / ".profile"
try:
if not rc_path.exists():
return
existing = rc_path.read_text()
if _SHIM_BEGIN in existing:
return
is_fish = rc_path.name == "config.fish"
with open(rc_path, "a") as f:
f.write(f"\n{_SHIM_BEGIN}\n")
if is_fish:
f.write(f"set -gx LD_PRELOAD {shim_path}\n")
else:
f.write(f"export LD_PRELOAD={shim_path}\n")
f.write(f"{_SHIM_END}\n")
except OSError:
pass
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 = _compile_shim()
exec_shell_command(
"dbus-update-activation-environment --systemd "
"GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1"
)
if shim_path:
exec_shell_command(
f"dbus-update-activation-environment --systemd LD_PRELOAD={shim_path}"
)
exec_shell_command("systemctl --user import-environment LD_PRELOAD")
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")
if shim_path:
f.write(f"LD_PRELOAD={shim_path}\n")
except OSError:
pass
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:
pass
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:
pass
_write_shell_rc(shim_path)
logger.info("[GlobalMenu] GTK environment injected")
except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+741
View File
@@ -0,0 +1,741 @@
from typing import Any, Dict, List, Optional
from fabric.utils import Gio, GLib, logger
from 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):
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:
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:
pass
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:
pass
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:
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__ = (
"service_name",
"object_path",
"_action_path",
"_cached_items",
"_action_cache",
)
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":
use_change_state = True
elif 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:
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:
pass
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:
pass
return GLib.Variant("av", [])
class ActionMenuClient:
__slots__ = (
"service_name",
"object_path",
"_action_path",
"_cached_items",
"_action_map",
"_action_cache",
)
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:
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":
use_change_state = True
elif 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:
pass
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:
pass
return GLib.Variant("av", [])
+51
View File
@@ -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);
}
@@ -16,9 +16,9 @@ from fabric.utils import (
os,
)
from services.environment import setup_global_menu_environment
from utils.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
from utils.gtkmenu import ActionMenuClient, GtkMenuClient
from globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
from globalmenu.environment import setup_global_menu_environment
from globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient
_DBUS_TIMEOUT_INTROSPECT = 500
_DBUS_TIMEOUT_PID = 200
@@ -353,7 +353,12 @@ class GlobalMenuService(Service):
)
elif method_name == "GetMenus":
invocation.return_value(GLib.Variant("(a(uso))", ([],)))
with self._registry_lock:
menus = [
(0, svc, path)
for sender, (svc, path) in self._registered_menus.items()
]
invocation.return_value(GLib.Variant("(a(uso))", (menus,)))
except Exception as e:
logger.error(f"[GlobalMenuService] Registrar error {method_name}: {e}")
invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e))
@@ -1042,7 +1047,7 @@ class GlobalMenuService(Service):
svc = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc) if svc else None
if entry and entry.importer:
entry.importer.click_item(item_id)
entry.importer.click_item(item_id, pid=target_pid)
return True
return False
+1
View File
@@ -9,6 +9,7 @@ for log in [
"services",
"window",
"utils",
"globalmenu",
]:
logger.disable(log)
+3 -3
View File
@@ -115,7 +115,7 @@ class CachedNotification(Service):
notification_image_cache_key = None
if self.app_icon:
app_icon_cache_key = get_cache_key(self.app_icon, (35, 35), self.app_name)
app_icon_cache_key = get_cache_key(self.app_icon, (64, 64), self.app_name)
# Only try to get notification image cache key if we can safely access image_pixbuf
if self.id:
@@ -468,10 +468,10 @@ class CachedNotifications(Notifications):
try:
# Only cache at 35x35 to reduce disk usage - headers will scale this down
app_icon_cache_key = get_cache_key(
notification.app_icon, (35, 35), notification.app_name
notification.app_icon, (64, 64), notification.app_name
)
cache_notification_icon(
notification.app_icon, (35, 35), notification.app_name
notification.app_icon, (64, 64), notification.app_name
)
cached_notification.cache_metadata["app_icon_cache_key"] = (
app_icon_cache_key
-72
View File
@@ -1,72 +0,0 @@
"""Global menu environment setup — GTK_MODULES, UBUNTU_MENUPROXY, settings.ini, Flatpak."""
from fabric.utils import exec_shell_command, logger, os
ENV_VARS = {
"GTK_MODULES": "appmenu-gtk-module",
"UBUNTU_MENUPROXY": "1",
}
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:
# exec_shell_command("hyprctl keyword env GTK_MODULES,appmenu-gtk-module")
# exec_shell_command("hyprctl keyword env UBUNTU_MENUPROXY,1")
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 = os.path.expanduser("~/.config/environment.d")
try:
os.makedirs(env_dir, exist_ok=True)
env_path = os.path.join(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:
pass
settings_path = os.path.expanduser("~/.config/gtk-3.0/settings.ini")
try:
if not os.path.exists(os.path.dirname(settings_path)):
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
content = ""
if os.path.exists(settings_path):
with open(settings_path, "r") as f:
content = f.read()
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:
pass
pam_path = os.path.expanduser("~/.pam_environment")
try:
existing = ""
if os.path.exists(pam_path):
with open(pam_path, "r") as f:
existing = f.read()
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:
pass
logger.info("[GlobalMenu] GTK environment injected")
except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+3 -1
View File
@@ -48,7 +48,9 @@ def __getattr__(name):
def get_notification_service():
return CachedNotifications()
import sys
return getattr(sys.modules[__name__], "notification_service")
class ModusService(Service):