Repository: qtile/qtile

Issue:
multiple systray widgets are racy
The Distrotube Guy reports via https://www.youtube.com/watch?v=9NO2DJARbU0 that multiple systrays are racy.

The spec says: https://specifications.freedesktop.org/systemtray-spec/systemtray-spec-0.1.html#locating

that we should use `_NET_SYSETM_TRAY_Sn` for the nth system tray widget, but I think our code calculating this is wrong:

https://github.com/qtile/qtile/blob/master/libqtile/widget/systray.py#L129

This code always uses qtile's current screen, instead of the screen that the bar is on. It should at least use the right value for `n`.

Perhaps instead, we could make multiple Systray() objects be an error, and just have people specify one and let it mirror everywhere.


Discussion:
I suspect we can't add a `Mirror` as the icons are windows rather than being rendered by the widget's `Drawer`.

We can certainly still make multiple `Systray`s be an error.
Even if we could mirror them (which might be possible if we can get the pixmap data... but I doubt it'd be simple), there would also be the question of how they respond to clicks. I assume clients will just respond to icon clicks how they see fit, and so some may open menus at the pointer location (which would be ideal for mirroring the systray) but others may open them from the icon's position perhaps.

Also - mirroring would have to respond to changes in the icon's contents just like `Mirror` except this time it's draws to the window's pixmap. I'm not sure if that can be listened to as an X event.
You could probably make any `Systray.draw()` call, also call the Mirror's `draw` method too but I think the mouse clicks are the bigger issue here as you can't replicate it in the Mirror.

As I understand it, the Systray icons are windows and so respond to mouse clicks in accordance with however that application desires, e.g. toggling state, showing a menu (generated by the application). I don't see how we could get a Mirror's mouse click event to do the same.

So, two things we can do:
- make multiple `Systray` raise an error - probably remove the second one too
- prevent mirroring of `Systray` if someone tries to pass the same object to multiple bars
Yeah that would be good. We can set a boolean `global` in `systray.py` during `Systray._configure()`, and if it's already set, raise the config error.

A bit hacky perhaps but we override `create_mirror` so that it returns itself or another `Systray` we force the config error.

Resolving patch:
diff --git a/libqtile/widget/systray.py b/libqtile/widget/systray.py
--- a/libqtile/widget/systray.py
+++ b/libqtile/widget/systray.py
@@ -33,6 +33,7 @@
 
 from libqtile import bar
 from libqtile.backend.x11 import window
+from libqtile.confreader import ConfigError
 from libqtile.widget import base
 
 if TYPE_CHECKING:
@@ -99,6 +100,9 @@ class Systray(window._Window, base._Widget):
     """
     A widget that manages system tray.
 
+    Only one Systray widget is allowed. Adding additional Systray
+    widgets will result in a ConfigError.
+
     .. note::
         Icons will not render correctly where the bar/widget is
         drawn with a semi-transparent background. Instead, icons
@@ -109,6 +113,8 @@ class Systray(window._Window, base._Widget):
         one.
     """
 
+    _instances = 0
+
     _window_mask = EventMask.StructureNotify | EventMask.Exposure
 
     orientations = base.ORIENTATION_BOTH
@@ -137,6 +143,9 @@ def calculate_length(self):
     def _configure(self, qtile, bar):
         base._Widget._configure(self, qtile, bar)
 
+        if Systray._instances > 0:
+            raise ConfigError("Only one Systray can be used.")
+
         if self.configured:
             return
 
@@ -185,6 +194,17 @@ def _configure(self, qtile, bar):
         )
         qtile.core._root.send_event(event, mask=EventMask.StructureNotify)
 
+        Systray._instances += 1
+
+    def create_mirror(self):
+        """
+        Systray cannot be mirrored as we do not use a Drawer object to render icons.
+
+        Return itself so that, when the bar tries to configure it again, a ConfigError
+        is raised.
+        """
+        return self
+
     def handle_ClientMessage(self, event):  # noqa: N802
         atoms = self.conn.atoms
 
@@ -268,3 +288,5 @@ def finalize(self):
 
         del self.qtile.windows_map[self.wid]
         self.conn.conn.core.DestroyWindow(self.wid)
+
+        Systray._instances -= 1


Test patch:
diff --git a/test/widgets/test_systray.py b/test/widgets/test_systray.py
new file mode 100644
--- /dev/null
+++ b/test/widgets/test_systray.py
@@ -0,0 +1,67 @@
+# Copyright (c) 2022 elParaguayo
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import libqtile.bar
+import libqtile.config
+from libqtile import widget
+
+
+def test_no_duplicates_multiple_instances(manager_nospawn, minimal_conf_noscreen):
+    """Check only one instance of Systray widget."""
+    assert not widget.Systray._instances
+    config = minimal_conf_noscreen
+    config.screens = [
+        libqtile.config.Screen(top=libqtile.bar.Bar([widget.Systray(), widget.Systray()], 10))
+    ]
+
+    manager_nospawn.start(config)
+
+    widgets = manager_nospawn.c.bar["top"].info()["widgets"]
+    assert len(widgets) == 2
+    assert widgets[1]["name"] == "configerrorwidget"
+
+
+def test_no_duplicates_mirror(manager_nospawn, minimal_conf_noscreen):
+    """Check systray is not mirrored."""
+    assert not widget.Systray._instances
+    systray = widget.Systray()
+    config = minimal_conf_noscreen
+    config.fake_screens = [
+        libqtile.config.Screen(
+            top=libqtile.bar.Bar([systray], 10),
+            x=0,
+            y=0,
+            width=300,
+            height=300,
+        ),
+        libqtile.config.Screen(
+            top=libqtile.bar.Bar([systray], 10),
+            x=0,
+            y=300,
+            width=300,
+            height=300,
+        ),
+    ]
+
+    manager_nospawn.start(config)
+
+    # Second screen has tried to mirror the Systray instance
+    widgets = manager_nospawn.c.screen[1].bar["top"].info()["widgets"]
+    assert len(widgets) == 1
+    assert widgets[0]["name"] == "configerrorwidget"