feat(notifications): increase app icon size to 64x64 and refactor service retrieval
This commit is contained in:
10 files changed
+1360
-81
No files matched your search
Whitespace-only changes.
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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", [])
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ for log in [
|
||||
"services",
|
||||
"window",
|
||||
"utils",
|
||||
"globalmenu",
|
||||
]:
|
||||
logger.disable(log)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user