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.56 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
89
connection, _0, path = self.service.rpartition(":")
90
self.menu = None
91
92
try:
93
self.proxy = Gio.DBusProxy.new_sync(
94
self.applet.connection,
95
Gio.DBusProxyFlags.NONE,
96
None,
97
connection,
98
path,
99
"org.kde.StatusNotifierItem",
100
None
101
)
102
self.applet.item_id += 1
103
icon_name = self.proxy.get_cached_property("IconName")
104
if icon_name:
105
icon_name = icon_name.unpack()
106
self.icon.set_from_icon_name(icon_name)
107
menu = self.proxy.get_cached_property("Menu")
108
if menu:
109
self.menu = dbusmenu.CanonicalMenuModel()
110
self.menu.connect("menu_changed", lambda x: panorama_panel.add_icons_to_menu(self.popover_menu, Gtk.IconSize.NORMAL))
111
self.menu.connect_dbus(connection, menu.get_string(), f"ac{self.applet.item_id}")
112
self.insert_action_group(f"ac{self.applet.item_id}", self.menu.actions)
113
self.popover_menu.set_menu_model(self.menu.menu)
114
115
# TODO: NewTitle etc.
116
self.applet.connection.signal_subscribe(
117
connection,
118
"org.kde.StatusNotifierItem",
119
"NewIcon",
120
path,
121
None,
122
Gio.DBusSignalFlags.NONE,
123
self.on_icon_change,
124
)
125
except Exception as e:
126
print(f"Could not get properties from {service}: {e}")
127
traceback.print_exc()
128
129
def on_icon_change(self, *args):
130
icon_name = self.proxy.call_sync(
131
"org.freedesktop.DBus.Properties.Get",
132
GLib.Variant("(ss)", ("org.kde.StatusNotifierItem", "IconName")),
133
Gio.DBusCallFlags.NONE,
134
-1,
135
None
136
)
137
if icon_name:
138
self.icon.set_from_icon_name(icon_name.unpack()[0])
139
140
141
class IndicatorApplet(panorama_panel.Applet):
142
name = _("Indicators")
143
description = _("Show application status icons")
144
icon = Gio.ThemedIcon.new("dialog-warning")
145
146
def shutdown(self, app: Gtk.Application):
147
super().shutdown(app)
148
self.service.unexport(self.connection)
149
150
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
151
super().__init__(orientation=orientation, config=config or {}, **kwargs)
152
self.items = {}
153
self.item_id = 0
154
155
if config is None:
156
config = {}
157
158
self.icon_size = config.get("icon_size", 24)
159
160
self.connection = Gio.bus_get_sync(Gio.BusType.SESSION)
161
162
# Make the host name unique
163
self.bus_name = f"org.kde.StatusNotifierHost-{os.getpid()}-pan-{uuid.uuid4()}"
164
165
self.service = StatusNotifierService(self)
166
self.service.export(self.connection)
167
Gio.bus_own_name(
168
Gio.BusType.SESSION,
169
self.bus_name,
170
Gio.BusNameOwnerFlags.NONE,
171
None,
172
None,
173
None,
174
)
175
176
try:
177
self.watcher = Gio.DBusProxy.new_sync(
178
self.connection,
179
Gio.DBusProxyFlags.NONE,
180
None,
181
"org.kde.StatusNotifierWatcher",
182
"/StatusNotifierWatcher",
183
"org.kde.StatusNotifierWatcher",
184
None,
185
)
186
except Exception as e:
187
print("Could not connect to StatusNotifierWatcher:", e)
188
self.watcher = None
189
190
if self.watcher:
191
try:
192
self.watcher.call_sync(
193
"RegisterStatusNotifierHost",
194
GLib.Variant("(s)", (self.bus_name,)),
195
Gio.DBusCallFlags.NONE,
196
-1,
197
None,
198
)
199
except Exception as e:
200
print("Failed to register host:", e)
201
202
# Add the existing items
203
existing_items = self.watcher.call_sync(
204
"org.freedesktop.DBus.Properties.Get",
205
GLib.Variant("(ss)", ("org.kde.StatusNotifierWatcher", "RegisteredStatusNotifierItems")),
206
Gio.DBusCallFlags.NONE,
207
-1,
208
None
209
)
210
for item in existing_items[0]:
211
self.on_item_registered(item)
212
213
# Watch for new items
214
self.connection.signal_subscribe(
215
None,
216
"org.kde.StatusNotifierWatcher",
217
"StatusNotifierItemRegistered",
218
"/StatusNotifierWatcher",
219
None,
220
Gio.DBusSignalFlags.NONE,
221
self.on_item_registered_signal,
222
)
223
# Watch for vanishing items
224
self.connection.signal_subscribe(
225
None,
226
"org.kde.StatusNotifierWatcher",
227
"StatusNotifierItemUnregistered",
228
"/StatusNotifierWatcher",
229
None,
230
Gio.DBusSignalFlags.NONE,
231
self.on_item_unregistered_signal,
232
)
233
# TODO: watch for things such as icon changes.
234
235
def on_item_registered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
236
(service,) = params.unpack()
237
self.on_item_registered(service)
238
239
def on_item_unregistered_signal(self, conn, sender_name, obj_path, iface_name, signal_name, params):
240
(service,) = params.unpack()
241
self.on_item_unregistered(service)
242
243
def on_item_registered(self, service):
244
icon = StatusIcon(self, service)
245
self.append(icon)
246
self.items[service] = icon
247
248
def on_item_unregistered(self, service):
249
if service in self.items:
250
self.remove(self.items[service])
251
del self.items[service]
252
253
def get_config(self):
254
return {
255
"icon_size": self.icon_size,
256
}
257