linux observer
0
fork

Configure Feed

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

at main 250 lines 7.5 kB view raw
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