From dd399150016121815b80e4f303ab281a426e8576 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:19:41 +0200 Subject: [PATCH 1/6] Fix plugin reload thread leak that froze tray Settings/Plugins load_all() re-imports each plugin as a fresh module but never tore down the previous module's background threads. clipboard_history starts a clipboard watcher + snippet-trigger watcher at register time, so every reload (one fires on each settings save) leaked another pair. The trigger watcher reads input devices in a tight loop, so a few leaked copies saturated the GIL, froze the GTK main loop, and stopped the tray command socket from being serviced -- Settings and Plugins menu items silently stopped responding. Add a generic unregister() teardown hook: plugin_loader tracks loaded user modules and calls each module's optional unregister() before re-importing. clipboard_history.unregister() stops both watchers. --- plugin_loader.py | 25 +++++++++++++++++++++++++ plugins_repo/clipboard_history.py | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/plugin_loader.py b/plugin_loader.py index 683fd92..cd23e9b 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -21,6 +21,13 @@ _PLUGINS: List[Plugin] = [] +# Module names of the user plugins loaded by the previous _load_user_plugins() +# run. Before each reload we call every one's optional unregister() hook so a +# plugin that starts a background thread (e.g. clipboard_history's watchers) +# can stop it - otherwise each reload, and one fires on every settings save, +# leaked another live thread. +_USER_MODULE_NAMES: set[str] = set() + USER_PLUGIN_DIR = CONFIG_DIR / "plugins" LINUXPOP_DIR = str(Path(__file__).resolve().parent) REPO_PLUGIN_DIR = Path(LINUXPOP_DIR) / "plugins_repo" @@ -372,9 +379,25 @@ def _seed_default_plugins() -> None: _PLUGIN_SEED_MARKER.touch() +def _teardown_user_plugins() -> None: + """Call the optional unregister() hook on every user plugin loaded last + time, so its background threads stop before we re-import. Also catches + plugins whose file was removed since the previous load.""" + for mod_name in _USER_MODULE_NAMES: + hook = getattr(sys.modules.get(mod_name), "unregister", None) + if callable(hook): + try: + hook() + except Exception: + print(f"[plugin_loader] {mod_name}.unregister() failed:") + traceback.print_exc() + _USER_MODULE_NAMES.clear() + + def _load_user_plugins() -> None: _ensure_on_path() _seed_default_plugins() + _teardown_user_plugins() if not USER_PLUGIN_DIR.is_dir(): return for path in sorted(USER_PLUGIN_DIR.glob("*.py")): @@ -390,6 +413,8 @@ def _load_user_plugins() -> None: # introspection needs cls.__module__ to resolve. sys.modules[mod_name] = module spec.loader.exec_module(module) + # Track it so the next reload can call its unregister() hook. + _USER_MODULE_NAMES.add(mod_name) if hasattr(module, "register"): # Wrap register so we can track which source file each # plugin came from - the Plugin Manager uses this to diff --git a/plugins_repo/clipboard_history.py b/plugins_repo/clipboard_history.py index cb60b13..a180ac1 100644 --- a/plugins_repo/clipboard_history.py +++ b/plugins_repo/clipboard_history.py @@ -3248,6 +3248,30 @@ def _open_from_popup(_text: str) -> None: open_picker(target) +def unregister() -> None: + """Stop this module's background threads before a plugin reload. + + plugin_loader.load_all() re-imports each plugin as a *fresh* module + object, so without an explicit teardown the previous module's clipboard + watcher and snippet-trigger watcher kept running. Every reload - and one + fires on each settings save - leaked another pair of threads. The trigger + watcher reads input devices in a tight loop, so a handful of leaked copies + saturated the GIL, froze the GTK main loop, and silently killed the tray's + command socket (Settings / Plugins menu items stopped responding). The + loader calls this on the outgoing module just before the new one runs.""" + global _trigger_watcher, _watcher_thread + if _trigger_watcher is not None: + try: + _trigger_watcher.stop() + except Exception: # noqa: BLE001 + pass + _trigger_watcher = None + # Ends _watcher_loop() (it polls _watcher_stop); the thread is a daemon + # so it unwinds on its own within one select/poll interval. + _watcher_stop.set() + _watcher_thread = None + + def register(register_plugin) -> None: # Honour the master kill-switch so the user can turn the entire # clipboard plugin off (no watcher thread, no hotkey, no popup From 2c62282ce7543424859111b2845f906f1088ddfc Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:33:30 +0200 Subject: [PATCH 2/6] Tray: show menu on left-click via custom StatusNotifierItem (ItemIsMenu=true) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt's QSystemTrayIcon hardcodes the SNI ItemIsMenu property to false and offers no API to change it, so on KDE Wayland left-click could not surface the menu: plasmashell sent Activate() instead of showing the menu, and a client-side QMenu.popup() never maps on Wayland. Right-click worked only because plasmashell renders the DBusMenu itself. Replace the Qt tray subprocess with a hand-rolled StatusNotifierItem + com.canonical.dbusmenu implemented in dbus-python (already bundled for KGlobalAccel), advertising ItemIsMenu=true. plasmashell then renders our menu on both left- and right-click. Icon is rasterised with GdkPixbuf (IconPixmap for the light/dark styles, IconName for colour); the length-prefixed JSON socket protocol to the daemon is unchanged. tray.py prefers tray_dbus.py and falls back to tray_qt.py if absent. Verified live: ItemIsMenu=true on the running item, full menu served via GetLayout, and a synthetic DBusMenu clicked event on "Settings…" reached the daemon ("opening settings dialog…"). --- tray.py | 16 +- tray_dbus.py | 608 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 tray_dbus.py diff --git a/tray.py b/tray.py index c20e3a4..42a2844 100644 --- a/tray.py +++ b/tray.py @@ -17,7 +17,14 @@ from typing import Callable from xdg_paths import CACHE_DIR as SOCKET_DIR -TRAY_SCRIPT = str(Path(__file__).resolve().parent / "tray_qt.py") +# Custom StatusNotifierItem (dbus-python) that advertises ItemIsMenu=true so +# plasmashell shows the menu on left-click too. Falls back to the Qt +# QSystemTrayIcon implementation (right-click only on KDE Wayland) if the +# dbus-python tray is unavailable. +_TRAY_DIR = Path(__file__).resolve().parent +TRAY_SCRIPT = str(_TRAY_DIR / "tray_dbus.py") +if not os.path.isfile(TRAY_SCRIPT): + TRAY_SCRIPT = str(_TRAY_DIR / "tray_qt.py") def _tray_preexec() -> None: @@ -106,14 +113,14 @@ def __init__( self._connected = False if not os.path.isfile(TRAY_SCRIPT): - print("[tray] tray_qt.py not found - tray disabled") + print(f"[tray] {os.path.basename(TRAY_SCRIPT)} not found - tray disabled") return self._start_process() self._connect() def _start_process(self) -> None: - """Launch the Qt tray subprocess.""" + """Launch the tray subprocess.""" SOCKET_DIR.mkdir(parents=True, exist_ok=True) # Remove stale socket try: @@ -127,7 +134,8 @@ def _start_process(self) -> None: stderr=subprocess.STDOUT, preexec_fn=_tray_preexec, # die with parent + own session ) - print(f"[tray] spawned Qt tray process (pid={self._proc.pid})") + print(f"[tray] spawned tray process {os.path.basename(TRAY_SCRIPT)} " + f"(pid={self._proc.pid})") except OSError as exc: print(f"[tray] failed to start tray process: {exc}") self._proc = None diff --git a/tray_dbus.py b/tray_dbus.py new file mode 100644 index 0000000..ada96b5 --- /dev/null +++ b/tray_dbus.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python3 +"""LinuxPop tray icon - hand-rolled StatusNotifierItem (dbus-python + GLib). + +Why not QSystemTrayIcon? Qt hardcodes the SNI `ItemIsMenu` property to +false (see QStatusNotifierItemAdaptor::itemIsMenu in qtbase), and there is +no public API to change it. With ItemIsMenu=false the host (plasmashell) +sends Activate() on left-click instead of showing the menu, and a +client-side QMenu.popup() never maps on KDE Wayland - so left-click could +not surface the menu. KStatusNotifierItem.setIsMenu(true) is the documented +fix but it has no PySide6 binding and hit a Fedora D-Bus registration bug. + +So we implement the StatusNotifierItem + com.canonical.dbusmenu ourselves +with **ItemIsMenu=true**, which tells plasmashell to render our context menu +on BOTH left- and right-click. dbus-python is already bundled (the Wayland +backend uses it for KGlobalAccel) and marshals the nested DBusMenu types far +more painlessly than QtDBus. The icon is rasterised with GdkPixbuf; the +length-prefixed JSON socket protocol to the main daemon is unchanged. +""" +from __future__ import annotations + +import json +import os +import signal +import socket +import struct +import sys +from pathlib import Path + +import gi +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import GdkPixbuf, GLib # noqa: E402 + +import dbus # noqa: E402 +import dbus.service # noqa: E402 +from dbus.mainloop.glib import DBusGMainLoop # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from xdg_paths import CACHE_DIR as SOCKET_DIR, CONFIG_DIR # noqa: E402 + +ICON_DIR = Path(__file__).resolve().parent / "icons" +SETTINGS_FILE = CONFIG_DIR / "settings.json" + +SNI_IFACE = "org.kde.StatusNotifierItem" +MENU_IFACE = "com.canonical.dbusmenu" +PROPS_IFACE = "org.freedesktop.DBus.Properties" +WATCHER_NAME = "org.kde.StatusNotifierWatcher" +WATCHER_PATH = "/StatusNotifierWatcher" +SNI_PATH = "/StatusNotifierItem" +MENU_PATH = "/MenuBar" + + +# ─── settings / icon ──────────────────────────────────────────────── + +def _tray_icon_style() -> str: + """'color' (coloured badge, default), 'light' (monochrome for dark + panels) or 'dark' (monochrome for light panels).""" + try: + d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + v = str(d.get("tray_icon_style", "color")).strip().lower() + return v if v in ("color", "light", "dark") else "color" + except Exception: + return "color" + + +def _pixbuf_to_argb(pb: "GdkPixbuf.Pixbuf") -> bytes: + """GdkPixbuf RGBA -> SNI IconPixmap ARGB32 in network (big-endian) order.""" + if not pb.get_has_alpha(): + pb = pb.add_alpha(False, 0, 0, 0) + w, h, stride = pb.get_width(), pb.get_height(), pb.get_rowstride() + src = pb.get_pixels() + out = bytearray(w * h * 4) + o = 0 + for y in range(h): + row = y * stride + for x in range(w): + i = row + x * 4 + r, g, b, a = src[i], src[i + 1], src[i + 2], src[i + 3] + out[o] = a + out[o + 1] = r + out[o + 2] = g + out[o + 3] = b + o += 4 + return bytes(out) + + +def _render_icon_pixmaps() -> "dbus.Array": + """Build the SNI IconPixmap array a(iiay) for the current style. + + color -> the full-colour brand SVG; light/dark -> the monochrome + symbolic SVG recoloured to a fixed panel-appropriate colour (plasmashell + won't recolour custom symbolic icons reliably, so we bake the colour in). + """ + style = _tray_icon_style() + if style == "color": + src = ICON_DIR / "linuxpop.svg" + svg = src.read_text(encoding="utf-8") if src.is_file() else "" + else: + src = ICON_DIR / "linuxpop-tray-symbolic.svg" + color = "#f4f5f6" if style == "light" else "#2a2e32" + svg = (src.read_text(encoding="utf-8").replace("currentColor", color) + if src.is_file() else "") + pixmaps = dbus.Array([], signature="(iiay)") + if not svg: + return pixmaps + raw = svg.encode("utf-8") + for size in (16, 22, 24, 32, 48): + try: + loader = GdkPixbuf.PixbufLoader.new_with_type("svg") + loader.set_size(size, size) + loader.write(raw) + loader.close() + pb = loader.get_pixbuf() + if pb is None: + continue + argb = _pixbuf_to_argb(pb) + pixmaps.append(dbus.Struct( + (dbus.Int32(pb.get_width()), dbus.Int32(pb.get_height()), + dbus.ByteArray(argb)), signature="iiay")) + except Exception as exc: # noqa: BLE001 + print(f"[tray-dbus] icon render {size}px failed: {exc}", flush=True) + return pixmaps + + +# ─── menu model ───────────────────────────────────────────────────── +# id 0 is the root. Each entry maps to a com.canonical.dbusmenu item. The +# `event` is the message sent to the main daemon when the item is clicked. + +SEP = {"kind": "separator"} + + +def _build_menu_model() -> list[dict]: + return [ + {"id": 1, "label": "LinuxPop", "enabled": False}, + {"id": 2, **SEP}, + {"id": 3, "label": "Auto-popup on selection", "toggle": True, + "checked": True, "event": "toggle_watcher"}, + {"id": 4, "label": "Show popup now", "event": "show_popup"}, + {"id": 5, **SEP}, + {"id": 6, "label": "Settings…", "event": "settings"}, + {"id": 7, "label": "Plugins…", "event": "plugins"}, + {"id": 8, "label": "About LinuxPop", "event": "about"}, + {"id": 9, "label": "Support LinuxPop…", "event": "support"}, + {"id": 10, "label": "Contact on GitHub…", + "url": "https://github.com/GaimsDevSoftware/linuxpop/issues"}, + {"id": 11, **SEP}, + {"id": 12, "label": "Quit LinuxPop", "event": "quit"}, + ] + + +# ─── socket wire helpers (unchanged protocol) ─────────────────────── + +def _send_message(sock: socket.socket, msg: dict) -> None: + raw = json.dumps(msg).encode("utf-8") + sock.sendall(struct.pack("!I", len(raw)) + raw) + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("Socket closed") + buf += chunk + return buf + + +def _recv_message(sock: socket.socket) -> dict | None: + raw_len = _recv_exact(sock, 4) + msg_len = struct.unpack("!I", raw_len)[0] + if msg_len > 1_000_000: + return None + return json.loads(_recv_exact(sock, msg_len).decode("utf-8")) + + +# ─── DBusMenu object ──────────────────────────────────────────────── + +class DBusMenu(dbus.service.Object): + """Minimal com.canonical.dbusmenu the host renders itself.""" + + def __init__(self, bus, on_event): + super().__init__(bus, MENU_PATH) + self._on_event = on_event + self._items = _build_menu_model() + self._revision = 1 + + # -- helpers -- + def _item(self, item_id): + for it in self._items: + if it["id"] == item_id: + return it + return None + + def _props(self, it, names): + if it.get("kind") == "separator": + p = {"type": dbus.String("separator")} + else: + p = {"label": dbus.String(it.get("label", "")), + "enabled": dbus.Boolean(it.get("enabled", True)), + "visible": dbus.Boolean(True)} + if it.get("toggle"): + p["toggle-type"] = dbus.String("checkmark") + p["toggle-state"] = dbus.Int32(1 if it.get("checked") else 0) + if names: + p = {k: v for k, v in p.items() if k in names} + return dbus.Dictionary(p, signature="sv") + + def _leaf(self, it, names): + return dbus.Struct( + (dbus.Int32(it["id"]), self._props(it, names), + dbus.Array([], signature="v")), signature="(ia{sv}av)") + + def set_checked(self, item_id, checked): + it = self._item(item_id) + if it is None: + return + it["checked"] = bool(checked) + self.ItemsPropertiesUpdated( + dbus.Array([dbus.Struct( + (dbus.Int32(item_id), + dbus.Dictionary( + {"toggle-state": dbus.Int32(1 if checked else 0)}, + signature="sv")), signature="(ia{sv})")], + signature="(ia{sv})"), + dbus.Array([], signature="(ias)")) + + # -- interface methods -- + @dbus.service.method(MENU_IFACE, in_signature="iias", + out_signature="u(ia{sv}av)") + def GetLayout(self, parentId, recursionDepth, propertyNames): + names = list(propertyNames) + if parentId == 0: + children = dbus.Array( + [self._leaf(it, names) for it in self._items], signature="v") + root = dbus.Struct( + (dbus.Int32(0), + dbus.Dictionary({"children-display": dbus.String("submenu")}, + signature="sv"), + children), signature="(ia{sv}av)") + return dbus.UInt32(self._revision), root + it = self._item(parentId) + if it is None: + it = {"id": parentId} + return dbus.UInt32(self._revision), self._leaf(it, names) + + @dbus.service.method(MENU_IFACE, in_signature="aias", + out_signature="a(ia{sv})") + def GetGroupProperties(self, ids, propertyNames): + names = list(propertyNames) + want = list(ids) if ids else [it["id"] for it in self._items] + out = dbus.Array([], signature="(ia{sv})") + for item_id in want: + it = self._item(item_id) + if it is not None: + out.append(dbus.Struct( + (dbus.Int32(item_id), self._props(it, names)), + signature="(ia{sv})")) + return out + + @dbus.service.method(MENU_IFACE, in_signature="is", out_signature="v") + def GetProperty(self, item_id, name): + it = self._item(item_id) or {} + return self._props(it, [name]).get(name, dbus.String("")) + + @dbus.service.method(MENU_IFACE, in_signature="isvu", out_signature="") + def Event(self, item_id, eventId, data, timestamp): + if str(eventId) != "clicked": + return + it = self._item(int(item_id)) + if it is None: + return + if it.get("toggle"): + it["checked"] = not it.get("checked") + self.set_checked(it["id"], it["checked"]) + self._on_event(it.get("event"), it["checked"]) + elif it.get("url"): + self._on_event("__open_url__", it["url"]) + elif it.get("event"): + self._on_event(it["event"], None) + + @dbus.service.method(MENU_IFACE, in_signature="a(isvu)", + out_signature="ai") + def EventGroup(self, events): + for ev in events: + try: + self.Event(ev[0], ev[1], ev[2], ev[3]) + except Exception: # noqa: BLE001 + pass + return dbus.Array([], signature="i") + + @dbus.service.method(MENU_IFACE, in_signature="i", out_signature="b") + def AboutToShow(self, item_id): + return dbus.Boolean(False) + + @dbus.service.method(MENU_IFACE, in_signature="ai", + out_signature="aiai") + def AboutToShowGroup(self, ids): + return (dbus.Array([], signature="i"), dbus.Array([], signature="i")) + + # -- properties -- + @dbus.service.method(PROPS_IFACE, in_signature="ss", out_signature="v") + def Get(self, iface, prop): + return self.GetAll(iface).get(prop, dbus.String("")) + + @dbus.service.method(PROPS_IFACE, in_signature="s", out_signature="a{sv}") + def GetAll(self, iface): + return dbus.Dictionary({ + "Version": dbus.UInt32(3), + "Status": dbus.String("normal"), + "TextDirection": dbus.String("ltr"), + "IconThemePath": dbus.Array([], signature="s"), + }, signature="sv") + + # -- signals -- + @dbus.service.signal(MENU_IFACE, signature="a(ia{sv})a(ias)") + def ItemsPropertiesUpdated(self, updated, removed): + pass + + @dbus.service.signal(MENU_IFACE, signature="ui") + def LayoutUpdated(self, revision, parent): + pass + + @dbus.service.signal(MENU_IFACE, signature="iu") + def ItemActivationRequested(self, item_id, timestamp): + pass + + +# ─── StatusNotifierItem object ────────────────────────────────────── + +class StatusNotifierItem(dbus.service.Object): + def __init__(self, bus, menu: DBusMenu, on_event): + super().__init__(bus, SNI_PATH) + self._menu = menu + self._on_event = on_event + self._pixmaps = _render_icon_pixmaps() + self._style = _tray_icon_style() + + def reload_icon(self): + self._pixmaps = _render_icon_pixmaps() + self._style = _tray_icon_style() + try: + self.NewIcon() + except Exception: # noqa: BLE001 + pass + + # -- SNI methods (host -> us). With ItemIsMenu=true the host shows the + # menu itself, but we keep Activate as a fallback that asks the menu + # host to open via the standard activation request. -- + @dbus.service.method(SNI_IFACE, in_signature="ii", out_signature="") + def Activate(self, x, y): + # With ItemIsMenu=true the host shows the menu itself on left-click and + # never calls this. Hosts that ignore ItemIsMenu would call Activate - + # but no Wayland protocol lets us pop a menu at the cursor, so there's + # nothing useful to do; leave it a no-op rather than misbehave. + pass + + @dbus.service.method(SNI_IFACE, in_signature="ii", out_signature="") + def SecondaryActivate(self, x, y): + self._on_event("show_popup", None) + + @dbus.service.method(SNI_IFACE, in_signature="ii", out_signature="") + def ContextMenu(self, x, y): + pass # host renders the DBusMenu itself + + @dbus.service.method(SNI_IFACE, in_signature="is", out_signature="") + def Scroll(self, delta, orientation): + pass + + # -- properties -- + @dbus.service.method(PROPS_IFACE, in_signature="ss", out_signature="v") + def Get(self, iface, prop): + return self.GetAll(iface).get(prop, dbus.String("")) + + @dbus.service.method(PROPS_IFACE, in_signature="s", out_signature="a{sv}") + def GetAll(self, iface): + # color -> themed IconName. light/dark -> embedded IconPixmap (works in + # the Flatpak sandbox, where the host can't read our private icon + # files). If pixmap rasterising ever yields nothing, fall back to the + # themed name so the item is never iconless. + icon_name = ("linuxpop" + if self._style == "color" or len(self._pixmaps) == 0 + else "") + return dbus.Dictionary({ + "Category": dbus.String("ApplicationStatus"), + "Id": dbus.String("linuxpop-tray"), + "Title": dbus.String("LinuxPop"), + "Status": dbus.String("Active"), + "WindowId": dbus.Int32(0), + "IconName": dbus.String(icon_name), + "IconPixmap": self._pixmaps, + "OverlayIconName": dbus.String(""), + "AttentionIconName": dbus.String(""), + "ToolTip": dbus.Struct( + (dbus.String(""), dbus.Array([], signature="(iiay)"), + dbus.String("LinuxPop - clipboard popup assistant"), + dbus.String("")), signature="(sa(iiay)ss)"), + # The whole point: tells the host to show the menu on left-click. + "ItemIsMenu": dbus.Boolean(True), + "Menu": dbus.ObjectPath(MENU_PATH), + }, signature="sv") + + @dbus.service.method(PROPS_IFACE, in_signature="ssv", out_signature="") + def Set(self, iface, prop, value): + pass + + # -- signals the host listens for -- + @dbus.service.signal(SNI_IFACE, signature="") + def NewIcon(self): + pass + + @dbus.service.signal(SNI_IFACE, signature="") + def NewStatus(self): + pass + + @dbus.service.signal(SNI_IFACE, signature="") + def NewToolTip(self): + pass + + +# ─── tray app ─────────────────────────────────────────────────────── + +class TrayDBus: + def __init__(self) -> None: + DBusGMainLoop(set_as_default=True) + self._bus = dbus.SessionBus() + self._loop = GLib.MainLoop() + + self._install_icon() + + self._menu = DBusMenu(self._bus, self._on_event) + self._sni = StatusNotifierItem(self._bus, self._menu, self._on_event) + # Register with the watcher using our UNIQUE connection name (":1.x"), + # not a well-known "org.kde.StatusNotifierItem-PID-1" name. We always + # own our unique name, so this needs no D-Bus name-ownership permission + # - which is exactly why it works inside the Flatpak sandbox (owning an + # org.kde.* well-known name would be denied by the bus proxy). This is + # the same approach Qt's QSystemTrayIcon uses. plasmashell introspects + # /StatusNotifierItem on whatever service string we pass. + self._service = self._bus.get_unique_name() + self._registered = False + self._register_with_watcher() + # Re-register if the watcher (kded/plasmashell) restarts. + self._bus.watch_name_owner(WATCHER_NAME, self._on_watcher_owner) + + # daemon socket + self._client: socket.socket | None = None + self._server: socket.socket | None = None + self._setup_socket() + + self._initial_ppid = os.getppid() + GLib.timeout_add(2000, self._check_parent_alive) + + print("[tray-dbus] started (StatusNotifierItem, ItemIsMenu=true)", + flush=True) + + # -- icon install (so IconName='linuxpop' resolves) -- + def _install_icon(self) -> None: + import shutil + user_dir = Path.home() / ".local/share/icons/hicolor/scalable/apps" + try: + user_dir.mkdir(parents=True, exist_ok=True) + for name in ("linuxpop", "linuxpop-tray-symbolic"): + src = ICON_DIR / f"{name}.svg" + if src.is_file(): + shutil.copy2(src, user_dir / f"{name}.svg") + except OSError: + pass + + # -- watcher registration -- + def _register_with_watcher(self) -> None: + try: + watcher = self._bus.get_object(WATCHER_NAME, WATCHER_PATH) + watcher.RegisterStatusNotifierItem( + self._service, dbus_interface=WATCHER_NAME) + self._registered = True + print("[tray-dbus] registered with StatusNotifierWatcher", + flush=True) + except dbus.DBusException as exc: + self._registered = False + print(f"[tray-dbus] watcher registration failed: {exc}", flush=True) + + def _on_watcher_owner(self, owner: str) -> None: + # Fires once with the current owner (already handled by the explicit + # call above) and again whenever the watcher restarts. Only act on a + # genuine (re)appearance we haven't registered with yet. + if owner and not self._registered: + self._register_with_watcher() + elif not owner: + self._registered = False + + # -- daemon socket IPC -- + def _setup_socket(self) -> None: + SOCKET_DIR.mkdir(parents=True, exist_ok=True) + path = str(SOCKET_DIR / "tray.sock") + try: + os.unlink(path) + except OSError: + pass + self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._server.bind(path) + self._server.listen(1) + self._server.setblocking(False) + (SOCKET_DIR / "tray.info").write_text(f"{path}\n{os.getpid()}\n") + GLib.io_add_watch(self._server.fileno(), GLib.IO_IN, self._on_accept) + + def _on_accept(self, *_a) -> bool: + try: + client, _ = self._server.accept() + except OSError: + return True + if self._client: + try: + self._client.close() + except OSError: + pass + self._client = client + self._client.setblocking(False) + GLib.io_add_watch(self._client.fileno(), + GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR, + self._on_client_data) + return True # keep listening + + def _on_client_data(self, _fd, condition) -> bool: + if condition & (GLib.IO_HUP | GLib.IO_ERR): + self._disconnect_client() + return False + try: + msg = _recv_message(self._client) + except (ConnectionError, OSError, BlockingIOError, json.JSONDecodeError): + self._disconnect_client() + return False + if msg is not None: + self._handle_command(msg) + return True + + def _disconnect_client(self) -> None: + if self._client: + try: + self._client.close() + except OSError: + pass + self._client = None + + def _handle_command(self, msg: dict) -> None: + cmd = msg.get("cmd") + if cmd == "set_watcher_active": + self._menu.set_checked(3, bool(msg.get("value", True))) + elif cmd == "reload_icon": + self._sni.reload_icon() + elif cmd == "quit": + self._loop.quit() + elif cmd == "ping" and self._client: + try: + _send_message(self._client, {"event": "pong", "value": None}) + except OSError: + pass + + # -- events from menu -> main daemon -- + def _on_event(self, event: str | None, value) -> None: + if event == "__open_url__": + self._open_url(value) + return + if event == "quit": + # tell the daemon, then exit ourselves + self._emit("quit", None) + GLib.timeout_add(200, lambda: (self._loop.quit(), False)[1]) + return + if event: + self._emit(event, value) + + def _emit(self, event: str, value) -> None: + if self._client: + try: + _send_message(self._client, {"event": event, "value": value}) + except OSError: + self._disconnect_client() + + def _open_url(self, url: str) -> None: + try: + GLib.spawn_async(["xdg-open", url], + flags=GLib.SpawnFlags.SEARCH_PATH) + except Exception as exc: # noqa: BLE001 + print(f"[tray-dbus] xdg-open failed: {exc}", flush=True) + + def _check_parent_alive(self) -> bool: + try: + if os.getppid() != self._initial_ppid: + print("[tray-dbus] parent daemon gone -- exiting", flush=True) + self._loop.quit() + return False + except Exception: # noqa: BLE001 + pass + return True + + def run(self) -> None: + try: + self._loop.run() + finally: + self._disconnect_client() + if self._server: + try: + self._server.close() + except OSError: + pass + + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + TrayDBus().run() From 90c910ec207b97aa973231cc31db78d84aca76bf Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:26:06 +0200 Subject: [PATCH 3/6] Plugin Manager: fix drag-and-drop and right-click reordering in the Order tab Both reorder gestures were no-ops on GTK3/Handy: - The drag source was set on the whole HdyActionRow, but the enclosing GtkListBox claims the button-1 press for row activation, so the drag never started. Move the drag source to the drag handle (the gripin its own GtkEventBox), which reliably begins the drag; the row stays the drop target. - Right-click used a GestureMultiPress on each row, which the listbox can swallow. Replace it with a single button-press-event handler on the parent GtkListBox, resolving the target row via get_row_at_y() - the canonical, reliable pattern. --- plugin_manager.py | 159 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/plugin_manager.py b/plugin_manager.py index 894b5d8..a43e3b4 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -201,6 +201,7 @@ def __init__(self, on_changed: Callable[[], None] | None = None) -> None: self._catalog_page: Handy.PreferencesPage | None = None self._installed_group: Handy.PreferencesGroup | None = None self._order_group: Handy.PreferencesGroup | None = None + self._order_listbox = None self._custom_group: Handy.PreferencesGroup | None = None def show(self, tab: str | None = None) -> None: @@ -744,9 +745,10 @@ def _build_order_group(self) -> Handy.PreferencesGroup: group = Handy.PreferencesGroup() group.set_title("Popup button order") group.set_description( - "Drag-free reordering: ↑ and ↓ buttons move a plugin up or down. " - "The popup shows plugins in this order (those not listed fall back " - "to their built-in priority)." + "Drag a plugin by its handle (≡) to drop it anywhere in the list, " + "or use the ↑ / ↓ buttons for one step at a time. The popup shows " + "plugins in this order (those not listed fall back to their " + "built-in priority)." ) self._fill_order_group(group) return group @@ -777,10 +779,19 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: ), ) + # Full ordered name list, kept in sync for drag-and-drop + arrow moves. + self._order_names = [p.name for p in sorted_plugins] + self._order_listbox = None # set to the rows' parent GtkListBox below + dnd_targets = [Gtk.TargetEntry.new( + "LINUXPOP_ORDER_ROW", Gtk.TargetFlags.SAME_APP, 0)] + for index, plugin in enumerate(sorted_plugins): row = Handy.ActionRow() row.set_title(plugin.tooltip or plugin.name) row.set_subtitle(plugin.name) + row._order_index = index + row._order_icon = plugin.icon + row._plugin_name = plugin.name try: img = Gtk.Image.new_from_icon_name(plugin.icon, Gtk.IconSize.LARGE_TOOLBAR) img.set_pixel_size(20) @@ -788,6 +799,35 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: except Exception: pass + # Drag-and-drop: the DRAG HANDLE (≡) is the drag source; the whole + # row is the drop target. The handle lives in its own EventBox so + # button-1 starts a drag immediately - putting the drag source on + # the row instead lets the enclosing GtkListBox claim the press for + # row activation, so the drag never begins. The ↑/↓ buttons stay + # for precise / keyboard-driven moves. + try: + theme = Gtk.IconTheme.get_default() + if theme.has_icon("list-drag-handle-symbolic"): + grip_img = Gtk.Image.new_from_icon_name( + "list-drag-handle-symbolic", Gtk.IconSize.BUTTON) + else: + grip_img = Gtk.Label(label="⣿") # braille block, grip-like + grip_img.get_style_context().add_class("dim-label") + grip = Gtk.EventBox() + grip.add(grip_img) + grip.set_valign(Gtk.Align.CENTER) + grip.set_tooltip_text("Drag to reorder") + grip.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, dnd_targets, + Gdk.DragAction.MOVE) + grip.connect("drag-begin", self._on_order_drag_begin, row) + grip.connect("drag-data-get", self._on_order_drag_data_get, row) + row.drag_dest_set(Gtk.DestDefaults.ALL, dnd_targets, + Gdk.DragAction.MOVE) + row.connect("drag-data-received", self._on_order_drag_data_received) + row.add(grip) + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] drag-reorder setup failed: {exc}") + up_btn = Gtk.Button.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON) up_btn.set_valign(Gtk.Align.CENTER) up_btn.set_sensitive(index > 0) @@ -803,6 +843,20 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: row.add(down_btn) group.add(row) + if self._order_listbox is None: + self._order_listbox = row.get_parent() + + # Right-click context menu (move to top/bottom, hide). One handler on + # the parent GtkListBox is far more reliable than per-row gestures, + # which the listbox can swallow; resolve the target row via + # get_row_at_y(). The listbox always delivers button-press-event. + if self._order_listbox is not None: + try: + self._order_listbox.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self._order_listbox.connect( + "button-press-event", self._on_order_listbox_button) + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] context-menu setup failed: {exc}") def _refresh_order_group(self) -> None: if self._order_group is None: @@ -819,14 +873,16 @@ def _on_move(self, _btn, index: int, delta: int, sorted_plugins) -> None: new_idx = index + delta if new_idx < 0 or new_idx >= len(sorted_plugins): return - ordered_names = [p.name for p in sorted_plugins] - ordered_names[index], ordered_names[new_idx] = ( - ordered_names[new_idx], ordered_names[index], - ) + names = [p.name for p in sorted_plugins] + names[index], names[new_idx] = names[new_idx], names[index] + self._persist_order(names) + + def _persist_order(self, ordered_names) -> None: + """Save the full plugin order, then refresh the list and the popup.""" try: from settings import get_settings s = get_settings() - s.set("plugin_order", ordered_names) + s.set("plugin_order", list(ordered_names)) s.save() except Exception as exc: # noqa: BLE001 print(f"[plugin_manager] could not save order: {exc}") @@ -835,6 +891,93 @@ def _on_move(self, _btn, index: int, delta: int, sorted_plugins) -> None: if self._on_changed: self._on_changed() + # ---- drag-and-drop reordering ------------------------------------------- + def _on_order_drag_begin(self, _grip, context, row) -> None: + # Show the plugin's own icon under the cursor while dragging. + try: + Gtk.drag_set_icon_name( + context, getattr(row, "_order_icon", "view-list-symbolic"), 8, 8) + except Exception: + pass + + def _on_order_drag_data_get(self, _grip, context, data, info, time, row) -> None: + data.set_text(str(getattr(row, "_order_index", -1)), -1) + + def _on_order_drag_data_received(self, row, context, x, y, data, info, time) -> None: + try: + src = int(data.get_text()) + except (TypeError, ValueError): + return + dst = getattr(row, "_order_index", -1) + names = list(getattr(self, "_order_names", []) or []) + if not (0 <= src < len(names)) or not (0 <= dst < len(names)) or src == dst: + return + # Move the dragged plugin to the dropped-on row's position. + names.insert(dst, names.pop(src)) + self._persist_order(names) + + # ---- right-click context menu ------------------------------------------- + def _on_order_listbox_button(self, listbox, event) -> bool: + if event.button != 3: # only right-click opens the context menu + return False + row = listbox.get_row_at_y(int(event.y)) + name = getattr(row, "_plugin_name", None) if row is not None else None + if not name: + return False + menu = Gtk.Menu() + menu.attach_to_widget(row, None) # tie lifetime to the row + row._ctx_menu = menu # and keep a ref so it isn't GC'd + + def add(label, callback, sensitive=True): + item = Gtk.MenuItem(label=label) + item.set_sensitive(sensitive) + item.connect("activate", lambda _i: callback()) + menu.append(item) + + names = list(getattr(self, "_order_names", []) or []) + at_top = names and names[0] == name + at_bottom = names and names[-1] == name + add("Move to top", lambda: self._order_move_to(name, "top"), not at_top) + add("Move to bottom", lambda: self._order_move_to(name, "bottom"), not at_bottom) + menu.append(Gtk.SeparatorMenuItem()) + add("Hide from popup", lambda: self._hide_plugin(name)) + menu.show_all() + menu.popup_at_pointer(event) + return True + + def _order_move_to(self, name: str, where: str) -> None: + names = list(getattr(self, "_order_names", []) or []) + if name not in names: + return + names.remove(name) + names.insert(0, name) if where == "top" else names.append(name) + self._persist_order(names) + + def _hide_plugin(self, name: str) -> None: + """Hide a plugin from the popup by adding it to disabled_plugins. + Reversible: re-enable it from the Installed tab.""" + try: + from settings import get_settings + s = get_settings() + disabled = list(s.get("disabled_plugins") or []) + if name not in disabled: + disabled.append(name) + s.set("disabled_plugins", disabled) + s.save() + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] hide failed: {exc}") + return + # Reload so the daemon drops it from the popup, then refresh the list + # (the hidden plugin leaves the Order tab; unhide it under Installed). + if self._on_changed: + try: + self._on_changed() + except Exception: + pass + self._refresh_order_group() + if getattr(self, "_installed_group", None) is not None: + self._refresh_installed_group() + def _rebuild_catalog_buttons(self) -> None: page = getattr(self, "_catalog_page", None) if page is None: From 03e7f9896d674e5044e8d2149b3a1effa7234b23 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:47:14 +0200 Subject: [PATCH 4/6] Plugin Manager: fix Order-tab drop not reordering The drag handle started the drag but dropping never moved the plugin: the payload was written with set_text()/read with get_text(), which only work for text targets. With the custom LINUXPOP_ORDER_ROW atom, get_text() returned None, int() raised, and the drop was silently ignored. Use SelectionData.set()/get_data() with the target atom instead. --- plugin_manager.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugin_manager.py b/plugin_manager.py index a43e3b4..ed89b07 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -901,12 +901,16 @@ def _on_order_drag_begin(self, _grip, context, row) -> None: pass def _on_order_drag_data_get(self, _grip, context, data, info, time, row) -> None: - data.set_text(str(getattr(row, "_order_index", -1)), -1) + # Use set()/get_data() with our custom target atom. set_text() only + # works for text targets, so with "LINUXPOP_ORDER_ROW" the receiver's + # get_text() returns None and the drop is silently dropped. + payload = str(getattr(row, "_order_index", -1)).encode("utf-8") + data.set(data.get_target(), 8, payload) def _on_order_drag_data_received(self, row, context, x, y, data, info, time) -> None: try: - src = int(data.get_text()) - except (TypeError, ValueError): + src = int(bytes(data.get_data()).decode("utf-8")) + except (TypeError, ValueError, AttributeError): return dst = getattr(row, "_order_index", -1) names = list(getattr(self, "_order_names", []) or []) From 937b2d1b85520febe286a6bf0b9832bba9d8e9fd Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:54:00 +0200 Subject: [PATCH 5/6] Plugin order: add Pin-to-top (locked) for popup plugins New pinned_plugins setting. Pinned plugins always sort before everything else, in pin order, and can't be pushed below by reordering the rest - applied consistently in the popup (for_content_type + the matched-view sort) and the Order tab. Right-click in the Order tab gains Pin to top / Unpin from top; pinned rows show a star. --- plugin_loader.py | 71 ++++++++++++++++++++++++++-- plugin_manager.py | 64 +++++++++++++++++++++---- popup.py | 116 ++++++++++++++++++++++++++++++++++++++++++---- settings.py | 18 +++++++ 4 files changed, 246 insertions(+), 23 deletions(-) diff --git a/plugin_loader.py b/plugin_loader.py index cd23e9b..1172edc 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -233,21 +233,82 @@ def for_content_type(content_type: ContentType, text: str | None = None) -> List # Plugins not in the order list fall back to their built-in priority. try: from settings import get_settings - order = list(get_settings().get("plugin_order") or []) + s = get_settings() + order = list(s.get("plugin_order") or []) + pinned = list(s.get("pinned_plugins") or []) except Exception: - order = [] + order, pinned = [], [] order_index = {name: i for i, name in enumerate(order)} + pinned_index = {name: i for i, name in enumerate(pinned)} big = len(order) + 1_000_000 def sort_key(p: Plugin): + # Tier 0: pinned (locked to the top, in pin order). Tier 1: user + # plugin_order. Tier 2: everything else by built-in priority. + if p.name in pinned_index: + return (0, pinned_index[p.name], p.priority) if p.name in order_index: - return (0, order_index[p.name], p.priority) - return (1, big, p.priority) + return (1, order_index[p.name], p.priority) + return (2, big, p.priority) matched.sort(key=sort_key) return matched +# Known plugin categories. A plugin sets Plugin.category to one of these keys; +# the popup collapses each group behind a chip (icon + label) that expands to +# its members. Order here is irrelevant - chips appear at the position of the +# group's first member, so plugin_order still controls placement. +CATEGORIES: dict[str, dict[str, str]] = { + "format": {"label": "Formatting", "icon": "linuxpop-format-symbolic"}, + "markdown": {"label": "Markdown", "icon": "linuxpop-md-symbolic"}, +} + + +def plan_grouped(plugins, *, group: bool, min_size: int, + categories: dict | None = None) -> list[tuple]: + """Turn an ordered plugin list into a popup display plan. + + Returns a list of entries, preserving the incoming order: + ("action", plugin) + ("category", key, label, icon, [member plugins]) + + A category collapses into one chip only when it has at least `min_size` + members present; smaller groups stay inline as plain actions (no point + hiding one button behind a chip). With group=False every plugin is an + inline action, i.e. today's behaviour. Pure/GTK-free so it can be tested. + """ + cats = CATEGORIES if categories is None else categories + if not group: + return [("action", p) for p in plugins] + + members: dict[str, list] = {} + skeleton: list[tuple] = [] + for p in plugins: + key = getattr(p, "category", None) + if key and key in cats: + if key not in members: + members[key] = [] + skeleton.append(("catref", key)) + members[key].append(p) + else: + skeleton.append(("action", p)) + + out: list[tuple] = [] + for entry in skeleton: + if entry[0] != "catref": + out.append(entry) + continue + key = entry[1] + group_members = members[key] + if len(group_members) >= max(2, min_size): + meta = cats[key] + out.append(("category", key, meta["label"], meta["icon"], group_members)) + else: + out.extend(("action", m) for m in group_members) + return out + + def _register_builtins() -> None: # Universal: copy works on anything register(Plugin( @@ -320,7 +381,7 @@ def _can_run(text: str) -> bool: # EMAIL register(Plugin( name="compose-email", - icon="mail-send-symbolic", + icon="mail-message-new-symbolic", tooltip="Compose email", handler=actions.compose_email, content_types=(ContentType.EMAIL,), diff --git a/plugin_manager.py b/plugin_manager.py index ed89b07..9e32aa2 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -202,6 +202,7 @@ def __init__(self, on_changed: Callable[[], None] | None = None) -> None: self._installed_group: Handy.PreferencesGroup | None = None self._order_group: Handy.PreferencesGroup | None = None self._order_listbox = None + self._pinned_set: set[str] = set() self._custom_group: Handy.PreferencesGroup | None = None def show(self, tab: str | None = None) -> None: @@ -768,16 +769,21 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: group.add(row) return - # Compute effective order: user list first, then unlisted by priority + # Compute effective order: pinned first (locked to top), then the user + # plugin_order list, then unlisted by priority. order = list((settings.get("plugin_order") if settings else None) or []) + pinned = list((settings.get("pinned_plugins") if settings else None) or []) order_idx = {n: i for i, n in enumerate(order)} - sorted_plugins = sorted( - all_plugins, - key=lambda p: ( - (0, order_idx[p.name]) if p.name in order_idx - else (1, p.priority), - ), - ) + pinned_idx = {n: i for i, n in enumerate(pinned)} + + def _ord_key(p): + if p.name in pinned_idx: + return (0, pinned_idx[p.name], p.priority) + if p.name in order_idx: + return (1, order_idx[p.name], p.priority) + return (2, len(order) + 1, p.priority) + sorted_plugins = sorted(all_plugins, key=_ord_key) + self._pinned_set = set(pinned) # Full ordered name list, kept in sync for drag-and-drop + arrow moves. self._order_names = [p.name for p in sorted_plugins] @@ -792,12 +798,23 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: row._order_index = index row._order_icon = plugin.icon row._plugin_name = plugin.name + row._pinned = plugin.name in getattr(self, "_pinned_set", set()) try: img = Gtk.Image.new_from_icon_name(plugin.icon, Gtk.IconSize.LARGE_TOOLBAR) img.set_pixel_size(20) row.add_prefix(img) except Exception: pass + if row._pinned: + # A star marks pinned (locked-to-top) plugins. + try: + pin_img = Gtk.Image.new_from_icon_name( + "starred-symbolic", Gtk.IconSize.BUTTON) + pin_img.set_valign(Gtk.Align.CENTER) + pin_img.set_tooltip_text("Pinned to top") + row.add_prefix(pin_img) + except Exception: + pass # Drag-and-drop: the DRAG HANDLE (≡) is the drag source; the whole # row is the drop target. The handle lives in its own EventBox so @@ -939,6 +956,12 @@ def add(label, callback, sensitive=True): menu.append(item) names = list(getattr(self, "_order_names", []) or []) + is_pinned = name in getattr(self, "_pinned_set", set()) + if is_pinned: + add("Unpin from top", lambda: self._order_set_pinned(name, False)) + else: + add("Pin to top", lambda: self._order_set_pinned(name, True)) + menu.append(Gtk.SeparatorMenuItem()) at_top = names and names[0] == name at_bottom = names and names[-1] == name add("Move to top", lambda: self._order_move_to(name, "top"), not at_top) @@ -949,6 +972,31 @@ def add(label, callback, sensitive=True): menu.popup_at_pointer(event) return True + def _order_set_pinned(self, name: str, pinned: bool) -> None: + """Pin a plugin to the top (locked above the rest) or unpin it.""" + try: + from settings import get_settings + s = get_settings() + current = list(s.get("pinned_plugins") or []) + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] could not read pinned_plugins: {exc}") + return + if pinned and name not in current: + current.append(name) + elif not pinned and name in current: + current.remove(name) + else: + return + try: + s.set("pinned_plugins", current) + s.save() + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] could not save pinned_plugins: {exc}") + return + self._refresh_order_group() + if self._on_changed: + self._on_changed() + def _order_move_to(self, name: str, where: str) -> None: names = list(getattr(self, "_order_names", []) or []) if name not in names: diff --git a/popup.py b/popup.py index 4d51492..c3de6bb 100644 --- a/popup.py +++ b/popup.py @@ -355,6 +355,7 @@ def __init__( self.win.connect("leave-notify-event", self._on_leave) self._current_text: str = "" + self._open_category: str | None = None self._hide_timeout_id: int | None = None self._tracker_id: int | None = None # Absolute ceiling on time the popup will linger if the user @@ -405,6 +406,8 @@ def _clear_buttons(self) -> None: # Collapse row 2 until something gets added to it. self._row2.hide() self._row2.set_no_show_all(True) + # No category chip is expanded on a fresh popup. + self._open_category = None def _add_button( self, icon_name: str, tooltip: str, on_click, @@ -654,19 +657,26 @@ def show_for( # selections by content. That's intent-preserving, not # classifier-based, so it's fine to leave. plugins = [p for p in plugins if p.matches(text)] - # Sort the same way for_content_type does so plugin_order - # is honoured in the expanded view too. + # Sort the same way for_content_type does so pinned plugins and + # plugin_order are honoured in the expanded view too. try: from settings import get_settings as _gs - order = list(_gs().get("plugin_order") or []) + _s = _gs() + order = list(_s.get("plugin_order") or []) + pinned = list(_s.get("pinned_plugins") or []) except Exception: - order = [] + order, pinned = [], [] order_index = {n: i for i, n in enumerate(order)} + pinned_index = {n: i for i, n in enumerate(pinned)} big = len(order) + 1_000_000 - plugins.sort(key=lambda p: ( - (0, order_index[p.name], p.priority) - if p.name in order_index - else (1, big, p.priority))) + + def _ord_key(p): + if p.name in pinned_index: + return (0, pinned_index[p.name], p.priority) + if p.name in order_index: + return (1, order_index[p.name], p.priority) + return (2, big, p.priority) + plugins.sort(key=_ord_key) else: plugins = plugin_loader.for_content_type(content_type, text) # Strip out plugins that only make sense in editable widgets @@ -675,6 +685,10 @@ def show_for( # the focused widget - see main.py / editable_detect.py. if not editable: plugins = [p for p in plugins if not p.requires_editable] + # Keep the full, uncapped list for the grouped view: category chips + # collapse their members, so the flat cap below (which is about how + # many *icons* fit) shouldn't pre-trim members that live behind a chip. + plugins_all = list(plugins) # Hard cap so the popup doesn't grow to 25 icons across when # many plugins are installed. for_content_type already returns # in priority/custom-order, so the first N are the highest- @@ -726,8 +740,14 @@ def _worker(): self.hide() return _on_click - specs = [(p.icon, p.tooltip, make_handler(p)) for p in plugins] - self._present_buttons(specs, hidden_count, max_btns) + if self._group_categories_enabled(): + plan = plugin_loader.plan_grouped( + plugins_all, group=True, min_size=self._category_min(), + categories=plugin_loader.CATEGORIES) + self._present_plan(plan, make_handler, max_btns) + else: + specs = [(p.icon, p.tooltip, make_handler(p)) for p in plugins] + self._present_buttons(specs, hidden_count, max_btns) self._present_near(x, y, rect=rect) def _add_overflow_chip( @@ -819,6 +839,82 @@ def _present_buttons(self, specs, hidden_count: int, max_btns: int) -> None: extra, max_btns, row=self._row2 if shown > per_row else self._row1) + # ---- category grouping ------------------------------------------------ + def _group_categories_enabled(self) -> bool: + try: + from settings import get_settings as _gs + return bool(_gs().get("popup_group_categories")) + except Exception: + return False + + def _category_min(self) -> int: + try: + from settings import get_settings as _gs + return max(2, int(_gs().get("popup_category_min") or 2)) + except Exception: + return 2 + + def _present_plan(self, plan, make_handler, max_btns: int) -> None: + """Lay out a grouped plan (action + category entries) on row 1. + Category chips expand their members onto row 2 on click.""" + per_row = self._max_per_row() + shown = plan[:per_row] + for entry in shown: + if entry[0] == "category": + _, _key, label, icon, members = entry + specs = [(p.icon, p.tooltip, make_handler(p)) for p in members] + self._add_category_chip(icon, label, specs) + else: + p = entry[1] + self._add_button(p.icon, p.tooltip, make_handler(p), row=self._row1) + extra = len(plan) - len(shown) + if extra > 0: + self._add_overflow_chip(extra, max_btns, row=self._row1) + + def _add_category_chip(self, icon: str, label: str, member_specs) -> None: + """A chip that, on click, reveals its category's members on row 2 + (and collapses them again on a second click).""" + n = len(member_specs) + btn = Gtk.Button() + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.set_tooltip_text( + f"{label} — {n} action{'s' if n != 1 else ''} (click to expand)") + btn.get_style_context().add_class("linuxpop-action") + btn.get_style_context().add_class("linuxpop-category") + btn.set_image(self._make_icon_image(icon)) + btn.set_always_show_image(True) + + def _toggle(_b): + already_open = self._open_category == label + for child in list(self._row2.get_children()): + self._row2.remove(child) + child.destroy() + if already_open: + self._open_category = None + self._row2.hide() + self._row2.set_no_show_all(True) + self._try_resize() + return + self._open_category = label + per_row = self._max_per_row() + for ic, tip, h in member_specs[:per_row]: + self._add_button(ic, tip, h, row=self._row2) + hidden = n - min(n, per_row) + if hidden > 0: + self._add_overflow_chip(hidden, 0, row=self._row2) + self._row2.set_no_show_all(False) + self._row2.show_all() + self._try_resize() + + btn.connect("clicked", _toggle) + self._row1.pack_start(btn, False, False, 0) + + def _try_resize(self) -> None: + try: + self.win.queue_resize() + except Exception: + pass + def _add_expand_chip(self, rest, hidden_count: int, max_btns: int) -> None: """A chevron at the end of row 1; clicking it reveals row 2 with the actions that didn't fit on the first line ('expand' overflow mode).""" diff --git a/settings.py b/settings.py index 7bd171e..792a276 100644 --- a/settings.py +++ b/settings.py @@ -200,6 +200,11 @@ "selection_debounce_ms": 150, # If True, ignore selections that contain only whitespace "ignore_whitespace_only": True, + # If True, log a short, secret-redacted preview of selection text at + # DEBUG level (needs --debug to surface). Default False: the log + # records only content type + length, never the raw selection, so + # passwords/tokens/private text are not written to linuxpop.log. + "debug_log_selection_content": False, # Substrings that, if any matches the active window's title or # WM_CLASS (case-insensitive), suppress the popup entirely. Useful # for password managers, banking sites, etc. One entry per pattern. @@ -229,6 +234,15 @@ # "cap" -> one row only; the remainder sit behind a "+N" chip. # All modes are still bounded by max_popup_buttons above. "popup_overflow_mode": "expand", + # Collapse plugins that share a category (Formatting, Markdown, ...) behind + # a single chip in the popup; clicking the chip expands its members onto the + # second row. Declutters the bar when families like the markdown actions are + # installed. Turn off to show every action inline. + "popup_group_categories": True, + # A category only collapses into a chip once it has at least this many + # members in the popup; smaller groups stay inline (no point hiding one + # button behind a chip). + "popup_category_min": 2, # Icon style for branded/utility plugins: # "color" -> vibrant gradient tiles (default) # "glyph" -> uniform mono glyphs that match the plain text-edit icons @@ -249,6 +263,10 @@ # listed here appear first in this order; unlisted plugins fall back # to their built-in priority. Edit via Plugin Manager → Order tab. "plugin_order": [], + # Plugin names pinned to the top of the popup. Pinned plugins always sort + # before everything else, in this order, and can't be pushed down by + # reordering the rest. Toggle via Plugin Manager → Order tab (right-click). + "pinned_plugins": [], # Plugin names (Plugin.name) the user has switched off inside a # bundled .py file. The plugin_loader silently drops these at # register time. Lets users keep "Paste" but hide "Paste & Enter" From da444d52e97a223461aadb6d5bc34d5dcd75e01f Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:16:16 +0200 Subject: [PATCH 6/6] Flatpak: drop PySide6/Qt now that the tray is pure dbus-python The system tray no longer uses QSystemTrayIcon - tray_dbus.py is a hand-rolled StatusNotifierItem on dbus-python + GdkPixbuf, both already available in the sandbox. Remove the PySide6-Essentials + shiboken6 bundle (>100 MB) from the manifest, delete the now-dead tray_qt.py, and drop the Qt fallback in tray.py. Validated by running tray_dbus.py inside the installed Flatpak sandbox: it registers with org.kde.StatusNotifierWatcher through the D-Bus proxy, serves ItemIsMenu=true, renders the icon via GdkPixbuf, and serves the full DBusMenu. --- .../io.github.GaimsDevSoftware.LinuxPop.yml | 73 ++--- tray.py | 23 +- tray_qt.py | 310 ------------------ 3 files changed, 34 insertions(+), 372 deletions(-) delete mode 100644 tray_qt.py diff --git a/packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml b/packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml index bf3ed58..6d8cc3c 100644 --- a/packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml +++ b/packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml @@ -22,12 +22,20 @@ # - gtk-layer-shell: positions the popup as a layer-shell surface # (a plain GTK window can't be placed on Wayland). Built with # introspection so the GtkLayerShell-0.1 typelib is loadable by gi. -# - ydotool + ydotoold: key injection on Wayland. ydotoold writes to -# /dev/uinput, which needs the --device=all finish-arg. Bundling it -# means users don't have to install a host tool for Cut/Paste/Backspace. # Neither ships in GNOME Platform 46, so both are built from source # below with real upstream URL + sha256. # +# Key injection (paste chords): +# wdotool (libei/XDG RemoteDesktop portal) is the preferred injector on +# KDE Plasma 6 — it goes through the compositor's input stack, so modifier +# chords (ctrl+v, ctrl+a) arrive correctly in every toolkit. Inside the +# Flatpak sandbox it uses the host's RemoteDesktop portal via D-Bus +# forwarding, so it works without /dev/uinput. Bundle as a Flatpak module +# once wdotool is packaged for Fedora/Debian; until then install it on the +# host via `cargo install wdotool` and it is accessed through flatpak-spawn. +# ydotool (kernel uinput) is the legacy fallback — it drops modifier chords +# in Chrome/Electron on KDE Plasma 6. +# # RUNTIME VERSION: org.gnome.Platform//49 (GNOME 46 is EOL). 49 was # verified to ship GTK3, the Handy-1 typelib, and python3-gi on Python # 3.13 - the bundled Python wheels are cp313 to match. Re-check the @@ -51,10 +59,11 @@ finish-args: - --socket=fallback-x11 # X11 / XWayland backend - --socket=pulseaudio # text-to-speech (espeak-ng) audio output - --share=network # AI plugins (ChatGPT URL prefill, Ollama, etc.) - # No filesystem perms: the app's ~/.config/linuxpop + ~/.cache/linuxpop - # resolve to the sandbox's private per-app XDG dirs automatically (the - # linter flags explicit grants for these as unnecessary). xdg-open of - # selected paths/URLs goes through the OpenURI portal. + # No filesystem perms: the app reads $XDG_CONFIG_HOME / $XDG_CACHE_HOME + # (see xdg_paths.py) so its config + cache land in the sandbox's private, + # PERSISTENT per-app dirs. (Hardcoding ~/.config wrote to a throwaway + # tmpfs that the sandbox wiped on exit, losing every setting on restart - + # fixed in 0.9.4.) xdg-open of selected paths/URLs goes through the portal. - --talk-name=org.freedesktop.Notifications - --talk-name=org.kde.StatusNotifierWatcher # system tray - --talk-name=org.kde.kglobalaccel # global hotkey (linter-accepted) @@ -81,7 +90,6 @@ finish-args: # exactly what the feature does, and LinuxPop is self-distributed (not Flathub). - --talk-name=org.freedesktop.Flatpak - --device=dri # GTK rendering - - --device=all # ydotoold needs /dev/uinput for key injection cleanup: - /include @@ -231,23 +239,6 @@ modules: url: https://github.com/bugaevc/wl-clipboard/archive/refs/tags/v2.3.0.tar.gz sha256: b4dc560973f0cd74e02f817ffa2fd44ba645a4f1ea94b7b9614dacc9f895f402 - # ----- ydotool ----- - # Key injection on Wayland. ydotool v1.x is pure C99 with no external - # dependencies and only needs CMake. The companion ydotoold daemon holds - # a persistent uinput device; the wrapper script starts it at launch. - # --device=all grants /dev/uinput access inside the sandbox. - # We patch out the manpage subdir so we don't need scdoc in the SDK. - - name: ydotool - buildsystem: cmake-ninja - config-opts: - - -DCMAKE_POLICY_VERSION_MINIMUM=3.5 - sources: - - type: archive - url: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v1.0.4.tar.gz - sha256: ba075a43aa6ead51940e892ecffa4d0b8b40c241e4e2bc4bd9bd26b61fde23bd - - type: patch - path: ydotool-no-docs.patch - # ----- python3-dbus (dbus-python) ----- # The Wayland/KDE backend uses dbus-python for KGlobalAccel (global # hotkeys) and KWin scripting over D-Bus; it is NOT in the GNOME runtime, @@ -261,25 +252,13 @@ modules: url: https://dbus.freedesktop.org/releases/dbus-python/dbus-python-1.3.2.tar.gz sha256: ad67819308618b5069537be237f8e68ca1c7fcc95ee4a121fe6845b1418248f8 - # ----- PySide6 (Qt6) for the system-tray subprocess ----- - # tray_qt.py uses QSystemTrayIcon (StatusNotifierItem + DBusMenu) and - # QtSvg to recolour the tray glyph. The GNOME runtime has no Qt, so bundle - # PySide6-Essentials (Core/Gui/Widgets/DBus/Svg + platform plugins) and - # its shiboken6 runtime. abi3 wheels -> run on the runtime's Python 3.13. - # Without this the tray subprocess dies ("could not connect") in-sandbox. - - name: python3-pyside6 - buildsystem: simple - build-commands: - - pip3 install --prefix=/app --no-build-isolation --no-deps - shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl - pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl - sources: - - type: file - url: https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl - sha256: 9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe - - type: file - url: https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl - sha256: e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60 + # The system tray (tray_dbus.py) is a hand-rolled StatusNotifierItem built + # on dbus-python + GdkPixbuf - both already in the runtime / bundled above - + # so no Qt is needed. This previously bundled PySide6-Essentials + shiboken6 + # (~well over 100 MB) purely for the old QSystemTrayIcon tray; dropping it + # shrinks the Flatpak substantially. The dbus tray also advertises + # ItemIsMenu=true, which Qt's QSystemTrayIcon could not, so the menu now + # opens on left-click too. # ----- LinuxPop itself ----- # ----- QR code generator (qr_code plugin) ----- @@ -368,12 +347,6 @@ modules: # No `|| true` here: a missing source should fail the build loudly. - cp -r *.py platform_backend icons plugins_repo /app/share/linuxpop/ - # 1b. Bake the version. The sandbox has no .git at runtime, so resolve it - # now (git tag, with a metainfo fallback) into a _version.py the app - # reads first - keeps the About dialog in sync with the release. - - bash -c 'printf "VERSION = \"%s\"\n" "$(bash packaging/gen-version.sh)" - > /app/share/linuxpop/_version.py' - # 2. Wrapper script on PATH - install -Dm755 packaging/flatpak/linuxpop.wrapper /app/bin/linuxpop diff --git a/tray.py b/tray.py index 42a2844..59b0c36 100644 --- a/tray.py +++ b/tray.py @@ -1,7 +1,9 @@ -"""System tray icon (KStatusNotifierItem via separate Qt process) for LinuxPop. +"""System tray icon for LinuxPop. -Spawns a lean Qt process that uses KStatusNotifierItem - native KDE/Wayland -protocol, no XWayland dependency, no GTK thread conflicts. +Spawns a lean subprocess (tray_dbus.py) that exports a hand-rolled +StatusNotifierItem over D-Bus (dbus-python). It advertises ItemIsMenu=true +so plasmashell renders the menu on left- AND right-click, and needs no Qt. +This class talks to that subprocess over a small length-prefixed JSON socket. """ from __future__ import annotations @@ -17,14 +19,10 @@ from typing import Callable from xdg_paths import CACHE_DIR as SOCKET_DIR -# Custom StatusNotifierItem (dbus-python) that advertises ItemIsMenu=true so -# plasmashell shows the menu on left-click too. Falls back to the Qt -# QSystemTrayIcon implementation (right-click only on KDE Wayland) if the -# dbus-python tray is unavailable. -_TRAY_DIR = Path(__file__).resolve().parent -TRAY_SCRIPT = str(_TRAY_DIR / "tray_dbus.py") -if not os.path.isfile(TRAY_SCRIPT): - TRAY_SCRIPT = str(_TRAY_DIR / "tray_qt.py") +# The tray is a hand-rolled StatusNotifierItem (dbus-python) that advertises +# ItemIsMenu=true, so plasmashell shows the menu on left-click too - something +# the old Qt QSystemTrayIcon could not do. No Qt/PySide6 dependency. +TRAY_SCRIPT = str(Path(__file__).resolve().parent / "tray_dbus.py") def _tray_preexec() -> None: @@ -86,7 +84,8 @@ def _send_message(sock: socket.socket, msg: dict) -> None: class Tray: - """Same API as the old GTK/Ayatana Tray, but backed by a Qt KSNI subprocess.""" + """Same API as the old GTK/Ayatana Tray, backed by the dbus-python + StatusNotifierItem subprocess (tray_dbus.py).""" def __init__( self, diff --git a/tray_qt.py b/tray_qt.py deleted file mode 100644 index 82c46dc..0000000 --- a/tray_qt.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python3 -"""LinuxPop tray icon - Qt QSystemTrayIcon (StatusNotifierItem + DBusMenu). - -Uses QSystemTrayIcon, NOT the KF6 KStatusNotifierItem (which had a Fedora 44 -D-Bus registration bug). Qt's tray registers the SNI *and* exports the context -menu as a com.canonical.dbusmenu object that plasmashell renders itself - the -only thing that actually shows a menu on KWin/Wayland (a parentless QMenu.popup -never maps). Talks to the main LinuxPop process over a small length-prefixed -JSON socket, unchanged from the previous implementation. -""" -from __future__ import annotations - -import json, os, socket, sys, struct -from pathlib import Path - -from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu -from PySide6.QtGui import QIcon, QCursor -from PySide6.QtCore import QTimer - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from xdg_paths import CACHE_DIR as SOCKET_DIR, CONFIG_DIR # noqa: E402 - -ICON_DIR = str(Path(__file__).resolve().parent / "icons") -SETTINGS_FILE = CONFIG_DIR / "settings.json" - - -def _tray_icon_style() -> str: - """User's chosen tray-icon style: 'color' (coloured badge, default), - 'light' (light monochrome - for dark panels), or 'dark' (dark - monochrome - for light panels).""" - try: - d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) - v = str(d.get("tray_icon_style", "color")).strip().lower() - return v if v in ("color", "light", "dark") else "color" - except Exception: - return "color" - -# ─── Wire helpers ─────────────────────────────────────────────────── - -def _recv_exact(sock: socket.socket, n: int) -> bytes: - buf = b"" - while len(buf) < n: - chunk = sock.recv(n - len(buf)) - if not chunk: - raise ConnectionError("Socket closed") - buf += chunk - return buf - -def _recv_message(sock: socket.socket) -> dict | None: - try: - raw_len = _recv_exact(sock, 4) - msg_len = struct.unpack("!I", raw_len)[0] - if msg_len > 1_000_000: - return None - raw = _recv_exact(sock, msg_len) - return json.loads(raw.decode("utf-8")) - except (ConnectionError, OSError, json.JSONDecodeError): - return None - -def _send_message(sock: socket.socket, msg: dict) -> None: - raw = json.dumps(msg).encode("utf-8") - sock.sendall(struct.pack("!I", len(raw)) + raw) - -# ─── TrayQt ───────────────────────────────────────────────────────── - -class TrayQt: - def __init__(self) -> None: - self._app = QApplication(sys.argv) - self._app.setQuitOnLastWindowClosed(False) - self._app.setApplicationName("linuxpop-tray") - self._app.setDesktopFileName("linuxpop") - - self._install_icon() - - self._menu = QMenu() - self._setup_menu() - - # QSystemTrayIcon registers the SNI and exports `self._menu` as a - # DBusMenu. plasmashell renders that menu on its own surface (correctly - # positioned) when the user activates the item - no client-side popup. - self._tray = QSystemTrayIcon() - self._tray.setIcon(self._load_icon()) - self._tray.setToolTip("LinuxPop - clipboard popup assistant") - self._tray.setContextMenu(self._menu) - self._tray.activated.connect(self._on_activated) - self._tray.show() - - # Socket - self._sock: socket.socket | None = None - self._client: socket.socket | None = None - self._running = True - self._setup_socket() - - self._timer = QTimer() - self._timer.timeout.connect(self._check_socket) - self._timer.start(100) - - # Parent-death watchdog. PR_SET_PDEATHSIG (set by the launcher) is the - # primary guard, but it can miss if the tray gets reparented to a - # systemd subreaper rather than PID 1. So also poll our parent PID: - # the moment it stops being the daemon that spawned us, that daemon is - # gone and we must remove our tray icon instead of lingering forever. - self._initial_ppid = os.getppid() - self._ppid_timer = QTimer() - self._ppid_timer.timeout.connect(self._check_parent_alive) - self._ppid_timer.start(2000) - - avail = QSystemTrayIcon.isSystemTrayAvailable() - print(f"[tray-qt] Started (QSystemTrayIcon, tray_available={avail})", - flush=True) - - # ─── icon ─── - def _load_icon(self) -> QIcon: - """Tray icon per the user's `tray_icon_style` setting. - - Auto-recolouring isn't reliable on KDE (plasmashell won't recolour a - custom symbolic icon - verified it stays solid black; and the app's - colour scheme can differ from the panel theme, e.g. light Breeze - under dark WhiteSur-alt), so the user picks: - color -> the coloured brand badge; legible on any panel (default) - light -> light monochrome glyph; for DARK panels - dark -> dark monochrome glyph; for LIGHT panels - """ - style = _tray_icon_style() - if style in ("light", "dark"): - color = "#f4f5f6" if style == "light" else "#2a2e32" - ic = self._render_symbolic(color) - if ic is not None and not ic.isNull(): - return ic - # fall through to the coloured badge if rendering failed - for name in ("linuxpop", "linuxpop-tray-symbolic"): - p = Path(ICON_DIR) / f"{name}.svg" - if p.is_file(): - ic = QIcon(str(p)) - if not ic.isNull(): - return ic - return (QIcon.fromTheme("linuxpop") - or QIcon.fromTheme("applications-internet")) - - def _render_symbolic(self, color: str) -> QIcon | None: - """Render the monochrome symbolic tray SVG (fill="currentColor") in - `color`, as a multi-size QIcon. Used for the light/dark styles so the - glyph is a fixed, panel-appropriate colour the user chose.""" - p = Path(ICON_DIR) / "linuxpop-tray-symbolic.svg" - if not p.is_file(): - return None - try: - from PySide6.QtSvg import QSvgRenderer - from PySide6.QtGui import QImage, QPixmap, QPainter - from PySide6.QtCore import QByteArray, Qt - svg = p.read_text(encoding="utf-8").replace("currentColor", color) - renderer = QSvgRenderer(QByteArray(svg.encode("utf-8"))) - icon = QIcon() - for size in (16, 22, 24, 32, 48, 64): - img = QImage(size, size, QImage.Format_ARGB32) - img.fill(Qt.transparent) - painter = QPainter(img) - renderer.render(painter) - painter.end() - icon.addPixmap(QPixmap.fromImage(img)) - return icon - except Exception as exc: # noqa: BLE001 - print(f"[tray-qt] symbolic render failed: {exc}", flush=True) - return None - - def _install_icon(self) -> None: - import shutil - user_dir = Path.home() / ".local/share/icons/hicolor/scalable/apps" - user_dir.mkdir(parents=True, exist_ok=True) - for name in ("linuxpop-tray-symbolic", "linuxpop"): - src = Path(ICON_DIR) / f"{name}.svg" - dst = user_dir / f"{name}.svg" - if src.is_file(): - try: - shutil.copy2(src, dst) - except OSError: - pass - - # ─── activation ─── - def _on_activated(self, reason) -> None: - # Right-click already shows the DBusMenu (plasmashell renders it). On - # left-click (Trigger) / middle-click, surface the same menu so the user - # always reaches Settings/Plugins however they click. The popup here is - # driven by a real input event, so it maps on Wayland. - R = QSystemTrayIcon.ActivationReason - if reason in (R.Trigger, R.MiddleClick): - self._menu.popup(QCursor.pos()) - - # ─── menu ─── - def _setup_menu(self) -> None: - h = self._menu.addAction("LinuxPop") - h.setEnabled(False) - self._menu.addSeparator() - self._toggle_action = self._menu.addAction("Auto-popup on selection") - self._toggle_action.setCheckable(True) - self._toggle_action.setChecked(True) - self._toggle_action.triggered.connect( - lambda checked: self._emit("toggle_watcher", checked)) - a = self._menu.addAction("Show popup now") - a.triggered.connect(lambda: self._emit("show_popup", None)) - self._menu.addSeparator() - a = self._menu.addAction("Settings…") - a.triggered.connect(lambda: self._emit("settings", None)) - a = self._menu.addAction("Plugins…") - a.triggered.connect(lambda: self._emit("plugins", None)) - a = self._menu.addAction("About LinuxPop") - a.triggered.connect(lambda: self._emit("about", None)) - a = self._menu.addAction("Support LinuxPop…") - a.triggered.connect(lambda: self._emit("support", None)) - self._menu.addSeparator() - a = self._menu.addAction("Quit LinuxPop") - a.triggered.connect(lambda: self._emit("quit", None)) - - def _emit(self, event: str, value: object) -> None: - if self._client: - try: - _send_message(self._client, {"event": event, "value": value}) - except OSError: - pass - - # ─── socket IPC (unchanged protocol) ─── - def _setup_socket(self) -> None: - SOCKET_DIR.mkdir(parents=True, exist_ok=True) - socket_path = str(SOCKET_DIR / "tray.sock") - try: - os.unlink(socket_path) - except OSError: - pass - self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self._sock.bind(socket_path) - self._sock.listen(1) - self._sock.setblocking(False) - info_file = SOCKET_DIR / "tray.info" - info_file.write_text(f"{socket_path}\n{os.getpid()}\n") - - def _check_parent_alive(self) -> None: - """Quit if the daemon that spawned us is gone. Detected by the parent - PID changing (reparented to init or a systemd subreaper). Removes our - tray icon so a crashed/killed daemon can't leave a ghost behind.""" - try: - if os.getppid() != self._initial_ppid: - print("[tray-qt] parent daemon gone -- exiting", flush=True) - self._running = False - self._app.quit() - except Exception: - pass - - def _check_socket(self) -> None: - if not self._running: - self._app.quit() - return - try: - if self._client is None: - try: - self._client, _addr = self._sock.accept() - self._client.setblocking(True) - except BlockingIOError: - return - self._client.setblocking(False) - try: - msg = _recv_message(self._client) - if msg is not None: - self._handle_command(msg) - except BlockingIOError: - pass - except OSError: - self._disconnect_client() - - def _disconnect_client(self) -> None: - if self._client: - try: - self._client.close() - except OSError: - pass - self._client = None - - def _handle_command(self, msg: dict) -> None: - cmd = msg.get("cmd") - if cmd == "set_watcher_active": - self._toggle_action.setChecked(bool(msg.get("value", True))) - elif cmd == "reload_icon": - # Settings changed the tray_icon_style; re-render live. - try: - self._tray.setIcon(self._load_icon()) - except Exception as exc: # noqa: BLE001 - print(f"[tray-qt] reload_icon failed: {exc}", flush=True) - elif cmd == "quit": - self._running = False - elif cmd == "ping": - if self._client: - try: - _send_message(self._client, {"event": "pong", "value": None}) - except OSError: - pass - - def run(self) -> None: - self._app.exec() - if self._client: - try: - self._client.close() - except OSError: - pass - if self._sock: - try: - self._sock.close() - except OSError: - pass - -if __name__ == "__main__": - TrayQt().run()