chore: refactor imports and adopt new utils utilities

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

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ from services.bluetooth import BluetoothClient, BluetoothDevice
from shared.widgets.smooth_switch import SmoothSwitch
from shared.window.animated_scrollwindow import AnimatedScrollable
from utils.functions import spawn_detached
from utils.utils import svg_file
from utils.gtk_utils import svg_file
def get_battery_icon_file(
+6 -6
View File
@@ -11,7 +11,7 @@ from fabric.widgets.stack import Stack
from fabric.widgets.wayland import WaylandWindow as Window
from services.mpris import PlayerManager, PlayerService
from utils.utils import svg_file
from utils.gtk_utils import svg_file
from window.controlcenter.player import (
PLAYER_FALLBACK_ART,
apply_player_art,
@@ -416,7 +416,7 @@ class PlayerBox(Box):
initial_status = str(self.player.playback_status).lower()
icon_file = (
"player/play.svg" if initial_status == "paused" else "player/Pause.svg"
"player/play.svg" if initial_status == "paused" else "player/pause.svg"
)
self.play_pause_icon = svg_file(icon_file, size=22)
self.play_pause_button = Button(
@@ -433,7 +433,7 @@ class PlayerBox(Box):
)
self.prev_button = Button(
name="macos-control-button",
child=svg_file("player/Rewind.svg", size=22),
child=svg_file("player/rewind.svg", size=22),
style_classes=["control-buttons"],
on_clicked=self._on_player_prev,
)
@@ -638,7 +638,7 @@ class PlayerBox(Box):
try:
status = str(self.player.playback_status).lower()
self.play_pause_icon.dynamic_file(
"player/play.svg" if status == "paused" else "player/Pause.svg"
"player/play.svg" if status == "paused" else "player/pause.svg"
)
except Exception as e:
logger.error(f"An error occurred: {e}")
@@ -651,7 +651,7 @@ class PlayerBox(Box):
self._last_notified_status = current_status
icon_file = (
"player/play.svg" if current_status == "paused" else "player/Pause.svg"
"player/play.svg" if current_status == "paused" else "player/pause.svg"
)
if hasattr(self, "play_pause_icon"):
self.play_pause_icon.dynamic_file(icon_file)
@@ -659,7 +659,7 @@ class PlayerBox(Box):
# Fallback if play_pause_icon isn't available
import os
from utils.utils import get_relative_path
from utils.gtk_utils import get_relative_path
self.play_pause_button.get_child().set_from_file(
os.path.join(get_relative_path("assets/icons/"), icon_file)
+1 -1
View File
@@ -11,8 +11,8 @@ from services.inhibit import get_inhibit_service
from services.network import NetworkClient
from shared.widgets.flat_scale import FlatScale
from shared.window.applet_window import AppletWindow
from utils.gtk_utils import svg_file
from utils.roam import audio_service, modus_service
from utils.utils import svg_file
from window.controlcenter.bluetooth import (
BluetoothConnections,
set_bluetooth_enabled_with_fallback,
+1 -1
View File
@@ -6,7 +6,7 @@ from services.modus import (
is_night_light_active,
toggle_night_light,
)
from utils.utils import svg_file
from utils.gtk_utils import svg_file
def create_night_light_widget(control_center):
+3 -6
View File
@@ -193,12 +193,9 @@ class PerAppVolumeControl(Box):
def _populate_apps(self):
"""Populate the widget with current audio applications"""
# Clear and DESTROY existing widgets to prevent memory leaks
for child in list(self.apps_container.get_children()):
try:
child.destroy()
except Exception as e:
logger.error(f"An error occurred: {e}")
from utils.functions import clear_children
clear_children(self.apps_container)
self.apps_container.children = []
self._app_widgets.clear()
+4 -4
View File
@@ -9,7 +9,7 @@ from fabric.widgets.stack import Stack
import shared.data as data
from services.mpris import PlayerManager, PlayerService
from utils.utils import svg_file
from utils.gtk_utils import svg_file
CACHE_DIR = f"{data.CACHE_DIR}/media"
PLAYER_FALLBACK_ART = get_relative_path("../../assets/icons/music.svg")
@@ -357,7 +357,7 @@ class PlayerBox(Box):
initial_status = str(self.player.playback_status).lower()
icon_file = (
"player/play.svg" if initial_status == "paused" else "player/Pause.svg"
"player/play.svg" if initial_status == "paused" else "player/pause.svg"
)
self.play_pause_icon = svg_file(icon_file, size=22)
self.play_pause_button = Button(
@@ -538,7 +538,7 @@ class PlayerBox(Box):
try:
status = str(self.player.playback_status).lower()
self.play_pause_icon.dynamic_file(
"player/play.svg" if status == "paused" else "player/Pause.svg"
"player/play.svg" if status == "paused" else "player/pause.svg"
)
except Exception as e:
logger.error(f"An error occurred: {e}")
@@ -553,7 +553,7 @@ class PlayerBox(Box):
self._last_notified_status = current_status
self.play_pause_icon.dynamic_file(
"player/play.svg" if current_status == "paused" else "player/Pause.svg"
"player/play.svg" if current_status == "paused" else "player/pause.svg"
)
if self.player_stack:
self.player_stack.on_player_playback_changed(self, current_status)
+1 -1
View File
@@ -15,7 +15,7 @@ from utils.functions import (
get_wifi_icon_for_strength,
spawn_detached,
)
from utils.utils import svg_file
from utils.gtk_utils import svg_file
class WifiNetworkSlot(Box):
+4 -4
View File
@@ -16,7 +16,7 @@ from fabric.widgets.overlay import Overlay
from fabric.widgets.wayland import WaylandWindow as Window
from shared.data import load_config
from utils.utils import svg_file
from utils.gtk_utils import svg_file
from window.desktop.constants import (
CALENDAR_UPDATE_INTERVAL,
LOCATION_APIS,
@@ -465,9 +465,9 @@ class Calendar(Box):
return True
def update_calendar(self):
for child in self.calendar_grid.get_children():
self.calendar_grid.remove(child)
child.destroy()
from utils.functions import clear_children
clear_children(self.calendar_grid)
self.month_label.set_label(calendar.month_name[self.current_month])
cal = calendar.monthcalendar(self.current_year, self.current_month)
for week in cal:
+4 -6
View File
@@ -23,9 +23,9 @@ from services.modus import (
launch_app,
open_trash,
)
from utils.functions import is_special_workspace_id
from utils.functions import clear_children, is_special_workspace_id
from utils.gtk_utils import svg_file
from utils.icon_resolver import IconResolver
from utils.utils import svg_file
from .animator import DockAnimator
from .constants import (
@@ -779,9 +779,7 @@ class DockCanvas(Gtk.DrawingArea):
client=None,
instance_address: Optional[str] = None,
) -> None:
for child in self.menu.get_children():
self.menu.remove(child)
child.destroy()
clear_children(self.menu)
if instance_address:
close_item = Gtk.MenuItem(label="Close")
@@ -929,7 +927,7 @@ class DockCanvas(Gtk.DrawingArea):
if command_line:
launch_app(command_line)
elif hasattr(app_info, "launch"):
from globalmenu.launch import launch_desktop_app
from window.globalmenu.launch import launch_desktop_app
launch_desktop_app(app_info)
else:
+1 -1
View File
@@ -1,4 +1,4 @@
from utils.utils import toml_file
from utils.gtk_utils import toml_file
PINNED_APPS_FILE = toml_file("dock.toml")
View File
Whitespace-only changes.
+407
View File
@@ -0,0 +1,407 @@
"""
Universal DBus + GTK Global Menu Client
Supports:
- com.canonical.dbusmenu (KDE / Unity / XFCE)
- GTK appmenu-module DBusMenu
- GTK GMenuModel fallback (best-effort)
- Incremental updates via DBusMenu signals
"""
import threading
from typing import Callable, Dict, List, Optional, Tuple
from fabric.utils import Any, Gio, GLib, logger
# SHARED BUS — one connection for the entire process
_SESSION_BUS: Optional[Gio.DBusConnection] = None
_BUS_LOCK = threading.Lock()
def _get_bus() -> Gio.DBusConnection:
global _SESSION_BUS
if _SESSION_BUS is None:
with _BUS_LOCK:
if _SESSION_BUS is None:
_SESSION_BUS = Gio.bus_get_sync(Gio.BusType.SESSION, None)
return _SESSION_BUS
class DBusMenuItem:
__slots__ = (
"action_name",
"action_target",
"children",
"enabled",
"has_submenu",
"icon_name",
"id",
"label",
"parent_id",
"shortcut",
"type",
"visible",
)
def __init__(
self,
id: int,
label: str = "",
enabled: bool = True,
visible: bool = True,
type: str = "standard",
shortcut: str = "",
icon_name: str = "",
has_submenu: bool = False,
children: Optional[List["DBusMenuItem"]] = None,
action_name: str = "",
action_target: Optional[Any] = None,
parent_id: int = -1,
):
self.id = id
self.label = label
self.enabled = enabled
self.visible = visible
self.type = type
self.shortcut = shortcut
self.icon_name = icon_name
self.has_submenu = has_submenu
self.children: List[DBusMenuItem] = children if children is not None else []
self.action_name = action_name
self.action_target = action_target
self.parent_id = parent_id
class DBusMenuClient:
__slots__ = (
"_cache",
"_cache_valid",
"_fetching",
"_hash_cache",
"_lock",
"_on_item_activated_cb",
"_on_items_updated_cb",
"_on_layout_updated_cb",
"_revision",
"_signal_ids",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._cache: Optional[List[DBusMenuItem]] = None
self._cache_valid = False
self._hash_cache: Optional[str] = None
self._revision: int = 0
self._lock = threading.Lock()
self._fetching = False
self._signal_ids: List[int] = []
self._on_layout_updated_cb: Optional[Callable] = None
self._on_items_updated_cb: Optional[Callable] = None
self._on_item_activated_cb: Optional[Callable] = None
def _call(self, method, params, reply_type=None):
try:
return _get_bus().call_sync(
self.service_name,
self.object_path,
"com.canonical.dbusmenu",
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.warning(
f"[dbusmenu] return _get_bus().call_sync( self.service_name, self.obje... failed: {e}"
)
return None
def get_layout(self, force_refresh=False):
if self._cache and self._cache_valid and not force_refresh:
return self._cache
with self._lock:
if self._fetching:
return self._cache or []
self._fetching = True
try:
return self._fetch()
finally:
with self._lock:
self._fetching = False
def _fetch(self):
params = GLib.Variant("(iias)", (0, -1, []))
res = self._call("GetLayout", params)
if not res:
return []
try:
revision = res.get_child_value(0).get_uint32()
self._revision = revision
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
layout = layout.get_variant()
parsed = self._parse_dbusmenu(layout).children
h = self._hash(parsed)
if h == self._hash_cache:
return self._cache or parsed
self._hash_cache = h
self._cache = parsed
self._cache_valid = True
return parsed
except Exception as e:
logger.error(f"[Menu] parse error: {e}")
return []
def _hash(self, items):
flat: List[str] = []
def walk(nodes):
for n in nodes:
flat.append(f"{n.id}:{n.label}:{n.enabled}")
walk(n.children)
walk(items)
return str(hash("".join(flat)))
def _parse_dbusmenu(self, node, _depth: int = 0, _parent_id: int = -1):
if _depth > 20:
return DBusMenuItem(id=0)
item = DBusMenuItem(id=0)
try:
if node.n_children() != 3:
return item
item.id = node.get_child_value(0).get_int32()
item.parent_id = _parent_id
try:
props = node.get_child_value(1).unpack()
except Exception:
props = {}
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
item.type = props.get("type", "standard")
item.icon_name = props.get("icon-name", "")
if props.get("children-display") == "submenu":
item.has_submenu = True
sc = props.get("shortcut")
if sc:
try:
parts = []
if isinstance(sc, list):
flat = sc[0] if isinstance(sc[0], list) else sc
mapping = {
"Control": "Ctrl",
"Shift": "Shift",
"Alt": "Alt",
"Meta": "Super",
}
for s in flat:
parts.append(mapping.get(s, str(s).upper()))
item.shortcut = "+".join(parts)
except Exception:
item.shortcut = ""
children = node.get_child_value(2)
for i in range(children.n_children()):
if _depth >= 20:
break
c = children.get_child_value(i)
if c.is_of_type(GLib.VariantType("v")):
c = c.get_variant()
item.children.append(self._parse_dbusmenu(c, _depth + 1, item.id))
if item.has_submenu and not item.children:
try:
self.about_to_show(item.id)
except Exception as e:
logger.warning(
f"[dbusmenu] self.about_to_show(item.id) failed: {e}"
)
except Exception as e:
logger.warning(
f"[dbusmenu] if node.n_children() != 3: return item failed: {e}"
)
return item
def about_to_show(self, item_id):
try:
res = self._call(
"AboutToShow",
GLib.Variant("(i)", (item_id,)),
GLib.VariantType("(b)"),
)
return res.get_child_value(0).get_boolean() if res else False
except Exception as e:
logger.warning(
f"[dbusmenu] res = self._call( 'AboutToShow', GLib.Variant('(i)', (ite... failed: {e}"
)
return False
def click_item(self, item_id, pid=0):
try:
self._call(
"Event",
GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("s", ""), 0)),
)
self.invalidate()
except Exception as e:
logger.warning(
f"[dbusmenu] self._call( 'Event', GLib.Variant('(isvu)', (item_id, 'cl... failed: {e}"
)
def invalidate(self):
self._cache_valid = False
def prefetch_async(self):
if self._fetching:
return
threading.Thread(
target=self.get_layout, kwargs={"force_refresh": True}, daemon=True
).start()
def connect_signals(
self,
on_layout_updated: Optional[Callable] = None,
on_items_updated: Optional[Callable] = None,
on_item_activated: Optional[Callable] = None,
):
self._on_layout_updated_cb = on_layout_updated
self._on_items_updated_cb = on_items_updated
self._on_item_activated_cb = on_item_activated
bus = _get_bus()
def handle_layout_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
parent_id, revision, properties = params.unpack()
self._revision = revision
if self._on_layout_updated_cb:
self._on_layout_updated_cb(parent_id, revision, properties)
except Exception as e:
logger.debug(f"[DBusMenuClient] LayoutUpdated handler error: {e}")
def handle_items_properties_updated(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
updated_props, removed_ids = params.unpack()
if self._on_items_updated_cb:
self._on_items_updated_cb(updated_props, removed_ids)
self._cache_valid = False
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemsPropertiesUpdated error: {e}")
def handle_item_activation_requested(
_connection, _sender, _path, _iface, _signal, params, _user_data=None
):
try:
item_id, timestamp = params.unpack()
if self._on_item_activated_cb:
self._on_item_activated_cb(item_id, timestamp)
except Exception as e:
logger.debug(f"[DBusMenuClient] ItemActivationRequested error: {e}")
for sig_name, handler in [
("LayoutUpdated", handle_layout_updated),
("ItemsPropertiesUpdated", handle_items_properties_updated),
("ItemActivationRequested", handle_item_activation_requested),
]:
sid = bus.signal_subscribe(
self.service_name,
"com.canonical.dbusmenu",
sig_name,
self.object_path,
None,
Gio.DBusCallFlags.NONE,
handler,
None,
)
if sid:
self._signal_ids.append(sid)
def disconnect_signals(self):
if not self._signal_ids:
return
bus = _get_bus()
for sid in self._signal_ids:
try:
bus.signal_unsubscribe(sid)
except Exception as e:
logger.warning(f"[dbusmenu] bus.signal_unsubscribe(sid) failed: {e}")
self._signal_ids.clear()
def update_layout(
self, parent_id: int, revision: int
) -> Optional[List[DBusMenuItem]]:
self._revision = revision
params = GLib.Variant("(iias)", (parent_id, revision, []))
res = self._call("GetLayout", params)
if not res:
return None
try:
layout = res.get_child_value(1)
while layout.is_of_type(GLib.VariantType("v")):
layout = layout.get_variant()
parsed = self._parse_dbusmenu(layout).children
if parent_id == 0:
self._hash_cache = self._hash(parsed)
self._cache = parsed
self._cache_valid = True
return parsed
except Exception as e:
logger.error(f"[Menu] update_layout error: {e}")
return None
def apply_items_update(
self, updated_props: List[Tuple[int, Dict]], removed_ids: List[int]
):
if not self._cache:
return
removed_set = set(removed_ids)
def walk(items):
result = []
for item in items:
if item.id in removed_set:
continue
for uid, props in updated_props:
if item.id == uid:
if "label" in props:
item.label = str(props["label"]).replace("_", "")
if "enabled" in props:
item.enabled = bool(props["enabled"])
if "visible" in props:
item.visible = bool(props["visible"])
if "type" in props:
item.type = str(props["type"])
item.children = walk(item.children)
result.append(item)
return result
self._cache = walk(self._cache)
self._hash_cache = None
+239
View File
@@ -0,0 +1,239 @@
"""Detect whether an executable is built against GTK3 or GTK4.
The global menu compat shim (libmenu_button_shim.so) links against GTK3 and
must only be loaded into GTK3 processes. Loading it into a GTK4 process pulls
GTK 2/3 symbols into the same process and GTK4 aborts with:
GTK-ERROR: "GTK 2/3 symbols detected. Using GTK 2/3 and GTK 4 in the same
process is not supported"
We therefore inspect an executable's ELF ``DT_NEEDED`` entries to learn which
GTK it depends on, and only inject the shim for GTK3 binaries. Results are
cached on disk (keyed by resolved path + mtime) so the lookup is cheap and is
never run on every launch for unchanged binaries.
"""
import json
import os
import shutil
from collections import deque
from pathlib import Path
from fabric.utils import logger
# SONAMEs we care about.
GTK3_SONAME = "libgtk-3.so.0"
GTK4_SONAME = "libgtk-4.so.1"
_CACHE_DIR = Path.home() / ".cache" / "modus"
_CACHE_FILE = _CACHE_DIR / "gtk_class_cache.json"
# Directories searched when resolving a NEEDED SONAME to a file. The binary's
# own directory is always tried first, then these (LD_LIBRARY_PATH wins).
_LIB_DIRS = [
*os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep),
"/usr/lib",
"/usr/lib64",
"/lib",
"/lib64",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/i386-linux-gnu",
]
def _parse_dynamic(path: Path) -> tuple[set[str], list[Path]]:
"""Parse an ELF's dynamic section.
Returns ``(needed_sonames, runpath_dirs)``. ``runpath_dirs`` are the
directories named by DT_RUNPATH/DT_RPATH with ``$ORIGIN`` expanded to the
directory containing ``path``. Private libraries (e.g. gedit's
libgedit-50.so) live there and must be searched to follow the dependency
tree correctly.
"""
try:
with open(path, "rb") as f:
ident = f.read(20)
if len(ident) < 20 or ident[:4] != b"\x7fELF":
return set(), []
is_64 = ident[4] == 2
data = path.read_bytes()
except OSError as e:
logger.warning(
f"[detection] with open(path, 'rb') as f: ident = f.read(20) failed: {e}"
)
return set(), []
try:
if is_64:
e_phoff = int.from_bytes(data[0x20:0x28], "little")
e_phentsize = int.from_bytes(data[0x36:0x38], "little")
e_phnum = int.from_bytes(data[0x38:0x3A], "little")
else:
e_phoff = int.from_bytes(data[0x1C:0x20], "little")
e_phentsize = int.from_bytes(data[0x2A:0x2C], "little")
e_phnum = int.from_bytes(data[0x2C:0x2E], "little")
except (IndexError, ValueError) as e:
logger.warning(
f"[detection] if is_64: e_phoff = int.from_bytes(data[0x20:0x28], 'litt... failed: {e}"
)
return set(), []
PT_DYNAMIC = 2
word_size = 8 if is_64 else 4
def unpack(b: bytes) -> int:
return int.from_bytes(b[:word_size], "little")
dynamic_off = None
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
if unpack(data[base : base + 4]) == PT_DYNAMIC:
dynamic_off = unpack(data[base + word_size : base + word_size * 2])
dyn_strtab = unpack(data[base + word_size * 2 : base + word_size * 3])
break
if dynamic_off is None:
return set(), []
DT_NEEDED = 1
DT_STRTAB = 5
DT_RUNPATH = 0x1D
DT_RPATH = 0x0F
needed_offsets: list[int] = []
strtab_off = dyn_strtab
runpath_str = ""
step = word_size * 2
pos = dynamic_off
while pos + step <= len(data):
d_tag = unpack(data[pos : pos + word_size])
d_val = unpack(data[pos + word_size : pos + step])
if d_tag == 0: # DT_NULL
break
if d_tag == DT_NEEDED:
needed_offsets.append(d_val)
elif d_tag == DT_STRTAB:
strtab_off = d_val
elif d_tag in (DT_RUNPATH, DT_RPATH):
end = data.find(b"\x00", strtab_off + d_val)
if end != -1:
runpath_str = data[strtab_off + d_val : end].decode("utf-8", "replace")
pos += step
needed: set[str] = set()
for off in needed_offsets:
end = data.find(b"\x00", strtab_off + off)
if end == -1:
continue
needed.add(data[strtab_off + off : end].decode("utf-8", "replace"))
origin = path.parent
runpath_dirs: list[Path] = []
for entry in runpath_str.split(":"):
entry = entry.strip()
if not entry:
continue
entry = entry.replace("$ORIGIN", str(origin)).replace("${ORIGIN}", str(origin))
runpath_dirs.append(Path(entry))
return needed, runpath_dirs
def _find_lib(soname: str, search_dirs: list[Path]) -> Path | None:
"""Resolve a NEEDED SONAME to a file within ``search_dirs``."""
for directory in search_dirs:
candidate = directory / soname
if candidate.is_file():
return candidate
return None
def _transitive_needed(root: Path) -> set[str]:
"""Collect every DT_NEEDED SONAME in the dependency tree of ``root``.
Follows NEEDED entries transitively (including private libs reached via
RUNPATH) so that GTK pulled in indirectly is still detected.
"""
all_needed: set[str] = set()
seen_paths: set[Path] = set()
queue: deque[Path] = deque([root])
while queue:
lib = queue.popleft()
if lib in seen_paths:
continue
seen_paths.add(lib)
needed, runpath_dirs = _parse_dynamic(lib)
search_dirs = [lib.parent, *runpath_dirs, *[Path(d) for d in _LIB_DIRS if d]]
for soname in needed:
all_needed.add(soname)
if soname in (GTK3_SONAME, GTK4_SONAME):
continue
dep = _find_lib(soname, search_dirs)
if dep and dep not in seen_paths:
queue.append(dep)
return all_needed
def _load_cache() -> dict:
from utils.functions import read_json_file
return read_json_file(str(_CACHE_FILE)) or {}
def _save_cache(cache: dict) -> None:
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_CACHE_FILE.write_text(json.dumps(cache))
except OSError as e:
logger.debug(f"[GlobalMenu] Could not write gtk class cache: {e}")
def executable_gtk_class(executable: str | None) -> str | None:
"""Classify an executable as ``"gtk3"``, ``"gtk4"`` or ``None``.
``None`` means "unable to determine" (e.g. a shell script, a wrapper, or a
non-GTK binary). In that case the caller should NOT inject the GTK3 shim,
which keeps GTK4 applications safe.
"""
if not executable:
return None
token = executable.strip().split()[0] if executable.strip() else ""
if not token:
return None
resolved = shutil.which(token) or token
path = Path(resolved).expanduser()
if not path.is_absolute() or not path.exists():
# Try as a direct path too.
path = Path(token)
if not path.exists():
return None
try:
mtime = path.stat().st_mtime_ns
except OSError as e:
logger.warning(f"[detection] mtime = path.stat().st_mtime_ns failed: {e}")
return None
cache = _load_cache()
key = f"{path}"
cached = cache.get(key)
if cached and cached.get("mtime") == mtime:
return cached.get("class")
needed = _transitive_needed(path)
if GTK4_SONAME in needed:
klass = "gtk4"
elif GTK3_SONAME in needed:
klass = "gtk3"
else:
klass = None
cache[key] = {"mtime": mtime, "class": klass}
_save_cache(cache)
return klass
+193
View File
@@ -0,0 +1,193 @@
"""Global menu environment setup — GTK_MODULES, UBUNTU_MENUPROXY, settings.ini, Flatpak."""
import os
import subprocess
from pathlib import Path
from fabric.utils import exec_shell_command, logger
SHIM_SRC = Path(__file__).resolve().parent / "libmenu_button_shim.c"
SHIM_SO = Path(__file__).resolve().parent / "libmenu_button_shim.so"
ENV_VARS = {
"GTK_MODULES": "appmenu-gtk-module",
"UBUNTU_MENUPROXY": "1",
}
# Marker lines previously written around the global LD_PRELOAD export in shell
# rc files. Kept so we can strip the stale block from already-configured systems.
_SHIM_BEGIN = "# >>> modus global-menu shim >>>"
_SHIM_END = "# <<< modus global-menu shim <<<"
def _remove_global_ld_preload():
"""Strip the legacy global LD_PRELOAD export from previously configured systems.
The shim is now injected per-launch into GTK3 apps only (see
globalmenu.launch). Any globally exported LD_PRELOAD must be removed so it
no longer breaks GTK4 processes.
"""
home = Path.home()
shell = os.environ.get("SHELL", "")
rc_files = [
home / ".zshrc",
home / ".bashrc",
home / ".profile",
home / ".config" / "fish" / "config.fish",
]
rc_path = {
"zsh": home / ".zshrc",
"bash": home / ".bashrc",
"fish": home / ".config" / "fish" / "config.fish",
}.get(Path(shell).name)
if rc_path:
rc_files.append(rc_path)
rc_files = list(dict.fromkeys(rc_files)) # de-dupe, keep order
for rc in rc_files:
try:
if not rc.is_file():
continue
lines = rc.read_text().splitlines(keepends=True)
if not any(_SHIM_BEGIN in ln for ln in lines):
continue
out = []
drop = False
for ln in lines:
if _SHIM_BEGIN in ln:
drop = True
continue
if _SHIM_END in ln:
drop = False
continue
if drop:
continue
out.append(ln)
rc.write_text("".join(out))
logger.info(f"[GlobalMenu] Removed stale LD_PRELOAD block from {rc}")
except OSError as e:
logger.warning(f"[environment] if not rc.is_file(): continue failed: {e}")
# Strip the LD_PRELOAD line from environment.d/appmenu.conf.
env_path = home / ".config" / "environment.d" / "appmenu.conf"
try:
if env_path.is_file():
kept = [
ln
for ln in env_path.read_text().splitlines(keepends=True)
if not ln.strip().startswith("LD_PRELOAD=")
]
env_path.write_text("".join(kept))
except OSError as e:
logger.warning(
f"[environment] if env_path.is_file(): kept = [ ln for ln in env_path.rea... failed: {e}"
)
# Clear it from the activation environment for future launches.
exec_shell_command("dbus-update-activation-environment --systemd LD_PRELOAD=")
def get_compiled_shim() -> Path | None:
"""Compile the GTK3 menu-button shim if needed. Returns path to .so or None."""
try:
if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime:
return SHIM_SO
if not SHIM_SRC.exists():
logger.debug("[GlobalMenu] Shim source not found, skipping")
return None
gtk_cflags = subprocess.check_output(
["pkg-config", "--cflags", "--libs", "gtk+-3.0"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
cmd = f"gcc -shared -fPIC -O2 -o {SHIM_SO} {SHIM_SRC} {gtk_cflags} -ldl"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
logger.warning(f"[GlobalMenu] Shim compilation failed:\n{result.stderr}")
return None
logger.info(f"[GlobalMenu] Compiled menu shim -> {SHIM_SO}")
return SHIM_SO
except Exception as e:
logger.warning(f"[GlobalMenu] Shim compilation error: {e}")
return None
def setup_global_menu_environment():
"""Write env config files so GTK apps register with AppMenu at next login.
Does NOT set env via hyprctl keyword env — that would cause Fabric's own
GTK windows to load appmenu-gtk-module and trigger Gdk-CRITICAL assertions.
Uses session-level config files that take effect at login.
"""
try:
shim_path = get_compiled_shim()
# Remove any legacy global LD_PRELOAD export from older installs.
_remove_global_ld_preload()
# NOTE: The menu-button shim is compiled against GTK3 and must NOT be
# exported globally. Injecting it into a GTK4 process aborts the app
# ("GTK 2/3 symbols detected"). It is injected per-launch into GTK3
# apps only — see globalmenu.launch.
if shim_path:
logger.debug(f"[GlobalMenu] GTK3 shim ready at {shim_path}")
exec_shell_command(
"dbus-update-activation-environment --systemd "
"GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1"
)
exec_shell_command(
"flatpak override --user --talk-name=com.canonical.AppMenu.Registrar"
)
env_dir = Path.home() / ".config" / "environment.d"
try:
env_dir.mkdir(parents=True, exist_ok=True)
env_path = env_dir / "appmenu.conf"
with open(env_path, "w") as f:
for k, v in ENV_VARS.items():
f.write(f"{k}={v}\n")
except OSError as e:
logger.warning(
f"[environment] env_dir.mkdir(parents=True, exist_ok=True) failed: {e}"
)
settings_path = Path.home() / ".config" / "gtk-3.0" / "settings.ini"
try:
settings_path.parent.mkdir(parents=True, exist_ok=True)
content = ""
if settings_path.exists():
content = settings_path.read_text()
if "gtk-shell-shows-menubar" not in content:
with open(settings_path, "a") as f:
if content and not content.endswith("\n"):
f.write("\n")
f.write("gtk-shell-shows-menubar=1\n")
except OSError as e:
logger.warning(
f"[environment] settings_path.parent.mkdir(parents=True, exist_ok=True) failed: {e}"
)
pam_path = Path.home() / ".pam_environment"
try:
existing = ""
if pam_path.exists():
existing = pam_path.read_text()
lines = existing.splitlines(True)
for k, v in ENV_VARS.items():
line = f"{k} DEFAULT={v}\n"
if not any(ln.startswith(f"{k} ") for ln in lines):
with open(pam_path, "a") as f:
f.write(line)
except OSError as e:
logger.warning(f"[environment] existing = '' failed: {e}")
logger.info("[GlobalMenu] GTK environment injected")
except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+760
View File
@@ -0,0 +1,760 @@
from typing import Any, Dict, List, Optional
from fabric.utils import Gio, GLib, logger
from window.globalmenu.dbusmenu import DBusMenuItem, _get_bus
def atspi_activate_menu_item(pid: int, label: str) -> bool:
"""Activate a menu item by label via AT-SPI accessibility interface.
Used as a fallback when D-Bus org.gtk.Actions.Activate doesn't reach the
app's real menu item handler (common with appmenu-gtk-module on Wayland
where gdk_threads_add_idle_full dispatch may not fire).
"""
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except (ImportError, ValueError) as e:
logger.warning(f"[gtkmenu] import gi failed: {e}")
return False
try:
desktop = Atspi.get_desktop(0)
for i in range(desktop.get_child_count()):
app = desktop.get_child_at_index(i)
if app is None:
continue
try:
app_pid = app.get_process_id()
except Exception as e:
logger.warning(f"[gtkmenu] app_pid = app.get_process_id() failed: {e}")
continue
if app_pid != pid:
continue
def _find_menubar(obj, depth=0):
if depth > 8:
return None
try:
if obj.get_role_name() == "menu bar":
return obj
for j in range(obj.get_child_count()):
result = _find_menubar(obj.get_child_at_index(j), depth + 1)
if result:
return result
except Exception as e:
logger.warning(
f"[gtkmenu] if obj.get_role_name() == 'menu bar': return obj failed: {e}"
)
return None
def _find_frame(obj, depth=0):
if depth > 4:
return None
try:
if obj.get_role_name() == "frame":
return obj
for j in range(obj.get_child_count()):
result = _find_frame(obj.get_child_at_index(j), depth + 1)
if result:
return result
except Exception as e:
logger.warning(
f"[gtkmenu] if obj.get_role_name() == 'frame': return obj failed: {e}"
)
return None
frame = _find_frame(app)
if not frame:
continue
menubar = _find_menubar(frame)
if not menubar:
continue
label_lower = label.lower().strip()
for j in range(menubar.get_child_count()):
item = menubar.get_child_at_index(j)
if item is None:
continue
try:
item_name = (item.get_name() or "").lower().strip()
except Exception as e:
logger.warning(
f"[gtkmenu] item_name = (item.get_name() or '').lower().strip() failed: {e}"
)
continue
if item_name == label_lower or label_lower in item_name:
try:
n_actions = item.get_n_actions()
if n_actions > 0:
result = item.do_action(0)
if result:
logger.debug(
f"[ATSPI] Activated menu item '{label}' "
f"via do_action on pid {pid}"
)
return True
except Exception as e:
logger.debug(f"[ATSPI] do_action failed: {e}")
return False
break
except Exception as e:
logger.debug(f"[ATSPI] activate_menu_item error: {e}")
return False
class _ActionInfo:
__slots__ = ("enabled", "parameter_type", "state", "state_type")
def __init__(
self,
enabled: bool = True,
parameter_type: str = "",
state: Optional[Any] = None,
state_type: str = "",
):
self.enabled = enabled
self.parameter_type = parameter_type
self.state = state
self.state_type = state_type
class GtkMenuClient:
__slots__ = (
"_action_cache",
"_action_path",
"_cached_items",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._action_path = (
object_path.split("/menus/")[0] if "/menus/" in object_path else object_path
)
self._cached_items: List[DBusMenuItem] = []
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
path or self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.debug(f"[GtkMenuClient] {method} failed: {e}")
return None
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
self._refresh_action_cache()
if force_refresh or not self._cached_items:
items = self._fetch_menu(parent_id)
self._cached_items = items
return self._cached_items
def _refresh_action_cache(self):
for path in dict.fromkeys([self._action_path, self.object_path]):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
path=path,
)
if not res:
continue
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack()
if state_v and state_v.n_children() > 0
else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
return
except Exception as e:
logger.debug(f"[GtkMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
for path in dict.fromkeys([self._action_path, self.object_path]):
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
path=path,
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack()
if state_v and state_v.n_children() > 0
else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[GtkMenuClient] Describe error for {action_name}: {e}")
return None
def _find_item(
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
) -> Optional[DBusMenuItem]:
if items is None:
items = self._cached_items
for item in items:
if item.id == item_id:
return item
if item.children:
found = self._find_item(item_id, item.children)
if found:
return found
return None
def _fetch_menu(
self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None
) -> List[DBusMenuItem]:
if _depth > 10:
return []
if _visited is None:
_visited = set()
if menu_id in _visited:
return []
_visited.add(menu_id)
res = self._call(
"org.gtk.Menus",
"Start",
GLib.Variant("(au)", ([menu_id],)),
GLib.VariantType("(a(uuaa{sv}))"),
)
if not res:
return []
try:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
items: List[DBusMenuItem] = []
def resolve(sections, visited=None):
if visited is None:
visited = set()
for props in sections:
if not isinstance(props, dict):
continue
if ":section" in props:
ref = props[":section"]
if isinstance(ref, (list, tuple)) and len(ref) == 2:
key = (int(ref[0]), int(ref[1]))
if key not in visited:
visited.add(key)
child = entry_map.get(key)
if child is not None:
resolve(child, visited)
continue
if not props.get("label") and not any(
k in props for k in ("action", ":submenu", "submenu")
):
continue
item = DBusMenuItem(id=hash((menu_id, len(items))))
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
sub_ref = props.get(":submenu") or props.get("submenu")
if (
sub_ref
and isinstance(sub_ref, (list, tuple))
and len(sub_ref) == 2
):
item.has_submenu = True
item.children = self._fetch_menu(
int(sub_ref[0]), _depth + 1, _visited
)
if "action" in props:
item.action_name = props["action"]
if "target" in props:
item.action_target = props["target"]
items.append(item)
if data:
resolve(data[0][2])
return items
except Exception as e:
logger.error(f"[GtkMenuClient] Parse error: {e}")
return []
def about_to_show(self, item_id: int) -> bool:
return False
def click_item(
self,
item_id: int,
action_name: str = "",
action_target=None,
pid: int = 0,
) -> None:
item_label = ""
if not action_name:
item = self._find_item(item_id)
if item and item.action_name:
action_name = item.action_name
action_target = item.action_target
item_label = item.label or ""
if not action_name:
return
if action_name.startswith("unity."):
action_name = action_name[6:]
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b" or action_info.parameter_type:
use_change_state = True
try:
for act_path in (self.object_path, self._action_path):
try:
if (
use_change_state
and action_info
and action_info.state is not None
):
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(
action_name,
GLib.Variant(action_info.state_type, new_state),
),
),
path=act_path,
)
return
else:
params_arr = self._build_activate_params(
action_info, action_target
)
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
path=act_path,
)
if pid and item_label:
atspi_activate_menu_item(pid, item_label)
return
except Exception as e:
logger.warning(
f"[gtkmenu] if ( use_change_state and action_info and action_info.sta... failed: {e}"
)
continue
except Exception as e:
logger.error(f"[GtkMenuClient] click failed: {e}")
if not use_change_state and pid and item_label:
atspi_activate_menu_item(pid, item_label)
@staticmethod
def _build_activate_params(action_info, action_target):
"""Build the parameters array for Activate call."""
if action_target is not None:
try:
return GLib.Variant("av", [action_target])
except Exception as e:
logger.warning(
f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}"
)
if action_info and action_info.parameter_type:
param_type_str = action_info.parameter_type
if param_type_str and param_type_str != "()":
try:
param_type = GLib.VariantType(param_type_str)
default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None))
return GLib.Variant("av", [default_val])
except Exception as e:
logger.warning(
f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}"
)
return GLib.Variant("av", [])
class ActionMenuClient:
__slots__ = (
"_action_cache",
"_action_map",
"_action_path",
"_cached_items",
"object_path",
"service_name",
)
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._action_path = object_path
self._cached_items: List[DBusMenuItem] = []
self._action_map: Dict[int, str] = {}
self._action_cache: Dict[str, _ActionInfo] = {}
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
path or self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.warning(
f"[gtkmenu] return _get_bus().call_sync( self.service_name, path or s... failed: {e}"
)
return None
def _refresh_action_cache(self):
try:
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return
data = res.get_child_value(0).unpack()
self._action_cache.clear()
for name, (enabled, param_type_v, state_v) in data.items():
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
self._action_cache[name] = _ActionInfo(
enabled=enabled,
parameter_type=param_type,
state=state,
state_type=state_type,
)
except Exception as e:
logger.debug(f"[ActionMenuClient] DescribeAll cache error: {e}")
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
cached = self._action_cache.get(action_name)
if cached is not None:
return cached
try:
res = self._call(
"org.gtk.Actions",
"Describe",
GLib.Variant("(s)", (action_name,)),
GLib.VariantType("(bgav)"),
)
if res:
enabled, param_type_v, state_v = res.unpack()
param_type = str(param_type_v) if param_type_v else ""
state = (
state_v.unpack() if state_v and state_v.n_children() > 0 else None
)
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
info = _ActionInfo(enabled, param_type, state, state_type)
self._action_cache[action_name] = info
return info
except Exception as e:
logger.debug(f"[ActionMenuClient] Describe error for {action_name}: {e}")
return None
def _fetch_menu(
self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None
) -> List[DBusMenuItem]:
if _depth > 10:
return []
if _visited is None:
_visited = set()
if menu_id in _visited:
return []
_visited.add(menu_id)
res = self._call(
"org.gtk.Menus",
"Start",
GLib.Variant("(au)", ([menu_id],)),
GLib.VariantType("(a(uuaa{sv}))"),
)
if not res:
return []
try:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
items: List[DBusMenuItem] = []
def resolve(sections, visited=None):
if visited is None:
visited = set()
for props in sections:
if not isinstance(props, dict):
continue
if ":section" in props:
ref = props[":section"]
if isinstance(ref, (list, tuple)) and len(ref) == 2:
key = (int(ref[0]), int(ref[1]))
if key not in visited:
visited.add(key)
child = entry_map.get(key)
if child is not None:
resolve(child, visited)
continue
if not props.get("label") and not any(
k in props for k in ("action", ":submenu", "submenu")
):
continue
item = DBusMenuItem(id=hash((menu_id, len(items))))
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
sub_ref = props.get(":submenu") or props.get("submenu")
if (
sub_ref
and isinstance(sub_ref, (list, tuple))
and len(sub_ref) == 2
):
item.has_submenu = True
item.children = self._fetch_menu(
int(sub_ref[0]), _depth + 1, _visited
)
if "action" in props:
item.action_name = props["action"]
if "target" in props:
item.action_target = props["target"]
items.append(item)
if data:
resolve(data[0][2])
return items
except Exception as e:
logger.error(f"[ActionMenuClient] Parse error: {e}")
return []
def get_layout(
self, parent_id: int = 0, force_refresh: bool = False
) -> List[DBusMenuItem]:
if parent_id != 0:
return []
self._refresh_action_cache()
menu_items = self._fetch_menu(0)
if menu_items:
self._cached_items = menu_items
return menu_items
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return self._cached_items
try:
data = res.get_child_value(0).unpack()
items: List[DBusMenuItem] = []
self._action_map = {}
for i, (name, (enabled, param_type_v, state_v)) in enumerate(data.items()):
label = name.replace("-", " ").replace("_", " ").strip().title()
param_type = str(param_type_v) if param_type_v else ""
state = None
state_type = ""
if state_v and state_v.n_children() > 0:
st = state_v.get_child_value(0)
state_type = st.get_type_string() if st else ""
try:
state = st.get_value()
except Exception:
state = state_v.unpack() if state_v else None
self._action_cache[name] = _ActionInfo(
enabled, param_type, state, state_type
)
item = DBusMenuItem(id=hash((0, i)))
item.action_name = name
item.visible = not bool(param_type) or param_type == "b"
can_activate = not bool(param_type)
item.enabled = enabled if can_activate else False
item.label = label
if param_type == "b":
item.type = "toggle"
item.enabled = enabled
self._action_map[item.id] = name
items.append(item)
self._cached_items = items
return items
except Exception as e:
logger.error(f"[ActionMenuClient] parse error: {e}")
return self._cached_items
def _find_item(
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
) -> Optional[DBusMenuItem]:
if items is None:
items = self._cached_items
for item in items:
if item.id == item_id:
return item
if item.children:
found = self._find_item(item_id, item.children)
if found:
return found
return None
def about_to_show(self, item_id: int) -> bool:
return False
def invalidate(self):
self._cached_items = []
self._action_map = {}
self._action_cache.clear()
def click_item(
self,
item_id: int,
action_name: str = "",
action_target=None,
pid: int = 0,
) -> None:
item_label = ""
if not action_name:
item = self._find_item(item_id)
if item and item.action_name:
action_name = item.action_name
action_target = item.action_target
item_label = item.label or ""
if not action_name:
return
action_info = self._describe_action(action_name)
use_change_state = False
if action_info and action_info.state is not None:
if action_info.state_type == "b" or action_info.parameter_type:
use_change_state = True
try:
if use_change_state and action_info and action_info.state is not None:
new_state = (
not action_info.state
if action_info.state_type == "b"
else action_info.state
)
self._call(
"org.gtk.Actions",
"ChangeState",
GLib.Variant(
"(sv)",
(action_name, GLib.Variant(action_info.state_type, new_state)),
),
)
return
else:
params_arr = self._build_activate_params(action_info, action_target)
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
)
if pid and item_label:
atspi_activate_menu_item(pid, item_label)
return
except Exception as e:
logger.error(f"[ActionMenuClient] click failed: {e}")
if not use_change_state and pid and item_label:
atspi_activate_menu_item(pid, item_label)
@staticmethod
def _build_activate_params(action_info, action_target):
"""Build the parameters array for Activate call."""
if action_target is not None:
try:
return GLib.Variant("av", [action_target])
except Exception as e:
logger.warning(
f"[gtkmenu] return GLib.Variant('av', [action_target]) failed: {e}"
)
if action_info and action_info.parameter_type:
param_type_str = action_info.parameter_type
if param_type_str and param_type_str != "()":
try:
param_type = GLib.VariantType(param_type_str)
default_val = GLib.Variant.new_tuple(GLib.Variant(param_type, None))
return GLib.Variant("av", [default_val])
except Exception as e:
logger.warning(
f"[gtkmenu] param_type = GLib.VariantType(param_type_str) failed: {e}"
)
return GLib.Variant("av", [])
+84
View File
@@ -0,0 +1,84 @@
"""GTK3-aware application launching for the global menu compatibility shim.
The legacy LD_PRELOAD approach exported ``libmenu_button_shim.so`` globally.
That shim links against GTK3, so loading it into a GTK4 process crashes the
app. Instead we detect the toolkit of the target executable and inject the
shim only into GTK3 processes, leaving GTK4 (and other) apps untouched.
Two launch paths are supported:
* :func:`launch_command` — for raw command lines (used by the dock launcher
and forwarded through ``uwsm app``). The shim is prepended via ``env`` so it
is set only for the spawned process.
* :func:`launch_desktop_app` — for :class:`fabric.utils.DesktopApp` objects,
which launch through GIO and inherit the current process environment. We
temporarily set ``LD_PRELOAD`` around the launch call and restore it
immediately afterwards.
"""
import os
import re
from fabric.utils import exec_shell_command_async, logger
from window.globalmenu.detection import executable_gtk_class
from window.globalmenu.environment import get_compiled_shim
_PERCENT_RE = re.compile(r"%\w+")
def _gtk3_shim() -> str | None:
"""Return the shim path if it is compiled, else ``None``."""
shim = get_compiled_shim()
return str(shim) if shim else None
def shim_env_for_executable(executable: str | None) -> dict[str, str] | None:
"""Return ``{"LD_PRELOAD": <shim>}`` for a GTK3 executable, else ``None``."""
if executable_gtk_class(executable) != "gtk3":
return None
shim = _gtk3_shim()
if not shim:
return None
return {"LD_PRELOAD": shim}
def launch_command(command_line: str) -> None:
"""Launch a command through ``uwsm app``, injecting the GTK3 shim if needed."""
if not command_line:
return
cleaned = _PERCENT_RE.sub("", command_line).strip()
if not cleaned:
return
shim_env = shim_env_for_executable(cleaned)
if shim_env:
# Scope the shim to the launched process only.
cleaned = f"env LD_PRELOAD={shim_env['LD_PRELOAD']} {cleaned}"
logger.debug(f"[GlobalMenu] Injecting GTK3 shim for: {cleaned}")
exec_shell_command_async(
f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'"
)
def launch_desktop_app(app) -> None:
"""Launch a :class:`fabric.utils.DesktopApp`, injecting the GTK3 shim if needed."""
shim_env = shim_env_for_executable(getattr(app, "executable", None))
if shim_env:
prev = os.environ.get("LD_PRELOAD")
os.environ["LD_PRELOAD"] = shim_env["LD_PRELOAD"]
logger.debug(
f"[GlobalMenu] Injecting GTK3 shim for desktop app: "
f"{getattr(app, 'display_name', app)}"
)
try:
app.launch()
finally:
if prev is None:
os.environ.pop("LD_PRELOAD", None)
else:
os.environ["LD_PRELOAD"] = prev
else:
app.launch()
@@ -0,0 +1,51 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <gtk/gtk.h>
typedef void (*orig_gtk_menu_item_activate)(GtkMenuItem *menu_item);
static int in_hook = 0;
typedef struct {
GtkWidget *widget;
} WidgetRef;
static gboolean do_emit(gpointer data) {
WidgetRef *ref = data;
GtkWidget *w = ref->widget;
g_free(ref);
if (!GTK_IS_WIDGET(w))
return G_SOURCE_REMOVE;
GdkEvent event;
memset(&event, 0, sizeof(event));
event.type = GDK_BUTTON_RELEASE;
event.any.window = gtk_widget_get_window(w);
event.any.send_event = TRUE;
gboolean handled = FALSE;
g_signal_emit_by_name(w, "button-release-event", &event, &handled);
return G_SOURCE_REMOVE;
}
void gtk_menu_item_activate(GtkMenuItem *menu_item) {
if (in_hook) {
orig_gtk_menu_item_activate orig =
(orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate");
orig(menu_item);
return;
}
in_hook = 1;
orig_gtk_menu_item_activate orig =
(orig_gtk_menu_item_activate)dlsym(RTLD_NEXT, "gtk_menu_item_activate");
orig(menu_item);
in_hook = 0;
/* After real activate, schedule synthetic button-release-event so apps
that connect to button-release-event instead of activate respond. */
WidgetRef *ref = g_new(WidgetRef, 1);
ref->widget = GTK_WIDGET(menu_item);
g_timeout_add(50, do_emit, ref);
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -14,7 +14,7 @@ from gi.repository import GtkSessionLock # pyright: ignore[reportAttributeAcces
from shared.widgets.circle_image import CircleImage as Image
from utils.functions import set_process_name
from utils.utils import svg_file
from utils.gtk_utils import svg_file
from window.panel.components.indicators import (
BatteryIndicator,
BluetoothIndicator,
+5 -11
View File
@@ -15,7 +15,7 @@ from services.modus import notification_service
from shared.widgets.clipping_box import ClippingBox
from shared.widgets.custom_image import CustomImage
from shared.window.applet_window import AppletWindow
from utils.functions import escape_markup_text
from utils.functions import clear_children, escape_markup_text
from window.notification.notification import (
NotificationWidget,
cache_notification_icon,
@@ -704,8 +704,7 @@ class NotificationCenter(AppletWindow):
self.group_widgets.clear()
# Clear notifications box
for child in self.notifications_box.get_children():
child.destroy()
clear_children(self.notifications_box)
# Group notifications by app name and preload assets with debugging
for cached_notification in notification_service.cached_notifications:
@@ -811,10 +810,7 @@ class NotificationCenter(AppletWindow):
def _refresh_group_widget(self, group_widget):
"""Refresh a group widget's content"""
try:
# Remove and destroy existing children
for child in group_widget.get_children():
group_widget.remove(child)
child.destroy()
clear_children(group_widget)
# Recreate content (collapsed + expanded states)
group_widget.create_collapsed_state()
@@ -870,8 +866,7 @@ class NotificationCenter(AppletWindow):
# Clear all remaining cached notification images AND icons
cleanup_all_notification_caches()
for child in self.notifications_box.get_children():
child.destroy()
clear_children(self.notifications_box)
except Exception as e:
logger.error(f"Error clearing notification groups: {e}")
@@ -892,8 +887,7 @@ class NotificationCenter(AppletWindow):
# Clear all remaining cached notification images AND icons when clear all is clicked
cleanup_all_notification_caches() # Clear ALL caches (icons + images)
for child in self.notifications_box.get_children():
child.destroy()
clear_children(self.notifications_box)
notification_service.clear_all_cached_notifications()
self.hide()
+1 -1
View File
@@ -4,7 +4,7 @@ from fabric.audio import Audio
from fabric.utils import logger
from fabric.widgets.scale import ScaleMark
from utils.utils import svg_file
from utils.gtk_utils import svg_file
from .animated_scale import AnimatedScale
from .base import BaseOSDContainer
+1 -1
View File
@@ -4,7 +4,7 @@ from fabric.utils import logger
from fabric.widgets.scale import ScaleMark
from services.brightness import Brightness
from utils.utils import svg_file
from utils.gtk_utils import svg_file
from .animated_scale import AnimatedScale
from .base import BaseOSDContainer
View File
Whitespace-only changes.
+3 -5
View File
@@ -7,8 +7,8 @@ from fabric.widgets.button import Button
from fabric.widgets.label import Label
from shared.window.applet_window import AppletWindow
from utils.functions import run_command, thread
from utils.utils import setup_cursor_hover, svg_file, toml_file
from utils.functions import clear_children, run_command, thread
from utils.gtk_utils import setup_cursor_hover, svg_file, toml_file
MODS_CONFIG_PATH = Path(toml_file("mods.toml"))
RELOAD_DELAY_MS = 200
@@ -167,9 +167,7 @@ class CustomMods(Box):
return True
def _rebuild(self, mods):
for child in list(self.get_children()):
self.remove(child)
child.destroy()
clear_children(self)
for popup in self._mod_popups.values():
if popup:
+2 -2
View File
@@ -5,12 +5,12 @@ from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from globalmenu.service import get_global_menu_service
from shared.dialogs.about import AboutApp, get_about_window
from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns
from utils.app_name_resolver import format_window
from utils.gtk_utils import setup_cursor_hover
from utils.roam import modus_service
from utils.utils import setup_cursor_hover
from window.globalmenu.service import get_global_menu_service
from window.settings.main import get_settings_window
+5 -8
View File
@@ -9,8 +9,8 @@ from services.network import NetworkClient
from shared.window.applet_window import AppletWindow
from shared.window.battery_widget import BatteryControl
from utils.functions import format_duration, get_wifi_icon_for_strength
from utils.gtk_utils import setup_cursor_hover, svg_file
from utils.roam import modus_service
from utils.utils import setup_cursor_hover, svg_file
from window.controlcenter.bluetooth import BluetoothConnections
from window.controlcenter.wifi import WifiConnections
@@ -387,9 +387,6 @@ class BatteryIndicator(Box):
def _get_time_to_full(self):
return self.battery_service.time_to_full
def _format_time(self, seconds: int) -> str:
return format_duration(seconds)
def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str:
clamped = max(0, min(100, percentage))
step = (clamped // 10) * 10
@@ -406,11 +403,11 @@ class BatteryIndicator(Box):
battery_state = f"{state_str}:{percentage}%"
if state_str == "discharging":
time_to_empty = self._format_time(self._get_time_to_empty())
time_to_empty = format_duration(self._get_time_to_empty())
if time_to_empty != "N/A":
battery_state += f":{time_to_empty}"
elif state_str == "charging":
time_to_full = self._format_time(self._get_time_to_full())
time_to_full = format_duration(self._get_time_to_full())
if time_to_full != "N/A":
battery_state += f":{time_to_full}"
@@ -421,11 +418,11 @@ class BatteryIndicator(Box):
if state == "CHARGING":
tooltip += " (Charging)"
time_to_full = self._format_time(self._get_time_to_full())
time_to_full = format_duration(self._get_time_to_full())
if time_to_full != "N/A":
tooltip += f" - {time_to_full} until full"
elif state == "DISCHARGING":
time_to_empty = self._format_time(self._get_time_to_empty())
time_to_empty = format_duration(self._get_time_to_empty())
if time_to_empty != "N/A":
tooltip += f" - {time_to_empty} remaining"
elif state == "FULLY_CHARGED":
+9 -7
View File
@@ -2,7 +2,7 @@ import os
from enum import Enum
from pathlib import Path
from fabric.utils import GLib, Gtk
from fabric.utils import GLib, Gtk, exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
@@ -367,14 +367,14 @@ class Pomodoro(AppletWindow):
if self.current_cycle >= self.max_cycles:
self.state = PomodoroState.LONG_BREAK
self.total_time = self.long_break_mins * 60
os.system(
'notify-send "Pomodoro" "Focus session complete! Time for a long break." &'
exec_shell_command_async(
'notify-send "Pomodoro" "Focus session complete! Time for a long break."'
)
else:
self.state = PomodoroState.SHORT_BREAK
self.total_time = self.short_break_mins * 60
os.system(
'notify-send "Pomodoro" "Focus session complete! Time for a short break." &'
exec_shell_command_async(
'notify-send "Pomodoro" "Focus session complete! Time for a short break."'
)
else:
if self.state == PomodoroState.LONG_BREAK:
@@ -384,7 +384,9 @@ class Pomodoro(AppletWindow):
self.state = PomodoroState.FOCUS
self.total_time = self.focus_mins * 60
os.system('notify-send "Pomodoro" "Break over! Time to focus." &')
exec_shell_command_async(
'notify-send "Pomodoro" "Break over! Time to focus."'
)
self.play_sound()
self.time_left = self.total_time
@@ -415,7 +417,7 @@ class Pomodoro(AppletWindow):
if os.path.exists(sound_file):
vol = float(self.volume) / 100.0
os.system(f"pw-play --volume={vol} '{sound_file}' &")
exec_shell_command_async(f"pw-play --volume={vol} '{sound_file}'")
def _update_display(self):
mins = self.time_left // 60
+4 -4
View File
@@ -9,7 +9,7 @@ from fabric.widgets.label import Label
from services.todo import get_todo_service
from shared.window.animated_scrollwindow import AnimatedScrollable
from shared.window.applet_window import AppletWindow
from utils.utils import svg_file
from utils.gtk_utils import svg_file
class TodoItem(Box):
@@ -379,10 +379,10 @@ class TodoListWidget(AppletWindow):
def _refresh_todos(self, *_):
"""Refresh the entire todo list"""
# Clear existing items
from utils.functions import clear_children
self.todo_items.clear()
for child in self.todos_container.get_children():
self.todos_container.remove(child)
clear_children(self.todos_container)
# Get all todos
todos = get_todo_service().todos
+1 -1
View File
@@ -4,7 +4,7 @@ from fabric.widgets.box import Box
from services.config import get_config, on_config_change
from utils.functions import is_special_workspace_id
from utils.utils import setup_cursor_hover
from utils.gtk_utils import setup_cursor_hover
# TODO: Support multi-monitor setups
+1 -1
View File
@@ -9,8 +9,8 @@ from fabric.widgets.wayland import WaylandWindow as Window
from services.config import get_config_all, on_config_change
from services.modus import notification_service
from utils.gtk_utils import setup_cursor_hover, svg_file
from utils.roam import modus_service
from utils.utils import setup_cursor_hover, svg_file
from window.controlcenter.main import ModusControlCenter
from window.notification.notification_center import NotificationCenter
from window.panel.components.custom_mods import CustomMods
+1 -1
View File
@@ -8,7 +8,7 @@ from services.battery import Battery
from services.capslock import CapsLock
from services.keyboard_layout import KeyboardLayout
from services.numlock import NumLock
from utils.utils import svg_file
from utils.gtk_utils import svg_file
DISPLAY_MS = 2000 # how long each indicator stays visible (ms)
+1 -1
View File
@@ -8,7 +8,7 @@ from fabric.widgets.centerbox import CenterBox
from services.mpris import PlayerService
from shared.widgets.custom_image import CustomImage
from utils.utils import svg_file
from utils.gtk_utils import svg_file
# tunables
_BARS = 8 # number of bars
+1 -1
View File
@@ -5,7 +5,7 @@ from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from services.screencapture import screen_capture_service
from utils.utils import setup_cursor_hover, svg_file
from utils.gtk_utils import setup_cursor_hover, svg_file
class RecordingIndicator(Box):
+1 -1
View File
@@ -1,6 +1,6 @@
from fabric.widgets.button import Button
from utils.utils import svg_file
from utils.gtk_utils import svg_file
class ToolButton(Button):
View File
Whitespace-only changes.
+1 -1
View File
@@ -9,7 +9,7 @@ from fabric.widgets.stack import Stack
from services.config import config, on_config_change
from shared.widgets.smooth_switch import SmoothSwitch
from utils.utils import setup_cursor_hover, svg_file
from utils.gtk_utils import setup_cursor_hover, svg_file
class SettingsRow(CenterBox):
View File
Whitespace-only changes.
+23 -43
View File
@@ -1,7 +1,6 @@
import importlib
import importlib.util
import inspect
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -237,53 +236,34 @@ class PluginLoader:
def _create_venv(self, venv_dir: Path) -> bool:
"""Create a virtualenv using uv."""
try:
result = subprocess.run(
["uv", "venv", str(venv_dir)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
logger.error(f"[PluginLoader] uv venv failed: {result.stderr[:300]}")
return result.returncode == 0
except FileNotFoundError:
logger.error("[PluginLoader] uv not found — install it first")
return False
except Exception as e:
logger.error(f"[PluginLoader] Failed to create venv: {e}")
return False
from utils.functions import run_command
result = run_command(["uv", "venv", str(venv_dir)], timeout=30)
if result.returncode != 0:
logger.error(f"[PluginLoader] uv venv failed: {result.stderr[:300]}")
return result.returncode == 0
def _install_deps(self, venv_dir: Path, req_file: Path) -> bool:
"""Install dependencies from requirements.txt using uv."""
from utils.functions import run_command
venv_python = venv_dir / "bin" / "python"
try:
result = subprocess.run(
[
"uv",
"pip",
"install",
"-r",
str(req_file),
"--python",
str(venv_python),
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error(
f"[PluginLoader] uv pip install failed: {result.stderr[:500]}"
)
return False
return True
except FileNotFoundError:
logger.error("[PluginLoader] uv not found — install it first")
return False
except Exception as e:
logger.error(f"[PluginLoader] Failed to install deps: {e}")
result = run_command(
[
"uv",
"pip",
"install",
"-r",
str(req_file),
"--python",
str(venv_python),
],
timeout=120,
)
if result.returncode != 0:
logger.error(f"[PluginLoader] uv pip install failed: {result.stderr[:500]}")
return False
return True
def _find_site_packages(self, venv_dir: Path) -> Path | None:
"""Find site-packages directory in a venv."""
+6 -6
View File
@@ -186,7 +186,7 @@ class PluginManager:
entry.instance = None
def _config_path(self) -> Path:
from utils.utils import toml_file
from utils.gtk_utils import toml_file
return Path(toml_file("config.toml")).parent / "plugins.json"
@@ -195,12 +195,12 @@ class PluginManager:
if not path.exists():
self._disabled_ids = set()
return
try:
with open(path) as f:
data = json.load(f)
from utils.functions import read_json_file
data = read_json_file(str(path))
if data is not None:
self._disabled_ids = set(data.get("disabled", []))
except Exception as e:
logger.error(f"[PluginManager] Failed to load disabled list: {e}")
else:
self._disabled_ids = set()
def _save_disabled_list(self) -> None:
+1 -1
View File
@@ -75,7 +75,7 @@ class ApplicationPlugin(SpotlightPlugin):
@staticmethod
def _launch_app(app: DesktopApp) -> None:
from globalmenu.launch import launch_desktop_app
from window.globalmenu.launch import launch_desktop_app
launch_desktop_app(app)
+13 -28
View File
@@ -1,10 +1,10 @@
import os
import re
import signal
import subprocess
from fabric.utils import logger
from utils.functions import format_duration, parse_timeout_string
from window.spotlight.api import SearchResult, SpotlightPlugin
_PID_FILE = os.path.join(
@@ -16,24 +16,6 @@ _INHIBIT_WHY = "Modus Caffeine"
# indefinite mode instead of relying on an in-process timer.
_INDEFINITE_SECS = 100_000_000
_DURATION_RE = re.compile(
r"^\s*(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|hour|hours)\s*$",
re.IGNORECASE,
)
_UNIT_SECS = {
"s": 1,
"sec": 1,
"secs": 1,
"m": 60,
"min": 60,
"mins": 60,
"h": 3600,
"hr": 3600,
"hrs": 3600,
"hour": 3600,
"hours": 3600,
}
class CaffeinePlugin(SpotlightPlugin):
id = "caffeine"
@@ -89,18 +71,21 @@ class CaffeinePlugin(SpotlightPlugin):
@staticmethod
def _parse_duration(text: str) -> int | None:
m = _DURATION_RE.match(text)
if not m:
return None
return int(m.group(1)) * _UNIT_SECS[m.group(2).lower()]
seconds = parse_timeout_string(text, in_seconds=True)
return seconds if seconds != 5 else None # 5 is the default on parse failure
@staticmethod
def _humanize(secs: int) -> str:
if secs >= 3600:
return f"{secs // 3600}h"
if secs >= 60:
return f"{secs // 60}m"
return f"{secs}s"
result = format_duration(secs)
# format_duration returns "N/A" for 0, but we want "0s"
if result == "N/A":
return "0s"
# format_duration returns "Xh Ym" or "Xm", we want shorter format
# For hours only: "1h" instead of "1h 0m"
if "h" in result and "m" in result:
hours = result.split("h")[0]
return f"{hours}h"
return result
# --- inhibition ----------------------------------------------------
+1 -1
View File
@@ -164,7 +164,7 @@ class EmojiPlugin(SpotlightPlugin):
result = self._normalize_annotations(raw) or None
if result:
logger.info(f"[Emoji] Normalized {len(result)} emojis")
logger.debug(f"[Emoji] Normalized {len(result)} emojis")
else:
logger.error("[Emoji] Normalization returned empty")
return result