By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 __init__.py

View raw Download
text/x-script.python • 8.61 kiB
Python script, ASCII text executable
        
            
1
#!/usr/bin/env python3
2
"""
3
D-Bus app indicator/system tray for the Panorama panel.
4
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
5
6
This program is free software: you can redistribute it and/or modify
7
it under the terms of the GNU General Public Licence as published by
8
the Free Software Foundation, either version 3 of the Licence, or
9
(at your option) any later version.
10
11
This program is distributed in the hope that it will be useful,
12
but WITHOUT ANY WARRANTY; without even the implied warranty of
13
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
GNU General Public Licence for more details.
15
16
You should have received a copy of the GNU General Public Licence
17
along with this program. If not, see <https://www.gnu.org/licenses/>.
18
"""
19
20
from __future__ import annotations
21
import os
22
import traceback
23
import uuid
24
import locale
25
from pathlib import Path
26
27
import gi
28
from ctypes import CDLL
29
CDLL("libdbusmenu-glib.so")
30
gi.require_version("Dbusmenu", "0.4")
31
32
import panorama_panel
33
from gi.repository import Gtk, GLib, Gio
34
35
from . import dbusmenu
36
37
module_directory = Path(__file__).resolve().parent
38
locale.bindtextdomain("panorama-indicator-applet", module_directory / "locale")
39
_ = lambda x: locale.dgettext("panorama-indicator-applet", x)
40
41
42
class StatusNotifierService:
43
"""
44
<node>
45
<interface name="org.kde.StatusNotifierHost">
46
</interface>
47
</node>
48
"""
49
50
def __init__(self, applet: IndicatorApplet):
51
self.bus_id = None
52
self.applet = applet
53
self.xml = """
54
<node>
55
<interface name="org.kde.StatusNotifierHost">
56
</interface>
57
</node>
58
"""
59
self.node_info = Gio.DBusNodeInfo.new_for_xml(self.xml)
60
self.iface_info = self.node_info.interfaces[0]
61
62
def export(self, connection: Gio.DBusConnection):
63
self.bus_id = connection.register_object(
64
"/StatusNotifierHost",
65
self.iface_info,
66
self.handle_method_call,
67
None,
68
None,
69
)
70
71
def unexport(self, connection: Gio.DBusConnection):
72
connection.unregister_object(self.bus_id)
73
74
def handle_method_call(self, conn, sender, obj_path, iface_name, method, params, invocation):
75
# It has no methods
76
invocation.return_value(None)
77
78
79
class StatusIcon(Gtk.MenuButton):
80
def __init__(self, applet: IndicatorApplet, service: str):
81
super().__init__(has_frame=False)
82
self.applet = applet
83
self.service = service
84
self.icon = Gtk.Image(icon_name="image-missing", pixel_size=self.applet.icon_size)
85
self.set_child(self.icon)
86
self.popover_menu = Gtk.PopoverMenu(flags=Gtk.PopoverMenuFlags.NESTED, has_arrow=False, halign=Gtk.Align.START)
87
self.set_popover(self.popover_menu)
88
panorama_panel.track_popover(self.popover_menu)
89
90
connection, _0, path = self.service.rpartition(":")
91
self.menu = None
92
93
try:
94
self.proxy = Gio.DBusProxy.new_sync(
95
self.applet.connection,
96
Gio.DBusProxyFlags.NONE,
97
None,
98
connection,
99
path,
100
"org.kde.StatusNotifierItem",
101
None
102
)
103
self.applet.item_id += 1
104
icon_name = self.proxy.get_cached_property("IconName")
105
if icon_name:
106
icon_name = icon_name.unpack()
107
self.icon.set_from_icon_name(icon_name)
108
menu = self.proxy.get_cached_property("Menu")
109
if menu:
110
self.menu = dbusmenu.CanonicalMenuModel()
111
self.menu.connect("menu_changed", lambda x: panorama_panel.add_icons_to_menu(self.popover_menu, Gtk.IconSize.NORMAL))
112
self.menu.connect_dbus(connection, menu.get_string(), f"ac{self.applet.item_id}")
113
self.insert_action_group(f"ac{self.applet.item_id}", self.menu.actions)
114
self.popover_menu.set_menu_model(self.menu.menu)
115
116
# TODO: NewTitle etc.
117
self.applet.connection.signal_subscribe(
118
connection,
119
"org.kde.StatusNotifierItem",
120
"NewIcon",
121
path,
122
None,
123
Gio.DBusSignalFlags.NONE,
124
self.on_icon_change,
125
)
126
except Exception as e:
127
print(f"Could not get properties from {service}: {e}")
128
traceback.print_exc()
129
130
def on_icon_change(self, *args):
131
icon_name = self.proxy.call_sync(
132
"org.freedesktop.DBus.Properties.Get",
133
GLib.Variant("(ss)", ("org.kde.StatusNotifierItem", "IconName")),
134
Gio.DBusCallFlags.NONE,
135
-1,
136
None
137
)
138
if icon_name:
139
self.icon.set_from_icon_name(icon_name.unpack()[0])
140
141
142
class IndicatorApplet(panorama_panel.Applet):
143
name = _("Indicators")
144
description = _("Show application status icons")
145
icon = Gio.ThemedIcon.new("dialog-warning")
146
147
def shutdown(self, app: Gtk.Application):
148
super().shutdown(app)
149
self.service.unexport(self.connection)
150
151
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
152
super().__init__(orientation=orientation, config=config or {}, **kwargs)
153
self.items = {}
154
self.item_id = 0
155
156
if config is None:
157
config = {}
158
159
self.icon_size = config.get("icon_size", 24)
160
161
self.connection = Gio.bus_get_sync(Gio.BusType.SESSION)
162
163
# Make the host name unique
164
self.bus_name = f"org.kde.StatusNotifierHost-{os.getpid()}-pan-{uuid.uuid4()}"
165
166
self.service = StatusNotifierService(self)
167
self.service.export(self.connection)
168
Gio.bus_own_name(
169
Gio.BusType.SESSION,
170
self.bus_name,
171
Gio.BusNameOwnerFlags.NONE,
172
None,
173
None,
174
None,
175
)
176
177
try:
178
self.watcher = Gio.DBusProxy.new_sync(
179
self.connection,
180
Gio.DBusProxyFlags.NONE,
181
None,
182
"org.kde.StatusNotifierWatcher",
183
"/StatusNotifierWatcher",
184
"org.kde.StatusNotifierWatcher",
185
None,
186
)
187
except Exception as e:
188
print("Could not connect to StatusNotifierWatcher:", e)
189
self.watcher = None
190
191
if self.watcher:
192
try:
193
self.watcher.call_sync(
194
"RegisterStatusNotifierHost",
195
GLib.Variant("(s)", (self.bus_name,)),
196
Gio.DBusCallFlags.NONE,
197
-1,
198
None,
199
)
200
except Exception as e:
201
print("Failed to register host:", e)
202
203
# Add the existing items
204
existing_items = self.watcher.call_sync(
205
"org.freedesktop.DBus.Properties.Get",
206
GLib.Variant("(ss)", ("org.kde.StatusNotifierWatcher", "RegisteredStatusNotifierItems")),
207
Gio.DBusCallFlags.NONE,
208
-1,
209
None
210
)
211
for item in existing_items[0]:
212
self.on_item_registered(item)
213
214
# Watch for new items
215
self.connection.signal_subscribe(
216
None,
217
"org.kde.StatusNotifierWatcher",
218
"StatusNotifierItemRegistered",
219
"/StatusNotifierWatcher",
220
None,
221
Gio.DBusSignalFlags.NONE,
222
self.on_item_registered_signal,
223
)
224
# Watch for vanishing items
225
self.connection.signal_subscribe(
226
None,
227
"org.kde.StatusNotifierWatcher",
228
"StatusNotifierItemUnregistered",
229
"/StatusNotifierWatcher",
230
None,
231
Gio.DBusSignalFlags.NONE,
232
self.on_item_unregistered_signal,
233
)
234
# TODO: watch for things such as icon changes.
235
236
def on_item_registered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
237
(service,) = params.unpack()
238
self.on_item_registered(service)
239
240
def on_item_unregistered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
241
(service,) = params.unpack()
242
self.on_item_unregistered(service)
243
244
def on_item_registered(self, service):
245
icon = StatusIcon(self, service)
246
self.append(icon)
247
self.items[service] = icon
248
249
def on_item_unregistered(self, service):
250
if service in self.items:
251
self.remove(self.items[service])
252
del self.items[service]
253
254
def get_config(self):
255
return {
256
"icon_size": self.icon_size,
257
}
258