linux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 242 lines 8.0 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3# ruff: noqa: F722, F821 4"""com.canonical.dbusmenu implementation over dbus-next. 5 6This implements the D-Bus menu protocol used by StatusNotifierItem 7to export application menus to the desktop environment's tray host. 8Both KDE Plasma and GNOME's AppIndicator extension consume this. 9 10Reference: https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml 11""" 12 13import logging 14 15from dbus_next import PropertyAccess, Variant 16from dbus_next.service import ( 17 ServiceInterface, 18 dbus_property, 19 method, 20 signal as dbus_signal, 21) 22 23log = logging.getLogger(__name__) 24 25 26class MenuItem: 27 """A menu item in the dbusmenu tree.""" 28 29 _next_id = 1 30 31 def __init__( 32 self, 33 label="", 34 icon_name="", 35 enabled=True, 36 visible=True, 37 toggle_type="", 38 toggle_state=-1, 39 item_type="", 40 children_display="", 41 shortcut=None, 42 callback=None, 43 ): 44 self.id = MenuItem._next_id 45 MenuItem._next_id += 1 46 self.label = label 47 self.icon_name = icon_name 48 self.enabled = enabled 49 self.visible = visible 50 self.toggle_type = toggle_type # "", "checkmark", "radio" 51 self.toggle_state = toggle_state # -1 = none, 0 = off, 1 = on 52 self.item_type = item_type # "" = standard, "separator" 53 self.children_display = children_display # "" or "submenu" 54 self.shortcut = shortcut 55 self.callback = callback 56 self.children: list["MenuItem"] = [] 57 58 def get_properties(self) -> dict: 59 """Return non-default properties as a dict of Variants.""" 60 props = {} 61 if self.label: 62 props["label"] = Variant("s", self.label) 63 if self.icon_name: 64 props["icon-name"] = Variant("s", self.icon_name) 65 # Some hosts cache booleans and won't default missing keys back to True. 66 props["enabled"] = Variant("b", self.enabled) 67 props["visible"] = Variant("b", self.visible) 68 if self.toggle_type: 69 props["toggle-type"] = Variant("s", self.toggle_type) 70 props["toggle-state"] = Variant("i", self.toggle_state) 71 if self.item_type: 72 props["type"] = Variant("s", self.item_type) 73 if self.children_display: 74 props["children-display"] = Variant("s", self.children_display) 75 return props 76 77 78def _separator(): 79 """Create a separator menu item.""" 80 item = MenuItem(item_type="separator") 81 return item 82 83 84class DBusMenu(ServiceInterface): 85 """com.canonical.dbusmenu service interface.""" 86 87 def __init__(self): 88 super().__init__("com.canonical.dbusmenu") 89 self._revision = 1 90 self._root = MenuItem() # id 0 is root 91 self._root.id = 0 92 self._root.children_display = "submenu" 93 self._items: dict[int, MenuItem] = {0: self._root} 94 MenuItem._next_id = 1 95 96 def set_menu(self, items: list[MenuItem]): 97 """Replace the entire menu tree.""" 98 self._root.children = items 99 self._items = {0: self._root} 100 self._register_items(items) 101 self._revision += 1 102 self.LayoutUpdated(self._revision, 0) 103 104 def update_properties(self, item: MenuItem, *names: str): 105 if not names: 106 return 107 108 updated = {name: self._property_variant(item, name) for name in names} 109 self.ItemsPropertiesUpdated([[item.id, updated]], []) 110 111 def _register_items(self, items: list[MenuItem]): 112 for item in items: 113 self._items[item.id] = item 114 if item.children: 115 self._register_items(item.children) 116 117 def _property_variant(self, item: MenuItem, name: str) -> Variant: 118 if name == "label": 119 return Variant("s", item.label) 120 if name == "visible": 121 return Variant("b", item.visible) 122 if name == "enabled": 123 return Variant("b", item.enabled) 124 if name == "icon-name": 125 return Variant("s", item.icon_name) 126 if name == "toggle-state": 127 return Variant("i", item.toggle_state) 128 129 raise ValueError(f"unsupported menu property: {name}") 130 131 def _build_layout(self, item: MenuItem, depth: int, props: list[str]): 132 """Build the (ia{sv}av) layout tuple for GetLayout.""" 133 item_props = item.get_properties() 134 if props: 135 item_props = {k: v for k, v in item_props.items() if k in props} 136 137 children_variants = [] 138 if depth != 0 and item.children: 139 for child in item.children: 140 child_layout = self._build_layout( 141 child, 142 depth - 1 if depth > 0 else -1, 143 props, 144 ) 145 children_variants.append(Variant("(ia{sv}av)", child_layout)) 146 147 return [item.id, item_props, children_variants] 148 149 # ── D-Bus Methods ── 150 151 @method() 152 def GetLayout( 153 self, parent_id: "i", recursion_depth: "i", property_names: "as" 154 ) -> "u(ia{sv}av)": 155 parent = self._items.get(parent_id, self._root) 156 layout = self._build_layout(parent, recursion_depth, property_names) 157 return [self._revision, layout] 158 159 @method() 160 def GetGroupProperties(self, ids: "ai", property_names: "as") -> "a(ia{sv})": 161 result = [] 162 for item_id in ids: 163 item = self._items.get(item_id) 164 if item: 165 props = item.get_properties() 166 if property_names: 167 props = {k: v for k, v in props.items() if k in property_names} 168 result.append([item_id, props]) 169 return result 170 171 @method() 172 def GetProperty(self, item_id: "i", name: "s") -> "v": 173 item = self._items.get(item_id) 174 if item: 175 props = item.get_properties() 176 if name in props: 177 return props[name] 178 return Variant("s", "") 179 180 @method() 181 def Event(self, item_id: "i", event_id: "s", data: "v", timestamp: "u"): 182 item = self._items.get(item_id) 183 if item and event_id == "clicked" and item.callback: 184 log.info(f"Menu item clicked: {item.label!r} (id={item_id})") 185 item.callback() 186 elif item: 187 log.debug(f"Menu event: {event_id} on {item.label!r} (id={item_id})") 188 189 @method() 190 def EventGroup(self, events: "a(isvu)") -> "ai": 191 errors = [] 192 for item_id, event_id, data, timestamp in events: 193 item = self._items.get(item_id) 194 if item and event_id == "clicked" and item.callback: 195 log.info(f"Menu item clicked: {item.label!r} (id={item_id})") 196 item.callback() 197 return errors 198 199 @method() 200 def AboutToShow(self, item_id: "i") -> "b": 201 return False # GetLayout always returns fresh state; no pending unsignaled changes. 202 203 @method() 204 def AboutToShowGroup(self, ids: "ai") -> "aiai": 205 return [[], []] # no updates, no errors 206 207 # ── D-Bus Properties ── 208 209 @dbus_property(access=PropertyAccess.READ) 210 def Version(self) -> "u": 211 return 3 212 213 @dbus_property(access=PropertyAccess.READ) 214 def TextDirection(self) -> "s": 215 return "ltr" 216 217 @dbus_property(access=PropertyAccess.READ) 218 def Status(self) -> "s": 219 return "normal" 220 221 @dbus_property(access=PropertyAccess.READ) 222 def IconThemePath(self) -> "as": 223 return [] 224 225 # ── D-Bus Signals ── 226 227 @dbus_signal() 228 def ItemsPropertiesUpdated(self, updated_props, removed_props) -> "a(ia{sv})a(ias)": 229 return [updated_props, removed_props] 230 231 @dbus_signal() 232 def LayoutUpdated(self, revision, parent) -> "ui": 233 return [revision, parent] 234 235 @dbus_signal() 236 def ItemActivationRequested(self, item_id, timestamp) -> "iu": 237 return [item_id, timestamp] 238 239 240def separator(): 241 """Create a separator menu item.""" 242 return _separator()