linux observer
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()