linux observer
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3# ruff: noqa: F722, F821
4"""StatusNotifierItem (SNI) implementation over dbus-next.
5
6Implements the org.kde.StatusNotifierItem D-Bus interface for
7registering a tray icon with KDE Plasma's system tray or GNOME's
8AppIndicator extension. Both speak the same protocol.
9
10The tray icon, menu, and tooltip are all rendered by the DE's
11tray host — this code just exposes the data over D-Bus.
12"""
13
14import logging
15
16from dbus_next import PropertyAccess
17from dbus_next.aio import MessageBus
18from dbus_next.service import (
19 ServiceInterface,
20 dbus_property,
21 method,
22 signal as dbus_signal,
23)
24
25log = logging.getLogger(__name__)
26
27
28class StatusNotifierItem(ServiceInterface):
29 """org.kde.StatusNotifierItem D-Bus interface."""
30
31 def __init__(self, app_id: str = "solstone-observer"):
32 super().__init__("org.kde.StatusNotifierItem")
33 self._id = app_id
34 self._category = "ApplicationStatus"
35 self._status = "Active" # Passive, Active, NeedsAttention
36 self._title = "solstone observer"
37 self._icon_name = "solstone-recording"
38 self._icon_accessible_desc = ""
39 self._attention_icon_name = ""
40 self._attention_accessible_desc = ""
41 self._overlay_icon_name = ""
42 self._tooltip_icon = ""
43 self._tooltip_title = "solstone observer"
44 self._tooltip_body = "recording"
45 self._icon_theme_path = ""
46 self._menu_path = "/MenuBar"
47 self._item_is_menu = True
48
49 # Callbacks
50 self.on_activate = None
51 self.on_secondary_activate = None
52 self.on_scroll = None
53
54 # ── Setters that emit change signals ──
55
56 def set_icon(self, icon_name: str):
57 if self._icon_name != icon_name:
58 self._icon_name = icon_name
59 self.NewIcon()
60
61 def set_icon_accessible_desc(self, desc: str):
62 if self._icon_accessible_desc != desc:
63 self._icon_accessible_desc = desc
64 self.emit_properties_changed({"IconAccessibleDesc": desc})
65
66 def set_status(self, status: str):
67 """Set Active, Passive, or NeedsAttention."""
68 if self._status != status:
69 self._status = status
70 self.NewStatus(status)
71
72 def set_tooltip(self, title: str, body: str, icon: str = ""):
73 self._tooltip_title = title
74 self._tooltip_body = body
75 if icon:
76 self._tooltip_icon = icon
77 self.NewToolTip()
78
79 def set_title(self, title: str):
80 if self._title != title:
81 self._title = title
82 self.NewTitle()
83
84 def set_attention_icon(self, icon_name: str):
85 self._attention_icon_name = icon_name
86 self.NewAttentionIcon()
87
88 def set_attention_accessible_desc(self, desc: str):
89 if self._attention_accessible_desc != desc:
90 self._attention_accessible_desc = desc
91 self.emit_properties_changed({"AttentionAccessibleDesc": desc})
92
93 def set_overlay_icon(self, icon_name: str):
94 self._overlay_icon_name = icon_name
95 self.NewOverlayIcon()
96
97 # ── D-Bus Properties ──
98
99 @dbus_property(access=PropertyAccess.READ)
100 def Category(self) -> "s":
101 return self._category
102
103 @dbus_property(access=PropertyAccess.READ)
104 def Id(self) -> "s":
105 return self._id
106
107 @dbus_property(access=PropertyAccess.READ)
108 def Title(self) -> "s":
109 return self._title
110
111 @dbus_property(access=PropertyAccess.READ)
112 def Status(self) -> "s":
113 return self._status
114
115 @dbus_property(access=PropertyAccess.READ)
116 def WindowId(self) -> "i":
117 return 0
118
119 @dbus_property(access=PropertyAccess.READ)
120 def IconName(self) -> "s":
121 return self._icon_name
122
123 @dbus_property(access=PropertyAccess.READ)
124 def IconAccessibleDesc(self) -> "s":
125 return self._icon_accessible_desc
126
127 @dbus_property(access=PropertyAccess.READ)
128 def IconPixmap(self) -> "a(iiay)":
129 return []
130
131 @dbus_property(access=PropertyAccess.READ)
132 def OverlayIconName(self) -> "s":
133 return self._overlay_icon_name
134
135 @dbus_property(access=PropertyAccess.READ)
136 def OverlayIconPixmap(self) -> "a(iiay)":
137 return []
138
139 @dbus_property(access=PropertyAccess.READ)
140 def AttentionIconName(self) -> "s":
141 return self._attention_icon_name
142
143 @dbus_property(access=PropertyAccess.READ)
144 def AttentionAccessibleDesc(self) -> "s":
145 return self._attention_accessible_desc
146
147 @dbus_property(access=PropertyAccess.READ)
148 def AttentionIconPixmap(self) -> "a(iiay)":
149 return []
150
151 @dbus_property(access=PropertyAccess.READ)
152 def AttentionMovieName(self) -> "s":
153 return ""
154
155 @dbus_property(access=PropertyAccess.READ)
156 def ToolTip(self) -> "(sa(iiay)ss)":
157 return [
158 self._tooltip_icon, # icon name
159 [], # icon pixmaps
160 self._tooltip_title, # title
161 self._tooltip_body, # body (supports HTML on KDE)
162 ]
163
164 @dbus_property(access=PropertyAccess.READ)
165 def IconThemePath(self) -> "s":
166 return self._icon_theme_path
167
168 @dbus_property(access=PropertyAccess.READ)
169 def Menu(self) -> "o":
170 return self._menu_path
171
172 @dbus_property(access=PropertyAccess.READ)
173 def ItemIsMenu(self) -> "b":
174 return self._item_is_menu
175
176 # ── D-Bus Methods ──
177
178 @method()
179 def ContextMenu(self, x: "i", y: "i"):
180 log.debug(f"ContextMenu at ({x}, {y})")
181
182 @method()
183 def Activate(self, x: "i", y: "i"):
184 log.debug(f"Activate at ({x}, {y})")
185 if self.on_activate:
186 self.on_activate()
187
188 @method()
189 def SecondaryActivate(self, x: "i", y: "i"):
190 log.debug(f"SecondaryActivate at ({x}, {y})")
191 if self.on_secondary_activate:
192 self.on_secondary_activate()
193
194 @method()
195 def Scroll(self, delta: "i", orientation: "s"):
196 log.debug(f"Scroll delta={delta} orientation={orientation}")
197 if self.on_scroll:
198 self.on_scroll(delta, orientation)
199
200 @method()
201 def ProvideXdgActivationToken(self, token: "s"):
202 log.debug(f"XDG activation token: {token}")
203
204 # ── D-Bus Signals ──
205
206 @dbus_signal()
207 def NewTitle(self):
208 pass
209
210 @dbus_signal()
211 def NewIcon(self):
212 pass
213
214 @dbus_signal()
215 def NewAttentionIcon(self):
216 pass
217
218 @dbus_signal()
219 def NewOverlayIcon(self):
220 pass
221
222 @dbus_signal()
223 def NewToolTip(self):
224 pass
225
226 @dbus_signal()
227 def NewStatus(self, status) -> "s":
228 return status
229
230
231async def register_with_watcher(bus: MessageBus, bus_name: str):
232 """Register our SNI with the StatusNotifierWatcher."""
233 try:
234 introspection = await bus.introspect(
235 "org.kde.StatusNotifierWatcher",
236 "/StatusNotifierWatcher",
237 )
238 proxy = bus.get_proxy_object(
239 "org.kde.StatusNotifierWatcher",
240 "/StatusNotifierWatcher",
241 introspection,
242 )
243 watcher = proxy.get_interface("org.kde.StatusNotifierWatcher")
244 await watcher.call_register_status_notifier_item(bus_name)
245 log.info(f"Registered with StatusNotifierWatcher as {bus_name}")
246 return True
247 except Exception as e:
248 log.warning(f"Failed to register with StatusNotifierWatcher: {e}")
249 log.warning("Is KDE Plasma running, or the AppIndicator extension enabled?")
250 return False