chore: refactor imports and adopt new utils utilities
This commit is contained in:
65 files changed
+221
-266
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
from utils.utils import toml_file
|
||||
from utils.gtk_utils import toml_file
|
||||
|
||||
PINNED_APPS_FILE = toml_file("dock.toml")
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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", [])
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Whitespace-only changes.
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
Whitespace-only changes.
@@ -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):
|
||||
|
||||
Whitespace-only changes.
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user