feat(globalmenu): got a prototype of working globalmenu for some apps
This commit is contained in:
10 files changed
+930
-123
No files matched your search
@@ -0,0 +1,341 @@
|
||||
"""Global Menu Service - extracts and manages application menus.
|
||||
|
||||
Uses the pure Python DBusMenu extractor via Gio.DBus.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Optional, List
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import logger
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
from utils.dbusmenu import DBusMenuClient, DBusMenuItem
|
||||
|
||||
REGISTRAR_XML = """
|
||||
<node>
|
||||
<interface name="com.canonical.AppMenu.Registrar">
|
||||
<method name="RegisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="o" name="menuObjectPath" direction="in"/>
|
||||
</method>
|
||||
<method name="UnregisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
</method>
|
||||
<method name="GetMenuForWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="s" name="service" direction="out"/>
|
||||
<arg type="o" name="menuObjectPath" direction="out"/>
|
||||
</method>
|
||||
<method name="GetMenus">
|
||||
<arg type="a(uso)" name="menus" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
class GlobalMenuService(Service):
|
||||
"""Service that extracts and provides application menus for the global menu bar."""
|
||||
|
||||
@Signal
|
||||
def menu_changed(self, menu_items: object) -> None: ...
|
||||
|
||||
@Signal
|
||||
def active_app_changed(self, app_name: str, wm_class: str) -> None: ...
|
||||
|
||||
@Property(object, flags="read-write")
|
||||
def current_menu(self) -> Optional[List[DBusMenuItem]]:
|
||||
return self._current_menu
|
||||
|
||||
@Property(str, flags="read-write")
|
||||
def current_app(self) -> str:
|
||||
return self._current_app
|
||||
|
||||
@Property(str, flags="read-write")
|
||||
def current_wm_class(self) -> str:
|
||||
return self._current_wm_class
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._current_menu: Optional[List[DBusMenuItem]] = None
|
||||
self._current_app: str = ""
|
||||
self._current_wm_class: str = ""
|
||||
|
||||
# Cache holds DBusMenuClient instances
|
||||
self._client_cache: dict[str, Optional[DBusMenuClient]] = {}
|
||||
# Cache holds the extracted top-level menus
|
||||
self._menu_cache: dict[str, Optional[List[DBusMenuItem]]] = {}
|
||||
|
||||
# Registrar mapping: sender -> object_path
|
||||
self._registered_menus: dict[str, str] = {}
|
||||
self._setup_registrar()
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._extraction_seq = 0 # Monotonic counter to discard stale results
|
||||
|
||||
logger.info(
|
||||
"[GlobalMenuService] Initialized native Python DBusMenu parser successfully"
|
||||
)
|
||||
|
||||
def _setup_registrar(self):
|
||||
"""Host the AppMenu Registrar on DBus to allow GTK/KDE apps to register menus."""
|
||||
try:
|
||||
self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
|
||||
self._reg_id = self._bus.register_object(
|
||||
"/com/canonical/AppMenu/Registrar",
|
||||
self._node.interfaces[0],
|
||||
self._handle_registrar_method,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
Gio.bus_own_name_on_connection(
|
||||
self._bus,
|
||||
"com.canonical.AppMenu.Registrar",
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
logger.info("[GlobalMenuService] AppMenu Registrar DBus service started")
|
||||
except Exception as e:
|
||||
logger.error(f"[GlobalMenuService] Failed to start Registrar: {e}")
|
||||
|
||||
def _handle_registrar_method(
|
||||
self,
|
||||
connection,
|
||||
sender,
|
||||
object_path,
|
||||
interface_name,
|
||||
method_name,
|
||||
parameters,
|
||||
invocation,
|
||||
):
|
||||
"""Handles incoming DBus requests from apps registering their menus."""
|
||||
try:
|
||||
if method_name == "RegisterWindow":
|
||||
window_id, menu_path = parameters.unpack()
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] App {sender} registered menu {menu_path} for window {window_id}"
|
||||
)
|
||||
self._registered_menus[sender] = menu_path
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "UnregisterWindow":
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "GetMenuForWindow":
|
||||
invocation.return_value(
|
||||
GLib.Variant("(so)", (sender, "/com/canonical/menu/0"))
|
||||
)
|
||||
|
||||
elif method_name == "GetMenus":
|
||||
invocation.return_value(GLib.Variant("(a(uso))", ([])))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[GlobalMenuService] Error handling Registrar method {method_name}: {e}"
|
||||
)
|
||||
invocation.return_error_literal(Gio.DBusError.FAILED, str(e))
|
||||
|
||||
def update_active_window(self, app_name: str, wm_class: str):
|
||||
"""Called when the active window changes. Triggers menu extraction."""
|
||||
if wm_class == self._current_wm_class:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Active window changed: {app_name} ({wm_class})"
|
||||
)
|
||||
self._current_app = app_name
|
||||
self._current_wm_class = wm_class
|
||||
self._extraction_seq += 1
|
||||
seq = self._extraction_seq
|
||||
self.active_app_changed(app_name, wm_class)
|
||||
|
||||
# Check cache first
|
||||
if wm_class in self._menu_cache and wm_class in self._client_cache:
|
||||
cached_menu = self._menu_cache[wm_class]
|
||||
self._current_menu = cached_menu
|
||||
logger.info(f"[GlobalMenuService] Cache hit for '{wm_class}'")
|
||||
self.menu_changed(cached_menu if cached_menu else [])
|
||||
return
|
||||
|
||||
# Clear menu immediately for new app while extraction runs
|
||||
self._current_menu = None
|
||||
self.menu_changed([])
|
||||
|
||||
# Extract menu in background thread
|
||||
logger.info(f"[GlobalMenuService] Starting extraction for '{wm_class}'")
|
||||
thread = threading.Thread(
|
||||
target=self._extract_menu,
|
||||
args=(wm_class, seq),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _discover_dbus_client(self) -> Optional[DBusMenuClient]:
|
||||
"""Discovers the DBusMenu object path for the active Wayland window using Gio.DBus."""
|
||||
try:
|
||||
out = subprocess.check_output(["hyprctl", "activewindow", "-j"], timeout=2)
|
||||
data = json.loads(out)
|
||||
target_pid = data.get("pid", 0)
|
||||
|
||||
if target_pid <= 0:
|
||||
return None
|
||||
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
res = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"ListNames",
|
||||
None,
|
||||
GLib.VariantType("(as)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
)
|
||||
names = res.get_child_value(0).unpack()
|
||||
|
||||
for svc in names:
|
||||
if (
|
||||
svc.startswith("org.freedesktop.systemd")
|
||||
or svc.startswith("org.freedesktop.login1")
|
||||
or svc == "org.freedesktop.DBus"
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
pid_res = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"GetConnectionUnixProcessID",
|
||||
GLib.Variant("(s)", (svc,)),
|
||||
GLib.VariantType("(u)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
1000,
|
||||
None,
|
||||
)
|
||||
pid = pid_res.get_child_value(0).get_uint32()
|
||||
|
||||
if pid == target_pid:
|
||||
# 1. Check if this app explicitly registered its menu via Registrar
|
||||
if svc in self._registered_menus:
|
||||
path = self._registered_menus[svc]
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Found Registrar path {path} for {svc}"
|
||||
)
|
||||
return DBusMenuClient(svc, path)
|
||||
|
||||
# 2. Fallback to common paths
|
||||
for path in [
|
||||
"/com/canonical/menu/0",
|
||||
"/com/canonical/menu/1",
|
||||
"/MenuBar",
|
||||
"/MenuBar/1",
|
||||
"/org/xfce/Thunar/menus/menubar/0",
|
||||
"/com/canonical/AppMenu/Registrar/0",
|
||||
]:
|
||||
try:
|
||||
introspect_res = bus.call_sync(
|
||||
svc,
|
||||
path,
|
||||
"org.freedesktop.DBus.Introspectable",
|
||||
"Introspect",
|
||||
None,
|
||||
GLib.VariantType("(s)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
500,
|
||||
None,
|
||||
)
|
||||
xml = introspect_res.get_child_value(0).get_string()
|
||||
if "com.canonical.dbusmenu" in xml:
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Discovered DBusMenu natively at {svc} {path}"
|
||||
)
|
||||
return DBusMenuClient(svc, path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"[GlobalMenuService] DBus discovery failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _extract_menu(self, wm_class: str, seq: int):
|
||||
"""Extract menu for a given WM class in a background thread."""
|
||||
try:
|
||||
client = self._discover_dbus_client()
|
||||
items = []
|
||||
|
||||
if client:
|
||||
items = client.get_layout()
|
||||
|
||||
with self._lock:
|
||||
self._client_cache[wm_class] = client
|
||||
self._menu_cache[wm_class] = items
|
||||
|
||||
if items:
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"[GlobalMenuService] No DBus menu found for '{wm_class}'"
|
||||
)
|
||||
|
||||
# Only update UI if this extraction is still current
|
||||
if seq != self._extraction_seq:
|
||||
return
|
||||
|
||||
# Update UI on main thread via GLib idle
|
||||
GLib.idle_add(self._emit_menu_changed, items)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[GlobalMenuService] Error extracting menu for '{wm_class}': {e}"
|
||||
)
|
||||
|
||||
def _emit_menu_changed(self, items: List[DBusMenuItem]):
|
||||
"""Emit menu_changed signal on the main thread."""
|
||||
self._current_menu = items
|
||||
self.menu_changed(items)
|
||||
return False # Remove from idle queue
|
||||
|
||||
def click_item(self, item_id: int) -> bool:
|
||||
"""Click a menu item by ID."""
|
||||
client = self._client_cache.get(self._current_wm_class)
|
||||
if client:
|
||||
client.click_item(item_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def about_to_show(self, item_id: int) -> bool:
|
||||
"""Triggers the AboutToShow event on a DBusMenu item."""
|
||||
client = self._client_cache.get(self._current_wm_class)
|
||||
if client:
|
||||
return client.about_to_show(item_id)
|
||||
return False
|
||||
|
||||
def refresh_menu_sync(self) -> List[DBusMenuItem]:
|
||||
"""Synchronously refetches the menu for the current app."""
|
||||
client = self._client_cache.get(self._current_wm_class)
|
||||
if client:
|
||||
items = client.get_layout()
|
||||
with self._lock:
|
||||
self._menu_cache[self._current_wm_class] = items
|
||||
return items
|
||||
return []
|
||||
|
||||
|
||||
_global_menu_svc_instance = None
|
||||
|
||||
|
||||
def get_global_menu_service() -> GlobalMenuService:
|
||||
global _global_menu_svc_instance
|
||||
if _global_menu_svc_instance is None:
|
||||
_global_menu_svc_instance = GlobalMenuService()
|
||||
return _global_menu_svc_instance
|
||||
@@ -324,8 +324,9 @@ class ModusService(Service):
|
||||
else:
|
||||
name = "Finder"
|
||||
|
||||
self.current_active_app_name = name
|
||||
# Set wm_class FIRST so that signal handlers reading it get the new value
|
||||
self.current_active_wm_class = wmclass
|
||||
self.current_active_app_name = name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ModusService] Error updating active window: {e}")
|
||||
|
||||
+56
-43
@@ -67,12 +67,9 @@ class PlayerService(Service):
|
||||
@Property(str, "readable", default_value="")
|
||||
def arturl(self) -> str:
|
||||
try:
|
||||
meta = self._player.props.metadata
|
||||
if meta and "mpris:artUrl" in meta.keys():
|
||||
return meta["mpris:artUrl"]
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
return ""
|
||||
return self._player.props.metadata["mpris:artUrl"] or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def title(self) -> str:
|
||||
@@ -84,22 +81,16 @@ class PlayerService(Service):
|
||||
@Property(object, "readable")
|
||||
def artist(self) -> list:
|
||||
try:
|
||||
meta = self._player.props.metadata
|
||||
if meta and "xesam:artist" in meta.keys():
|
||||
return list(meta["xesam:artist"]) or []
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
return []
|
||||
return list(self._player.props.metadata["xesam:artist"]) or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@Property(str, "readable", default_value="")
|
||||
def album(self) -> str:
|
||||
try:
|
||||
meta = self._player.props.metadata
|
||||
if meta and "xesam:album" in meta.keys():
|
||||
return meta["xesam:album"] or ""
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
return ""
|
||||
return self._player.props.metadata["xesam:album"] or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@Property(str, "readable", default_value="Stopped")
|
||||
def playback_status(self) -> str:
|
||||
@@ -117,24 +108,17 @@ class PlayerService(Service):
|
||||
@Property(int, "readable", default_value=0)
|
||||
def length(self) -> int:
|
||||
try:
|
||||
meta = self._player.props.metadata
|
||||
if meta and "mpris:length" in meta.keys():
|
||||
return int(meta["mpris:length"])
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
return 0
|
||||
return int(self._player.props.metadata["mpris:length"])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@Property(int, "read-write", default_value=0)
|
||||
@Property(int, "readable", default_value=0)
|
||||
def position(self) -> int:
|
||||
try:
|
||||
return int(self._player.get_position())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@position.setter
|
||||
def position(self, value: int):
|
||||
self.set_position(value / 1_000_000)
|
||||
|
||||
def play_pause(self, *_):
|
||||
try:
|
||||
self._player.play_pause()
|
||||
@@ -187,7 +171,7 @@ class PlayerService(Service):
|
||||
metadata = self._player.props.metadata
|
||||
if metadata:
|
||||
self.meta_change(metadata, self._player)
|
||||
self._handle_artwork(metadata, metadata.keys())
|
||||
self._handle_artwork(metadata)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize metadata: {e}")
|
||||
|
||||
@@ -207,10 +191,30 @@ class PlayerService(Service):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
self.pos_fabricator.stop()
|
||||
current = self.get_position() / 1_000_000
|
||||
try:
|
||||
self._player.set_position(int(pos * 1_000_000))
|
||||
except GLib.Error:
|
||||
try:
|
||||
offset = pos - current
|
||||
self._player.seek(int(offset * 1_000_000))
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
finally:
|
||||
if self.playback_status.lower() == "playing":
|
||||
self.pos_fabricator.start()
|
||||
|
||||
def seek(self, offset: float):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
self.pos_fabricator.stop()
|
||||
try:
|
||||
self._player.seek(int(offset * 1_000_000))
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
finally:
|
||||
if self.playback_status.lower() == "playing":
|
||||
self.pos_fabricator.start()
|
||||
|
||||
def poll_progress(self):
|
||||
if self._is_cleaning_up:
|
||||
@@ -220,20 +224,26 @@ class PlayerService(Service):
|
||||
else:
|
||||
self.pos_fabricator.stop()
|
||||
|
||||
def fabricating(self):
|
||||
def fabricating(self, metadata=None):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
try:
|
||||
pos = self._player.get_position() / 1_000_000
|
||||
keys = self._player.props.metadata.keys()
|
||||
dur = (
|
||||
self._player.props.metadata["mpris:length"] / 1_000_000
|
||||
if "mpris:length" in keys
|
||||
else 0
|
||||
)
|
||||
self.track_position(pos, dur)
|
||||
except GLib.Error as e:
|
||||
logger.warning(f"Failed to get position: {e}")
|
||||
return
|
||||
dur = 0
|
||||
if metadata is None:
|
||||
try:
|
||||
metadata = self._player.props.metadata
|
||||
except Exception:
|
||||
metadata = None
|
||||
if metadata is not None:
|
||||
try:
|
||||
dur = metadata["mpris:length"] / 1_000_000
|
||||
except Exception:
|
||||
dur = 0
|
||||
self.track_position(pos, dur)
|
||||
|
||||
def on_seeked(self, player, position):
|
||||
if self._is_cleaning_up:
|
||||
@@ -266,13 +276,16 @@ class PlayerService(Service):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
self.meta_change(metadata, player)
|
||||
self._handle_artwork(metadata, metadata.keys())
|
||||
self._handle_artwork(metadata)
|
||||
self.fabricating(metadata)
|
||||
|
||||
def _handle_artwork(self, metadata, keys):
|
||||
if self._is_cleaning_up or "mpris:artUrl" not in keys:
|
||||
def _handle_artwork(self, metadata):
|
||||
if self._is_cleaning_up:
|
||||
return
|
||||
try:
|
||||
art_url = metadata["mpris:artUrl"]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
art_url = metadata["mpris:artUrl"]
|
||||
artwork_hash = hashlib.md5(art_url.encode()).hexdigest()
|
||||
|
||||
if artwork_hash == self._current_artwork_hash:
|
||||
|
||||
Reference in new issue
Block a user