diff --git a/src/globalmenu/__init__.py b/src/globalmenu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/globalmenu/dbusmenu.py b/src/globalmenu/dbusmenu.py new file mode 100644 index 0000000..135c247 --- /dev/null +++ b/src/globalmenu/dbusmenu.py @@ -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 diff --git a/src/globalmenu/environment.py b/src/globalmenu/environment.py new file mode 100644 index 0000000..dc42275 --- /dev/null +++ b/src/globalmenu/environment.py @@ -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}") diff --git a/src/globalmenu/gtkmenu.py b/src/globalmenu/gtkmenu.py new file mode 100644 index 0000000..34cd555 --- /dev/null +++ b/src/globalmenu/gtkmenu.py @@ -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", []) diff --git a/src/globalmenu/libmenu_button_shim.c b/src/globalmenu/libmenu_button_shim.c new file mode 100644 index 0000000..2961032 --- /dev/null +++ b/src/globalmenu/libmenu_button_shim.c @@ -0,0 +1,51 @@ +#define _GNU_SOURCE +#include +#include + +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); +} diff --git a/src/services/globalmenu.py b/src/globalmenu/service.py similarity index 98% rename from src/services/globalmenu.py rename to src/globalmenu/service.py index c53ce6e..ea527df 100644 --- a/src/services/globalmenu.py +++ b/src/globalmenu/service.py @@ -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 diff --git a/src/main.py b/src/main.py index a96c3fc..2246392 100644 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,7 @@ for log in [ "services", "window", "utils", + "globalmenu", ]: logger.disable(log) diff --git a/src/services/custom_notification.py b/src/services/custom_notification.py index 9a58891..020a469 100644 --- a/src/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -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 diff --git a/src/services/environment.py b/src/services/environment.py deleted file mode 100644 index b130b89..0000000 --- a/src/services/environment.py +++ /dev/null @@ -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}") diff --git a/src/services/modus.py b/src/services/modus.py index 483ba89..6ae5981 100644 --- a/src/services/modus.py +++ b/src/services/modus.py @@ -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):