From 4fc3779dda3de9d175d17ab1733fb545f41b799b Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:40:49 +0200 Subject: [PATCH 01/10] Add GNOME/Mutter Wayland support via XWayland hybrid backend Mutter implements neither wlr-layer-shell (no absolute surface placement) nor an external cursor-position API (no KWin workspace.cursorPos equivalent) the two things the KDE backend relies on for popup positioning. So GNOME gets a hybrid backend that reuses the proven X11 path for the hard parts and swaps only the I/O that X11 tools can't do on Wayland. New: platform_backend/xwayland_gnome.py (XWaylandGnomeBackend) - inherits X11Backend for pointer (XQueryPointer) and popup placement (Gtk.Window.move) - both work for an XWayland toplevel and return global coordinates, no layer-shell needed - overrides selection/clipboard (wl-clipboard) and key injection (ydotool) by delegating to the KDE backend's compositor-agnostic helpers, because xclip/XFixes/xdotool only see XWayland apps, not native Wayland ones - selection watch via wl-paste --primary --watch (sees native Wayland apps) - double-click via the existing kernel-level evdev watcher - active window via AT-SPI, merged with XWayland WM_CLASS New: platform_backend/evdev_hotkey.py (EvdevHotkey) - global hotkey read from /dev/input at kernel level, so it fires whatever app has focus (an X11 grab via XWayland misses native-Wayland-focused windows). Modifier state via libxkbcommon, so ctrl:swap_lalt_lctl and other xkb options are honoured. Needs the 'input' group (same as ydotool). detect(): GNOME Wayland -> xwayland_gnome; KDE and X11 paths unchanged. main.py: force GDK_BACKEND=x11 before GTK import, but only for the gnome backend (KDE keeps native Wayland for gtk-layer-shell). LINUXPOP_GDK_BACKEND opts out. editable_detect.py: add active_window_atspi_haystacks() for blocklist matching on Wayland where WM_CLASS is unavailable for native apps. Verified on KDE-under-XWayland: detection, pointer, selection, paste, positioning (test_popup), and evdev hotkey arming (honouring the user's ctrl:swap_lalt_lctl). KDE backend confirmed unchanged. Visual verification on a real GNOME session (placement accuracy, fractional scaling) still pending; Flatpak packaging for GNOME deferred until then. --- editable_detect.py | 76 ++++++++++ main.py | 23 +++ platform_backend/__init__.py | 41 ++++-- platform_backend/evdev_hotkey.py | 229 +++++++++++++++++++++++++++++ platform_backend/xwayland_gnome.py | 153 +++++++++++++++++++ 5 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 platform_backend/evdev_hotkey.py create mode 100644 platform_backend/xwayland_gnome.py diff --git a/editable_detect.py b/editable_detect.py index 6a2cd2d..dba8397 100644 --- a/editable_detect.py +++ b/editable_detect.py @@ -486,6 +486,82 @@ def worker() -> None: return result[0] +def active_window_atspi_haystacks(timeout: float = 0.15) -> list[str]: + """Lowercased [app-name, active-window-name] for the focused window via + AT-SPI, for blocklist matching on Wayland where WM_CLASS is unavailable + for native apps (the X11 xprop/xdotool path only sees XWayland windows). + + Returns [] when AT-SPI is off/unavailable/times out. Mirrors + focused_selection_rect's defensive desktop walk and thread+timeout guard. + Gated on the same editable_atspi_listener_enabled setting.""" + if not _HAS_ATSPI: + return [] + try: + from settings import get_settings + if not bool(get_settings().get("editable_atspi_listener_enabled")): + return [] + except Exception: + return [] + + result: list[list[str]] = [[]] + + def worker() -> None: + try: + desktop = Atspi.get_desktop(0) + if desktop is None: + return + for i in range(desktop.get_child_count()): + try: + app = desktop.get_child_at_index(i) + except Exception: + continue + if app is None: + continue + try: + n_win = app.get_child_count() + except Exception: + continue + for j in range(n_win): + try: + win = app.get_child_at_index(j) + except Exception: + continue + if win is None: + continue + try: + if not win.get_state_set().contains( + Atspi.StateType.ACTIVE): + continue + except Exception: + continue + hay: list[str] = [] + try: + an = app.get_name() + if an: + hay.append(an.lower()) + except Exception: + pass + try: + wn = win.get_name() + if wn: + hay.append(wn.lower()) + except Exception: + pass + result[0] = hay + return + except Exception: + return + + t = threading.Thread(target=worker, daemon=True, + name="linuxpop-atspi-actwin") + t.start() + t.join(timeout=timeout) + if t.is_alive(): + _log.info("[blocklist] AT-SPI active-window probe timed out") + return [] + return result[0] + + def _wm_class_lower() -> str: """Return the focused window's WM_CLASS in lower-case, or '' on failure. diff --git a/main.py b/main.py index c5be4bf..10ee435 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,29 @@ from logging.handlers import RotatingFileHandler from pathlib import Path +# Decide the GTK display backend BEFORE importing gi/Gtk. On a non-KDE Wayland +# session (notably GNOME/Mutter) we run the GTK app under XWayland so the popup +# can position itself (Gtk.Window.move) and read the global pointer +# (XQueryPointer) - neither is possible via native Wayland on Mutter, which has +# no wlr-layer-shell and no external cursor-position API. KDE keeps the native +# Wayland GDK backend (it needs it for gtk-layer-shell), so this only fires for +# the xwayland_gnome backend. detect() is pure env inspection (no gi import). +# +# We force x11 even when GDK_BACKEND is already set to something else (many +# sessions preset GDK_BACKEND=wayland): the gnome backend's whole premise is an +# XWayland toplevel, so a native-Wayland GDK would silently break popup +# positioning. LINUXPOP_GDK_BACKEND lets a power user opt out. +try: + from platform_backend import detect as _detect_backend + if _detect_backend() == "xwayland_gnome": + _forced_gdk = os.environ.get("LINUXPOP_GDK_BACKEND") + if _forced_gdk: + os.environ["GDK_BACKEND"] = _forced_gdk + elif (os.environ.get("GDK_BACKEND") or "").lower() != "x11": + os.environ["GDK_BACKEND"] = "x11" +except Exception: + pass + import gi gi.require_version("Gtk", "3.0") diff --git a/platform_backend/__init__.py b/platform_backend/__init__.py index 0ae95d4..3dd1b19 100644 --- a/platform_backend/__init__.py +++ b/platform_backend/__init__.py @@ -1,8 +1,10 @@ """LinuxPop platform backend selection. `get_backend()` returns a cached PlatformBackend chosen by detect(): - - KDE Plasma Wayland -> WaylandKdeBackend - - everything else -> X11Backend (the original behaviour) + - KDE Plasma Wayland -> WaylandKdeBackend (native: gtk-layer-shell + KWin) + - GNOME Wayland -> XWaylandGnomeBackend (XWayland positioning + + Wayland-native I/O; Mutter has no layer-shell) + - everything else -> X11Backend (the original behaviour) The package is named `platform_backend`, not `platform`, to avoid shadowing Python's stdlib `platform` module (imported by gi and others). @@ -17,24 +19,34 @@ def detect() -> str: - """Return 'wayland_kde' or 'x11'. + """Return 'wayland_kde', 'xwayland_gnome', or 'x11'. - Wayland KDE is chosen only when there is a Wayland display AND no usable - X11 display - under XWayland (both set) we prefer the mature X11 path. - Override with LINUXPOP_BACKEND=x11|wayland_kde. + A Wayland session routes by desktop environment: + - KDE -> wayland_kde (native gtk-layer-shell + KWin cursor query) + - GNOME -> xwayland_gnome (Mutter lacks layer-shell; we run the popup + under XWayland and use Wayland-native I/O) + - other Wayland -> wayland_kde (best-effort; wlroots/Sway support + layer-shell, so the KDE path mostly works) + A non-Wayland session is always the mature X11 path. + + Override with LINUXPOP_BACKEND=x11|wayland_kde|xwayland_gnome. """ forced = (os.environ.get("LINUXPOP_BACKEND") or "").strip().lower() - if forced in ("x11", "wayland_kde"): + if forced in ("x11", "wayland_kde", "xwayland_gnome"): return forced has_wayland = bool(os.environ.get("WAYLAND_DISPLAY")) has_x11 = bool(os.environ.get("DISPLAY")) session = (os.environ.get("XDG_SESSION_TYPE") or "").lower() - if has_wayland and not has_x11: - return "wayland_kde" - if session == "wayland" and has_wayland: - # Pure Wayland session that also exposes XWayland: still prefer the - # native Wayland backend (selection/hotkey via X won't see native - # Wayland apps). + is_wayland_session = has_wayland and (session == "wayland" or not has_x11) + if is_wayland_session: + desktop = (os.environ.get("XDG_CURRENT_DESKTOP") or "").upper() + if "GNOME" in desktop: + # Mutter: no layer-shell and no external cursor API, so the KDE + # path can't place the popup. Run under XWayland instead. + return "xwayland_gnome" + # KDE (native) and any other Wayland compositor fall through to the + # KDE backend: it's native on KDE and a reasonable best-effort on + # wlroots-based compositors (which do implement layer-shell). return "wayland_kde" return "x11" @@ -46,6 +58,9 @@ def get_backend() -> PlatformBackend: if choice == "wayland_kde": from .wayland_kde import WaylandKdeBackend _backend = WaylandKdeBackend() + elif choice == "xwayland_gnome": + from .xwayland_gnome import XWaylandGnomeBackend + _backend = XWaylandGnomeBackend() else: from .x11 import X11Backend _backend = X11Backend() diff --git a/platform_backend/evdev_hotkey.py b/platform_backend/evdev_hotkey.py new file mode 100644 index 0000000..a4a2d9d --- /dev/null +++ b/platform_backend/evdev_hotkey.py @@ -0,0 +1,229 @@ +"""Global hotkey via evdev (/dev/input) for Wayland sessions where an X11 +grab cannot see a window that a *native* Wayland app has focused - notably +GNOME/Mutter, where XGrabKey through XWayland only fires while an XWayland +window is focused. + +Reading key events straight from the kernel input devices means the hotkey +fires regardless of which toolkit/app has focus. Modifier state is tracked +through libxkbcommon (reusing the KDE backend's _XkbModifierState), so a +Ctrl/Alt swap or any other xkb option is honoured rather than hard-coding +which physical keycode is Ctrl. Needs the user in the 'input' group (same +requirement as ydotool); logs and stays inert otherwise. + +Interface mirrors hotkey.Hotkey: start() / stop(wait, timeout), so main.py +treats it identically to the X11 hotkey. +""" +from __future__ import annotations + +import os +import select as _select +import struct +import threading +import time +from typing import Callable, Optional + +from gi.repository import GLib + +# evdev keycodes (linux/input-event-codes.h) for the keys a hotkey may use as +# its main (non-modifier) key. Keyed by lowercased name / alias. These are +# physical-position codes; a hotkey therefore tracks the physical key, which is +# the conventional behaviour for global shortcuts. +_KEYCODES = { + **{c: 2 + i for i, c in enumerate("1234567890")}, # 1..0 -> 2..11 + "a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, + "h": 35, "i": 23, "j": 36, "k": 37, "l": 38, "m": 50, "n": 49, + "o": 24, "p": 25, "q": 16, "r": 19, "s": 31, "t": 20, "u": 22, + "v": 47, "w": 17, "x": 45, "y": 21, "z": 44, + "minus": 12, "equal": 13, "bracketleft": 26, "bracketright": 27, + "semicolon": 39, "apostrophe": 40, "grave": 41, "backslash": 43, + "comma": 51, "period": 52, "dot": 52, "slash": 53, + "space": 57, "spacebar": 57, + "return": 28, "enter": 28, "tab": 15, "escape": 1, "esc": 1, + "backspace": 14, "delete": 111, "insert": 110, + "home": 102, "end": 107, "pageup": 104, "prior": 104, + "pagedown": 109, "next": 109, + "up": 103, "down": 108, "left": 105, "right": 106, + "f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, + "f7": 65, "f8": 66, "f9": 67, "f10": 68, "f11": 87, "f12": 88, +} + +# Hotkey-string modifier tokens -> canonical modifier name. +_MOD_ALIASES = { + "ctrl": "ctrl", "control": "ctrl", + "shift": "shift", + "alt": "alt", "mod1": "alt", + "super": "super", "win": "super", "meta": "super", "mod4": "super", +} + +# evdev keycodes for each modifier (left/right), used for the raw fallback +# when libxkbcommon is unavailable. +_MOD_CODES = { + "ctrl": {29, 97}, # KEY_LEFTCTRL / KEY_RIGHTCTRL + "shift": {42, 54}, # KEY_LEFTSHIFT / KEY_RIGHTSHIFT + "alt": {56, 100}, # KEY_LEFTALT / KEY_RIGHTALT + "super": {125, 126}, # KEY_LEFTMETA / KEY_RIGHTMETA +} +_ALL_MOD_CODES = {c for s in _MOD_CODES.values() for c in s} +_ALL_MODS = ("ctrl", "shift", "alt", "super") + +# Suppress duplicate fires that can arrive within the same millisecond when a +# keyboard is exposed through more than one event node. +_DEDUP_MS = 100 + + +def _parse(hotkey: str) -> tuple[frozenset, int, str]: + """Return (required-modifier-name set, main evdev keycode, key name). + + Raises ValueError for an empty string, an unknown modifier, or a main + key not in _KEYCODES.""" + parts = [p.strip().lower() for p in hotkey.split("+") if p.strip()] + if not parts: + raise ValueError(f"empty hotkey: {hotkey!r}") + key_token = parts[-1] + mods = set() + for token in parts[:-1]: + name = _MOD_ALIASES.get(token) + if name is None: + raise ValueError(f"unknown modifier {token!r} in {hotkey!r}") + mods.add(name) + code = _KEYCODES.get(key_token) + if code is None: + raise ValueError(f"unknown key {key_token!r} in {hotkey!r}") + return frozenset(mods), code, key_token + + +class EvdevHotkey: + def __init__( + self, + hotkey_str: str, + on_trigger: Callable[[], None], + use_polling: bool = False, + ) -> None: + self._hotkey_str = hotkey_str + self._on_trigger = on_trigger + # use_polling is irrelevant for evdev (already event-driven and + # grab-free); accepted only for interface parity with hotkey.Hotkey. + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._held: set[int] = set() # raw modifier keycodes down (fallback) + self._xkb = None # layout-aware modifier state + self._last_fire_ms = 0 + + def start(self) -> None: + self._stop.clear() + self._thread = threading.Thread( + target=self._run, daemon=True, name="linuxpop-hotkey-evdev") + self._thread.start() + + def stop(self, wait: bool = True, timeout: float = 1.5) -> None: + self._stop.set() + t = self._thread + if wait and t is not None and t.is_alive(): + t.join(timeout=timeout) + + def _open_devices(self) -> list: + import glob + # by-path *-event-kbd names the physical keyboards and excludes + # ydotool's virtual uinput node (no bus path), so injected keys never + # trigger the hotkey. + paths = set() + for p in glob.glob("/dev/input/by-path/*-event-kbd"): + try: + paths.add(os.path.realpath(p)) + except OSError: + pass + if not paths: + paths = set(glob.glob("/dev/input/event*")) + fds = [] + for path in sorted(paths): + try: + fds.append(os.open(path, os.O_RDONLY | os.O_NONBLOCK)) + except OSError: + pass + return fds + + def _run(self) -> None: + try: + req_mods, main_code, _key_name = _parse(self._hotkey_str) + except ValueError as exc: + print(f"[hotkey-evdev] {exc}") + return + fds = self._open_devices() + if not fds: + print("[hotkey-evdev] no readable input devices - add yourself to " + "the 'input' group? Global hotkey disabled.") + return + # Layout-aware modifier state (honours Ctrl/Alt swap etc.). Reused + # from the KDE backend; falls back to raw keycodes if libxkbcommon is + # missing. + from .wayland_kde import _XkbModifierState + self._xkb = _XkbModifierState() + print(f"[hotkey-evdev] listening for {self._hotkey_str!r} " + f"(code={main_code}, mods={sorted(req_mods)}) on " + f"{len(fds)} device(s)") + EV_KEY = 0x01 + fmt = "llHHi" + size = struct.calcsize(fmt) + try: + while not self._stop.is_set(): + r, _, _ = _select.select(fds, [], [], 0.5) + for fd in r: + try: + data = os.read(fd, size * 64) + except (BlockingIOError, OSError): + continue + for off in range(0, len(data) - size + 1, size): + _s, _us, et, code, val = struct.unpack_from( + fmt, data, off) + if et != EV_KEY: + continue + # Feed xkb (press=1/release=0; ignore autorepeat=2) + # and keep the raw-code fallback set in sync. + if val in (0, 1): + self._xkb.update(code, val == 1) + if code in _ALL_MOD_CODES: + if val == 1: + self._held.add(code) + elif val == 0: + self._held.discard(code) + continue + # Main key press only (val==1; ignore release/repeat). + if code == main_code and val == 1: + if self._mods_match(req_mods) and self._fresh(): + print(f"[hotkey-evdev] {self._hotkey_str!r} " + "fired -- scheduling trigger") + GLib.idle_add(self._safe_trigger) + finally: + for fd in fds: + try: + os.close(fd) + except OSError: + pass + + def _mods_match(self, req_mods) -> bool: + """Exact match: every required modifier active, no others. Uses the + layout-aware xkb state, falling back to raw keycodes per-modifier when + xkb is unavailable.""" + for name in _ALL_MODS: + want = name in req_mods + active = self._xkb.is_active(name) if self._xkb else None + if active is None: + active = bool(self._held & _MOD_CODES[name]) + if active != want: + return False + return True + + def _fresh(self) -> bool: + """Debounce duplicate events from multiple device nodes.""" + now = int(time.monotonic() * 1000) + if now - self._last_fire_ms < _DEDUP_MS: + return False + self._last_fire_ms = now + return True + + def _safe_trigger(self) -> bool: + try: + self._on_trigger() + except Exception as exc: # noqa: BLE001 + print(f"[hotkey-evdev] trigger handler failed: {exc}") + return False diff --git a/platform_backend/xwayland_gnome.py b/platform_backend/xwayland_gnome.py new file mode 100644 index 0000000..5e7839a --- /dev/null +++ b/platform_backend/xwayland_gnome.py @@ -0,0 +1,153 @@ +"""XWayland + GNOME (Mutter) platform backend. + +GNOME's Mutter implements neither wlr-layer-shell (so a Wayland client cannot +place a surface at absolute coordinates) nor any external cursor-position API +(there is no equivalent of KWin's workspace.cursorPos). Both are exactly what +the KDE backend relies on for popup placement. So on GNOME we take a hybrid +route: + + - positioning + pointer -> inherited from X11Backend, with the app running + under XWayland (main.py sets GDK_BACKEND=x11 when this backend is chosen). + Gtk.Window.move() and Xlib XQueryPointer both work for an XWayland toplevel + and report global coordinates - the two hard problems, solved for free. + - selection watch / clipboard / key injection -> Wayland-native tools + (wl-clipboard, ydotool), reused verbatim from the KDE backend, because the + X11 tools (xclip / XFixes / xdotool) only see XWayland apps, not native + Wayland ones. + - global hotkey -> evdev (/dev/input), read at kernel level so it fires + regardless of which app (native Wayland or XWayland) has focus; an X11 + grab via XWayland would miss keys while a native Wayland window is focused. + - active window -> AT-SPI (GNOME's native a11y), merged with the XWayland + WM_CLASS so legacy X11 apps are still matched. + +Net result: positioning/pointer come straight from the proven X11 path; only +the selection / clipboard / injection / hotkey I/O is swapped for Wayland- +native equivalents that already exist and are tested on KDE. +""" +from __future__ import annotations + +import os +import shutil +import sys +from typing import Optional + +from .x11 import X11Backend + + +class XWaylandGnomeBackend(X11Backend): + name = "xwayland_gnome" + # Running under XWayland: the popup is an X11 toplevel, so the Xlib-based + # pointer/Esc polling and Gtk.Window.move() inherited from X11Backend apply. + popup_uses_xlib = True + # XQueryPointer returns physical root pixels (as on native X11), so the + # popup divides by the monitor scale - inherit X11Backend's False. + pointer_is_logical = False + + def __init__(self) -> None: + super().__init__() + # Lazily-created KDE backend instance reused purely as a Wayland I/O + # helper for clipboard + key injection (its wl-clipboard / ydotool code + # is compositor-agnostic). Never used for pointer or positioning. + self._wl = None + + def _wl_io(self): + if self._wl is None: + from .wayland_kde import WaylandKdeBackend + self._wl = WaylandKdeBackend() + return self._wl + + # ---- session --------------------------------------------------------- + def check_session(self) -> None: + if not os.environ.get("WAYLAND_DISPLAY"): + print("[gnome] WAYLAND_DISPLAY not set - wrong backend.", + file=sys.stderr) + sys.exit(2) + if (os.environ.get("GDK_BACKEND") or "").lower() != "x11": + print("[gnome] WARNING: GDK_BACKEND is not 'x11'. Popup positioning " + "needs the app to run under XWayland; main.py normally sets " + "this automatically before GTK starts.", file=sys.stderr) + if not os.environ.get("DISPLAY"): + print("[gnome] WARNING: DISPLAY unset - XWayland may be " + "unavailable; pointer queries and popup placement will fail.", + file=sys.stderr) + desktop = (os.environ.get("XDG_CURRENT_DESKTOP") or "").upper() + if "GNOME" not in desktop: + print(f"[gnome] note: XDG_CURRENT_DESKTOP={desktop!r}; the " + "xwayland_gnome backend targets GNOME but should also work on " + "other non-KDE Wayland compositors via XWayland.", + file=sys.stderr) + if not shutil.which("wl-paste"): + print("[gnome] WARNING: wl-clipboard (wl-paste/wl-copy) missing - " + "selection auto-popup and clipboard actions need it. " + "Install: sudo dnf install wl-clipboard", file=sys.stderr) + if not (shutil.which("ydotool") or shutil.which("wtype")): + print("[gnome] WARNING: no Wayland key injector (ydotool/wtype) - " + "Cut/Paste/Backspace actions will not work. " + "Install ydotool and enable the ydotoold daemon.", + file=sys.stderr) + + # ---- selection / clipboard (Wayland-native) -------------------------- + def read_selection(self, source: str) -> str: + return self._wl_io().read_selection(source) + + def set_clipboard(self, text: str) -> None: + self._wl_io().set_clipboard(text) + + # ---- keystroke injection (ydotool/wtype via the KDE helper) ---------- + def send_key(self, combo: str) -> None: + self._wl_io().send_key(combo) + + def type_text(self, text: str) -> None: + self._wl_io().type_text(text) + + def can_paste(self) -> bool: + return self._wl_io().can_paste() + + def paste(self) -> None: + self._wl_io().paste() + + # ---- active window (AT-SPI + XWayland WM_CLASS) ---------------------- + def active_window_haystacks(self) -> list[str]: + hays: list[str] = [] + try: + from editable_detect import active_window_atspi_haystacks + hays.extend(active_window_atspi_haystacks()) + except Exception: + pass + # XWayland WM_CLASS/title still helps for legacy X11 apps. + try: + hays.extend(super().active_window_haystacks()) + except Exception: + pass + seen: set[str] = set() + out: list[str] = [] + for h in hays: + if h and h not in seen: + seen.add(h) + out.append(h) + return out + + # ---- component factories -------------------------------------------- + def make_selection_watcher(self, on_selection, debounce_ms): + # wl-paste --primary --watch sees native Wayland apps' selection (the + # X11 XFixes watcher would only see XWayland apps). The watcher calls + # back into this backend for read_selection() and pointer_position(). + from .wayland_kde import WaylandSelectionWatcher + return WaylandSelectionWatcher(self, on_selection, + debounce_ms=debounce_ms) + + def make_hotkey(self, hotkey_str, on_trigger, use_polling=False): + from .evdev_hotkey import EvdevHotkey + return EvdevHotkey(hotkey_str, on_trigger, use_polling=use_polling) + + def make_double_click_watcher(self, on_double_click): + # Kernel-level evdev double-click watcher: sees native Wayland AND + # XWayland windows. Uses this backend's pointer_position() (XWayland). + from .wayland_kde import WaylandDoubleClickWatcher + return WaylandDoubleClickWatcher(self, on_double_click) + + # ---- popup positioning ---------------------------------------------- + # init_popup_window() and move_popup_window() are inherited from + # X11Backend: under XWayland, Gtk.Window.move() positions the toplevel at + # global coordinates, so no layer-shell setup is needed (and none exists on + # Mutter anyway). From d4be119353775d72b8f9ec5c3215e14002895a7c Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:44:53 +0200 Subject: [PATCH 02/10] docs: add GNOME Wayland handoff note for cross-machine continuity Self-contained state-of-play so the GNOME work can be continued on a fresh Fedora GNOME machine (or by a fresh Claude session) without the original chat transcript: design rationale, file map, what's verified vs pending, how to run and test on GNOME, and the deferred work (Flatpak packaging, fractional-scaling check, docs). --- docs/GNOME-HANDOFF.md | 140 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/GNOME-HANDOFF.md diff --git a/docs/GNOME-HANDOFF.md b/docs/GNOME-HANDOFF.md new file mode 100644 index 0000000..cfa9dbf --- /dev/null +++ b/docs/GNOME-HANDOFF.md @@ -0,0 +1,140 @@ +# GNOME / Mutter Wayland support — handoff + +Status as of the `feature/gnome-wayland-support` branch. This note exists so the +work can be continued on a fresh Fedora GNOME machine (or by a fresh Claude +session) without the original chat transcript. The code travels with this +branch; this file travels with it. + +## Goal + +Make LinuxPop's popup appear at the selected text / mouse pointer on **Fedora +GNOME (Mutter, Wayland)**, the same way it already works on **Fedora KDE Plasma +(KWin, Wayland)** — without regressing KDE or the X11/Mint (Cinnamon) path. + +## Why GNOME needed a different approach than KDE + +The KDE backend (`platform_backend/wayland_kde.py`) positions the popup with two +KWin-specific mechanisms: + +1. **Window placement** via `wlr-layer-shell` (`gtk-layer-shell`, anchor + margins). +2. **Pointer position** via a KWin JS script reading `workspace.cursorPos` over DBus. + +**Mutter implements neither.** It has no `wlr-layer-shell` (deliberate GNOME +decision) and no external cursor-position API. So the KDE positioning path +cannot work on GNOME. + +## The solution: a hybrid XWayland backend + +`platform_backend/xwayland_gnome.py` → `XWaylandGnomeBackend(X11Backend)`. + +Key insight: the **X11 backend already solves the two hard problems** when run +under XWayland. `Gtk.Window.move()` positions an XWayland toplevel at global +coordinates, and `XQueryPointer` returns the global pointer — both DE-agnostic. +So GNOME reuses the proven X11 path for positioning/pointer and swaps **only** +the I/O that X11 tools can't do for native Wayland apps. + +| Capability | Source | Why | +|---|---|---| +| pointer_position | inherited `X11Backend` (XQueryPointer) | global coords under XWayland | +| popup placement (`move_popup_window`, `init_popup_window`) | inherited `X11Backend` (`Gtk.Window.move`) | works for XWayland toplevel; no layer-shell needed | +| `popup_uses_xlib = True`, `pointer_is_logical = False` | inherited | XWayland behaves like native X11 | +| selection watch | `WaylandSelectionWatcher` (`wl-paste --primary --watch`) | sees native Wayland apps; XFixes/xclip would only see XWayland apps | +| read_selection / set_clipboard | delegated to `WaylandKdeBackend` (`wl-clipboard`) | compositor-agnostic | +| key injection (send_key/type_text/can_paste/paste) | delegated to `WaylandKdeBackend` (ydotool/wtype) | `xdotool` can't reach native Wayland apps | +| double-click watcher | `WaylandDoubleClickWatcher` (kernel-level evdev) | sees native Wayland + XWayland | +| global hotkey | **new** `EvdevHotkey` (`/dev/input`) | X11 grab via XWayland misses native-Wayland-focused windows | +| active window (blocklist) | AT-SPI + XWayland WM_CLASS | WM_CLASS unavailable for native Wayland apps | + +The `WaylandKdeBackend` instance is created lazily and used **only** as a +compositor-agnostic I/O helper (clipboard + injection); never for pointer or +positioning. Its `__init__` only sets up a DBus main loop — harmless on GNOME. + +## Files in this branch + +**New:** +- `platform_backend/xwayland_gnome.py` — the hybrid backend. +- `platform_backend/evdev_hotkey.py` — `EvdevHotkey`: kernel-level global hotkey. + Modifier state via libxkbcommon (reuses `wayland_kde._XkbModifierState`), so + `ctrl:swap_lalt_lctl` and other xkb options are honoured. Interface mirrors + `hotkey.Hotkey` (start / stop(wait, timeout)). + +**Changed:** +- `platform_backend/__init__.py` — `detect()` routes GNOME+Wayland → + `xwayland_gnome`; KDE → `wayland_kde`; other Wayland → `wayland_kde` + (best-effort); non-Wayland → `x11`. `get_backend()` wires the new backend. + Override with `LINUXPOP_BACKEND=x11|wayland_kde|xwayland_gnome`. +- `main.py` — forces `GDK_BACKEND=x11` **before** importing gi/Gtk, but **only** + when `detect() == "xwayland_gnome"` (KDE keeps native Wayland for layer-shell). + Forces x11 even if the session presets `GDK_BACKEND=wayland`. Opt out with + `LINUXPOP_GDK_BACKEND=`. +- `editable_detect.py` — adds `active_window_atspi_haystacks()` for blocklist + matching on Wayland (AT-SPI, since WM_CLASS is unavailable for native apps). + +## What was verified (on KDE-under-XWayland; positioning/pointer are DE-agnostic) + +- `detect()` correct for GNOME / KDE / X11 / forced override. +- Backend instantiates; method resolution correct (positioning inherited from + `X11Backend`, I/O overridden). +- `pointer_position()` returns real global coords (XWayland). +- `read_selection("primary")` returns real selection (wl-paste). +- `can_paste()` True (ydotool present). +- GDK bootstrap forces x11 for gnome backend; leaves KDE on `wayland`. +- `test_popup.py` cycled 3 popups with no positioning crash. +- `EvdevHotkey` arms, opens 5 input devices, and the xkb state correctly + reflected the user's `layout=no variant=mac options=ctrl:swap_lalt_lctl`. +- KDE backend confirmed unchanged. + +## What still needs a REAL GNOME session to verify + +1. **Placement accuracy** — popup actually appears at the selection / pointer, + not the top-left corner. +2. **Fractional scaling (125%/150%)** — the main open risk. XWayland coordinates + may be offset/blurry under Mutter's fractional scaling; if so, adjust the + scale handling in `popup.py` (it divides physical coords by monitor scale + when `pointer_is_logical` is False). +3. **Cut/Paste/Backspace** via ydotool into native Wayland apps. +4. **Global hotkey** firing while a native Wayland app is focused. +5. **AT-SPI selection-rect anchoring** (`focused_selection_rect`) on GNOME apps. + +## How to run/test on Fedora GNOME + +```bash +git clone https://github.com/GaimsDevSoftware/linuxpop.git +cd linuxpop +git checkout feature/gnome-wayland-support + +# prerequisites +sudo dnf install wl-clipboard ydotool python3-gobject gtk3 \ + python3-xlib xdotool xclip tesseract +sudo usermod -aG input "$USER" # for evdev hotkey + double-click; re-login +systemctl --user enable --now ydotoold # or start ydotoold manually + +python main.py # backend auto-selected; GDK_BACKEND auto-set +``` + +The backend is chosen automatically on GNOME — no env vars needed. +`check_session()` prints non-fatal warnings if `wl-clipboard` or a Wayland key +injector is missing. + +Debugging: +- Force the backend: `LINUXPOP_BACKEND=xwayland_gnome python main.py --debug` +- Confirm XWayland: the app should set `GDK_BACKEND=x11` itself; verify with + `--debug` output. +- If the popup lands top-left: pointer query failed — check `DISPLAY` is set and + XWayland is running. + +## Remaining work (deferred until GNOME verification) + +- **Flatpak packaging for GNOME**: `finish-args` need `/dev/input` access (evdev + hotkey + double-click) and ydotool, mirroring the KDE work in commit + `e92c36e` ("Bundle ydotool in Flatpak…"). See `packaging/` and `PACKAGING.md`. +- **Docs/README**: mark GNOME as supported once visually confirmed. +- **Fractional-scaling fix** in `popup.py` if step 2 above shows offset. + +## Known caveats + +- evdev hotkey + double-click need the user in the `input` group (same as + ydotool — no new requirement beyond KDE). +- Primary selection isn't set identically by every app (some lazily); app-level, + also true on X11 — not a GNOME-specific regression. +- Blocklist matching is weaker on GNOME (AT-SPI app name instead of WM_CLASS). From c727766a8db5fc915f260ce1ae98452fbf92d910 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:21:59 +0200 Subject: [PATCH 03/10] docs: GNOME/Wayland backend design note Replace the branch handoff note with a standard architecture doc covering the hybrid XWayland approach, the capability split vs the KDE backend, the fractional-scaling coordinate handling, and setup on Fedora GNOME. --- docs/GNOME-HANDOFF.md | 140 ------------------------------------------ docs/gnome-wayland.md | 108 ++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 140 deletions(-) delete mode 100644 docs/GNOME-HANDOFF.md create mode 100644 docs/gnome-wayland.md diff --git a/docs/GNOME-HANDOFF.md b/docs/GNOME-HANDOFF.md deleted file mode 100644 index cfa9dbf..0000000 --- a/docs/GNOME-HANDOFF.md +++ /dev/null @@ -1,140 +0,0 @@ -# GNOME / Mutter Wayland support — handoff - -Status as of the `feature/gnome-wayland-support` branch. This note exists so the -work can be continued on a fresh Fedora GNOME machine (or by a fresh Claude -session) without the original chat transcript. The code travels with this -branch; this file travels with it. - -## Goal - -Make LinuxPop's popup appear at the selected text / mouse pointer on **Fedora -GNOME (Mutter, Wayland)**, the same way it already works on **Fedora KDE Plasma -(KWin, Wayland)** — without regressing KDE or the X11/Mint (Cinnamon) path. - -## Why GNOME needed a different approach than KDE - -The KDE backend (`platform_backend/wayland_kde.py`) positions the popup with two -KWin-specific mechanisms: - -1. **Window placement** via `wlr-layer-shell` (`gtk-layer-shell`, anchor + margins). -2. **Pointer position** via a KWin JS script reading `workspace.cursorPos` over DBus. - -**Mutter implements neither.** It has no `wlr-layer-shell` (deliberate GNOME -decision) and no external cursor-position API. So the KDE positioning path -cannot work on GNOME. - -## The solution: a hybrid XWayland backend - -`platform_backend/xwayland_gnome.py` → `XWaylandGnomeBackend(X11Backend)`. - -Key insight: the **X11 backend already solves the two hard problems** when run -under XWayland. `Gtk.Window.move()` positions an XWayland toplevel at global -coordinates, and `XQueryPointer` returns the global pointer — both DE-agnostic. -So GNOME reuses the proven X11 path for positioning/pointer and swaps **only** -the I/O that X11 tools can't do for native Wayland apps. - -| Capability | Source | Why | -|---|---|---| -| pointer_position | inherited `X11Backend` (XQueryPointer) | global coords under XWayland | -| popup placement (`move_popup_window`, `init_popup_window`) | inherited `X11Backend` (`Gtk.Window.move`) | works for XWayland toplevel; no layer-shell needed | -| `popup_uses_xlib = True`, `pointer_is_logical = False` | inherited | XWayland behaves like native X11 | -| selection watch | `WaylandSelectionWatcher` (`wl-paste --primary --watch`) | sees native Wayland apps; XFixes/xclip would only see XWayland apps | -| read_selection / set_clipboard | delegated to `WaylandKdeBackend` (`wl-clipboard`) | compositor-agnostic | -| key injection (send_key/type_text/can_paste/paste) | delegated to `WaylandKdeBackend` (ydotool/wtype) | `xdotool` can't reach native Wayland apps | -| double-click watcher | `WaylandDoubleClickWatcher` (kernel-level evdev) | sees native Wayland + XWayland | -| global hotkey | **new** `EvdevHotkey` (`/dev/input`) | X11 grab via XWayland misses native-Wayland-focused windows | -| active window (blocklist) | AT-SPI + XWayland WM_CLASS | WM_CLASS unavailable for native Wayland apps | - -The `WaylandKdeBackend` instance is created lazily and used **only** as a -compositor-agnostic I/O helper (clipboard + injection); never for pointer or -positioning. Its `__init__` only sets up a DBus main loop — harmless on GNOME. - -## Files in this branch - -**New:** -- `platform_backend/xwayland_gnome.py` — the hybrid backend. -- `platform_backend/evdev_hotkey.py` — `EvdevHotkey`: kernel-level global hotkey. - Modifier state via libxkbcommon (reuses `wayland_kde._XkbModifierState`), so - `ctrl:swap_lalt_lctl` and other xkb options are honoured. Interface mirrors - `hotkey.Hotkey` (start / stop(wait, timeout)). - -**Changed:** -- `platform_backend/__init__.py` — `detect()` routes GNOME+Wayland → - `xwayland_gnome`; KDE → `wayland_kde`; other Wayland → `wayland_kde` - (best-effort); non-Wayland → `x11`. `get_backend()` wires the new backend. - Override with `LINUXPOP_BACKEND=x11|wayland_kde|xwayland_gnome`. -- `main.py` — forces `GDK_BACKEND=x11` **before** importing gi/Gtk, but **only** - when `detect() == "xwayland_gnome"` (KDE keeps native Wayland for layer-shell). - Forces x11 even if the session presets `GDK_BACKEND=wayland`. Opt out with - `LINUXPOP_GDK_BACKEND=`. -- `editable_detect.py` — adds `active_window_atspi_haystacks()` for blocklist - matching on Wayland (AT-SPI, since WM_CLASS is unavailable for native apps). - -## What was verified (on KDE-under-XWayland; positioning/pointer are DE-agnostic) - -- `detect()` correct for GNOME / KDE / X11 / forced override. -- Backend instantiates; method resolution correct (positioning inherited from - `X11Backend`, I/O overridden). -- `pointer_position()` returns real global coords (XWayland). -- `read_selection("primary")` returns real selection (wl-paste). -- `can_paste()` True (ydotool present). -- GDK bootstrap forces x11 for gnome backend; leaves KDE on `wayland`. -- `test_popup.py` cycled 3 popups with no positioning crash. -- `EvdevHotkey` arms, opens 5 input devices, and the xkb state correctly - reflected the user's `layout=no variant=mac options=ctrl:swap_lalt_lctl`. -- KDE backend confirmed unchanged. - -## What still needs a REAL GNOME session to verify - -1. **Placement accuracy** — popup actually appears at the selection / pointer, - not the top-left corner. -2. **Fractional scaling (125%/150%)** — the main open risk. XWayland coordinates - may be offset/blurry under Mutter's fractional scaling; if so, adjust the - scale handling in `popup.py` (it divides physical coords by monitor scale - when `pointer_is_logical` is False). -3. **Cut/Paste/Backspace** via ydotool into native Wayland apps. -4. **Global hotkey** firing while a native Wayland app is focused. -5. **AT-SPI selection-rect anchoring** (`focused_selection_rect`) on GNOME apps. - -## How to run/test on Fedora GNOME - -```bash -git clone https://github.com/GaimsDevSoftware/linuxpop.git -cd linuxpop -git checkout feature/gnome-wayland-support - -# prerequisites -sudo dnf install wl-clipboard ydotool python3-gobject gtk3 \ - python3-xlib xdotool xclip tesseract -sudo usermod -aG input "$USER" # for evdev hotkey + double-click; re-login -systemctl --user enable --now ydotoold # or start ydotoold manually - -python main.py # backend auto-selected; GDK_BACKEND auto-set -``` - -The backend is chosen automatically on GNOME — no env vars needed. -`check_session()` prints non-fatal warnings if `wl-clipboard` or a Wayland key -injector is missing. - -Debugging: -- Force the backend: `LINUXPOP_BACKEND=xwayland_gnome python main.py --debug` -- Confirm XWayland: the app should set `GDK_BACKEND=x11` itself; verify with - `--debug` output. -- If the popup lands top-left: pointer query failed — check `DISPLAY` is set and - XWayland is running. - -## Remaining work (deferred until GNOME verification) - -- **Flatpak packaging for GNOME**: `finish-args` need `/dev/input` access (evdev - hotkey + double-click) and ydotool, mirroring the KDE work in commit - `e92c36e` ("Bundle ydotool in Flatpak…"). See `packaging/` and `PACKAGING.md`. -- **Docs/README**: mark GNOME as supported once visually confirmed. -- **Fractional-scaling fix** in `popup.py` if step 2 above shows offset. - -## Known caveats - -- evdev hotkey + double-click need the user in the `input` group (same as - ydotool — no new requirement beyond KDE). -- Primary selection isn't set identically by every app (some lazily); app-level, - also true on X11 — not a GNOME-specific regression. -- Blocklist matching is weaker on GNOME (AT-SPI app name instead of WM_CLASS). diff --git a/docs/gnome-wayland.md b/docs/gnome-wayland.md new file mode 100644 index 0000000..24b4899 --- /dev/null +++ b/docs/gnome-wayland.md @@ -0,0 +1,108 @@ +# GNOME / Mutter Wayland support + +How LinuxPop makes its popup work on **Fedora GNOME (Mutter, Wayland)**, the same +way it already works on **Fedora KDE Plasma (KWin, Wayland)** and **X11/Mint +(Cinnamon)** — without regressing either of those paths. + +## Why GNOME needs a different approach than KDE + +The KDE backend (`platform_backend/wayland_kde.py`) positions the popup with two +KWin-specific mechanisms: + +1. **Window placement** via `wlr-layer-shell` (`gtk-layer-shell`, anchor + margins). +2. **Pointer position** via a KWin JS script reading `workspace.cursorPos` over DBus. + +Mutter implements neither. It has no `wlr-layer-shell` (a deliberate GNOME +decision) and no external cursor-position API, so the KDE positioning path cannot +work on GNOME. + +## The solution: a hybrid XWayland backend + +`platform_backend/xwayland_gnome.py` → `XWaylandGnomeBackend(X11Backend)`. + +The X11 backend already solves the two hard problems when run under XWayland: +`Gtk.Window.move()` positions an XWayland toplevel at global coordinates, and +`XQueryPointer` returns the global pointer — both are DE-agnostic. So GNOME reuses +the proven X11 path for positioning and pointer, and swaps **only** the I/O that +X11 tools can't do for native Wayland apps. + +| Capability | Source | Why | +|---|---|---| +| pointer_position | inherited `X11Backend` (XQueryPointer) | global coords under XWayland | +| popup placement (`move_popup_window`, `init_popup_window`) | inherited `X11Backend` (`Gtk.Window.move`) | works for XWayland toplevel; no layer-shell needed | +| `popup_uses_xlib = True`, `pointer_is_logical = False` | inherited | XWayland behaves like native X11 | +| selection watch | `WaylandSelectionWatcher` (`wl-paste --primary --watch`) | sees native Wayland apps; XFixes/xclip would only see XWayland apps | +| read_selection / set_clipboard | delegated to `WaylandKdeBackend` (`wl-clipboard`) | compositor-agnostic | +| key injection (send_key/type_text/can_paste/paste) | delegated to `WaylandKdeBackend` (ydotool/wtype) | `xdotool` can't reach native Wayland apps | +| double-click watcher | `WaylandDoubleClickWatcher` (kernel-level evdev) | sees native Wayland + XWayland | +| global hotkey | `EvdevHotkey` (`/dev/input`) | X11 grab via XWayland misses native-Wayland-focused windows | +| active window (blocklist) | AT-SPI + XWayland WM_CLASS | WM_CLASS unavailable for native Wayland apps | + +The `WaylandKdeBackend` instance is created lazily and used only as a +compositor-agnostic I/O helper (clipboard + injection); never for pointer or +positioning. Its `__init__` only sets up a DBus main loop, which is harmless on +GNOME. + +## Files + +**New:** +- `platform_backend/xwayland_gnome.py` — the hybrid backend. +- `platform_backend/evdev_hotkey.py` — `EvdevHotkey`: kernel-level global hotkey. + Modifier state via libxkbcommon (reuses `wayland_kde._XkbModifierState`), so + `ctrl:swap_lalt_lctl` and other xkb options are honoured. Interface mirrors + `hotkey.Hotkey` (start / stop(wait, timeout)). + +**Changed:** +- `platform_backend/__init__.py` — `detect()` routes GNOME+Wayland → + `xwayland_gnome`; KDE → `wayland_kde`; other Wayland → `wayland_kde` + (best-effort); non-Wayland → `x11`. `get_backend()` wires the new backend. + Override with `LINUXPOP_BACKEND=x11|wayland_kde|xwayland_gnome`. +- `main.py` — forces `GDK_BACKEND=x11` before importing gi/Gtk, but only when + `detect() == "xwayland_gnome"` (KDE keeps native Wayland for layer-shell). + Forces x11 even if the session presets `GDK_BACKEND=wayland`. Opt out with + `LINUXPOP_GDK_BACKEND=`. +- `editable_detect.py` — adds `active_window_atspi_haystacks()` for blocklist + matching on Wayland (AT-SPI, since WM_CLASS is unavailable for native apps). + +## Coordinates and fractional scaling + +Under Mutter fractional scaling the GTK logical screen is the scaled-down size +(e.g. 3072×1728 at 125% on a 3840×2160 panel) and `monitor.get_scale_factor()` +reports the integer ceiling (2). `XQueryPointer` returns device coordinates at +that integer scale (≈2× the logical pointer), and `popup.py` divides global +coordinates by `monitor.get_scale_factor()` when `pointer_is_logical` is False, +recovering the correct logical position. The popup anchors above the selection +rect when one is available (`focused_selection_rect`), and falls back to the +pointer position otherwise. + +## Running on Fedora GNOME + +```bash +git clone https://github.com/GaimsDevSoftware/linuxpop.git +cd linuxpop + +# prerequisites +sudo dnf install wl-clipboard ydotool python3-gobject gtk3 \ + python3-xlib xdotool xclip tesseract +sudo usermod -aG input "$USER" # for evdev hotkey + double-click; re-login +systemctl --user enable --now ydotoold # or start ydotoold manually + +python main.py # backend auto-selected; GDK_BACKEND auto-set +``` + +The backend is chosen automatically on GNOME — no env vars needed. +`check_session()` prints non-fatal warnings if `wl-clipboard` or a Wayland key +injector is missing. + +Debugging: +- Force the backend: `LINUXPOP_BACKEND=xwayland_gnome python main.py --debug` +- If the popup lands top-left, the pointer query failed — check `DISPLAY` is set + and XWayland is running. + +## Caveats + +- The evdev hotkey and double-click watcher need the user in the `input` group + (same as ydotool — no new requirement beyond KDE). +- Primary selection isn't set identically by every app (some set it lazily). This + is app-level and also true on X11, not a GNOME-specific regression. +- Blocklist matching is weaker on GNOME (AT-SPI app name instead of WM_CLASS). From 3a95b8e520a1c50f2c463f0545573bcf0231c3c2 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:42:09 +0200 Subject: [PATCH 04/10] popup: anchor above the selected text on GNOME (enable AT-SPI selection rect) The popup is meant to open directly above the selected text, falling back to the mouse pointer only when that geometry isn't available. The rect path already existed (focused_selection_rect -> popup.show_for rect anchoring) but was unreachable on GNOME for two reasons, both fixed here: - editable_detect._atspi_environment_safe() only accepted AT-SPI when a socket existed at $XDG_RUNTIME_DIR/at-spi/bus_0. GNOME drops no socket there and exposes the bus via org.a11y.Bus on the session bus instead, so the probe returned False and _HAS_ATSPI was False. The probe now runs the foreign-uid at-spi-bus-launcher crash guard first (unchanged), then accepts either the XDG socket (KDE / X11) or a reachable org.a11y.Bus (GNOME). - focused_selection_rect() was gated behind editable_atspi_listener_enabled (the heavy always-on focus listener, off by default). It does a one-shot bounded walk and never needs that listener, so it's re-gated on the user-facing popup_anchor_to_selection toggle (default on). The focus listener and is_focus_editable's synchronous walk stay gated on editable_atspi_listener_enabled, so this does not turn those on. Requires desktop accessibility (toolkit-accessibility) so apps expose AT-SPI. When the rect is unavailable the popup falls back to the mouse pointer exactly as before, so this is safe when AT-SPI is off or an app exposes no geometry. --- editable_detect.py | 57 ++++++++++++++++++++++++++++++++++------------ settings.py | 8 ++++--- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/editable_detect.py b/editable_detect.py index dba8397..18acdc6 100644 --- a/editable_detect.py +++ b/editable_detect.py @@ -48,29 +48,42 @@ # AT-SPI is optional. If gi bindings aren't installed we skip to the # WM_CLASS fallback only - no hard dependency. # -# Two-step defensive probe before letting any code path call Atspi: -# 1. Our own at-spi bus socket must exist at the XDG path and be -# r/w by us. -# 2. There must be NO at-spi-bus-launcher running as another user. +# Defensive probe before letting any code path call Atspi: +# 1. There must be NO at-spi-bus-launcher running as another user. # A foreign-uid launcher (typically a root one left behind by # sudo'd interactions) can route our dbus query to its bus - # we then crash on first use via glib dbind-ERROR (SIGTRAP, # uncatchable from Python). +# 2. An AT-SPI bus must actually be reachable: either our own socket +# at the XDG at-spi/bus_0 path (KDE / X11) or the session's +# org.a11y.Bus service (GNOME, which drops no socket at that path). # When either check fails, skip the Atspi import entirely and fall # back to the WM_CLASS heuristic. The walker still works fine, # just without per-widget editable detection inside Electron apps. +def _a11y_bus_reachable() -> bool: + """True if the session exposes an AT-SPI bus via the standard + org.a11y.Bus service. GNOME uses this and drops no socket at the XDG + at-spi/bus_0 path that KDE/X11 rely on. Asking for the address is what + Atspi itself does on first use, so success here means later Atspi calls + reach a real bus.""" + try: + import dbus + addr = dbus.SessionBus().get_object( + "org.a11y.Bus", "/org/a11y/bus").GetAddress( + dbus_interface="org.a11y.Bus") + return bool(addr) + except Exception: + return False + + def _atspi_environment_safe() -> tuple[bool, str]: import os as _os import glob as _glob try: - runtime_dir = (_os.environ.get("XDG_RUNTIME_DIR") - or f"/run/user/{_os.getuid()}") - bus_socket = _os.path.join(runtime_dir, "at-spi", "bus_0") - if not _os.path.exists(bus_socket): - return False, f"bus socket missing at {bus_socket}" - if not _os.access(bus_socket, _os.R_OK | _os.W_OK): - return False, f"bus socket unreadable at {bus_socket}" my_uid = _os.getuid() + # Crash guard first, independent of how the bus is reached: a + # foreign-uid at-spi-bus-launcher can route our query to its bus + # and crash us via dbind-ERROR (SIGTRAP). for proc_dir in _glob.glob("/proc/[0-9]*"): try: with open(f"{proc_dir}/comm") as f: @@ -86,7 +99,19 @@ def _atspi_environment_safe() -> tuple[bool, str]: f"refusing to risk a dbind crash") except (OSError, ValueError): continue - return True, "" + # Then confirm a bus actually exists: XDG socket (KDE / X11) or + # org.a11y.Bus (GNOME). + runtime_dir = (_os.environ.get("XDG_RUNTIME_DIR") + or f"/run/user/{my_uid}") + bus_socket = _os.path.join(runtime_dir, "at-spi", "bus_0") + if _os.path.exists(bus_socket): + if not _os.access(bus_socket, _os.R_OK | _os.W_OK): + return False, f"bus socket unreadable at {bus_socket}" + return True, "" + if _a11y_bus_reachable(): + return True, "" + return False, (f"no at-spi bus (no socket at {bus_socket}, " + "no org.a11y.Bus)") except Exception as exc: return False, f"probe error: {exc}" @@ -423,13 +448,15 @@ def focused_selection_rect(timeout: float = 0.15) -> tuple[int, int, int, int] | the mouse pointer (the user's explicit request). Returns None - so the caller falls back to mouse positioning - whenever AT-SPI is off, unavailable, times out, the widget exposes no Text interface, or - nothing is selected. Gated on the same editable_atspi_listener_enabled - setting as the rest of the AT-SPI machinery.""" + nothing is selected. Gated on popup_anchor_to_selection (the user-facing + toggle, default on) plus AT-SPI availability - this is a one-shot bounded + walk and does not need the always-on focus listener, so it stays + decoupled from editable_atspi_listener_enabled.""" if not _HAS_ATSPI: return None try: from settings import get_settings - if not bool(get_settings().get("editable_atspi_listener_enabled")): + if not bool(get_settings().get("popup_anchor_to_selection")): return None except Exception: return None diff --git a/settings.py b/settings.py index 7bd171e..8255035 100644 --- a/settings.py +++ b/settings.py @@ -305,9 +305,11 @@ # explicitly wants AT-SPI back on. Default off. "editable_atspi_listener_enabled": False, # Anchor the popup to the SELECTED-TEXT rectangle (via AT-SPI screen - # extents) instead of the mouse pointer. On by default, but it only has - # any effect when editable_atspi_listener_enabled is also True AND the - # focused app exposes accessibility - otherwise the popup silently falls + # extents) instead of the mouse pointer - this is the primary placement. + # On by default. It uses a one-shot bounded AT-SPI walk (no always-on + # focus listener needed, so it's independent of + # editable_atspi_listener_enabled). When AT-SPI is unavailable or the + # focused app exposes no selection geometry, the popup silently falls # back to the mouse pointer (so leaving this on costs nothing). "popup_anchor_to_selection": True, # If True, show the one-time welcome dialog on first run. Set to False From c6d445ce4f85b96824edb46d52267f6b63c4e9c7 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:34:49 +0200 Subject: [PATCH 05/10] popup: keep AT-SPI selection walk off on Cinnamon by default The selection-rect anchoring enabled in the previous commit runs a one-shot AT-SPI walk by default (gated on popup_anchor_to_selection). On Cinnamon (Linux Mint's default) activating AT-SPI was correlated with a desktop-panel segfault - the exact reason the focus listener defaults off. Guard focused_selection_rect with a Cinnamon check so Mint keeps its proven mouse-pointer placement; power users who set editable_atspi_listener_enabled still opt in. GNOME/KDE are unaffected. --- editable_detect.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/editable_detect.py b/editable_detect.py index 18acdc6..2a08f63 100644 --- a/editable_detect.py +++ b/editable_detect.py @@ -76,6 +76,16 @@ def _a11y_bus_reachable() -> bool: return False +def _de_is_cinnamon() -> bool: + """True on Cinnamon (Linux Mint's default). Activating AT-SPI there was + correlated with a desktop-panel segfault (xapp-sn-watcher ATK assertions - + see settings.py), so the AT-SPI selection walk stays off by default on + Cinnamon and the popup falls back to the mouse pointer - the proven Mint + behaviour.""" + import os as _os + return "cinnamon" in (_os.environ.get("XDG_CURRENT_DESKTOP", "").lower()) + + def _atspi_environment_safe() -> tuple[bool, str]: import os as _os import glob as _glob @@ -456,7 +466,13 @@ def focused_selection_rect(timeout: float = 0.15) -> tuple[int, int, int, int] | return None try: from settings import get_settings - if not bool(get_settings().get("popup_anchor_to_selection")): + s = get_settings() + if not bool(s.get("popup_anchor_to_selection")): + return None + # Cinnamon: activating AT-SPI was correlated with a panel segfault. + # Stay off by default there (pointer fallback); honour an explicit + # opt-in for power users who accept the risk. + if _de_is_cinnamon() and not bool(s.get("editable_atspi_listener_enabled")): return None except Exception: return None From 3901c1c4fc7ced332c3e32af9c867d7dbd6361a7 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:44:25 +0200 Subject: [PATCH 06/10] packaging: ship Wayland ydotool setup (udev rule + user ydotoold service) Native (non-Flatpak) installs had no working keystroke injection on Wayland: the distro ydotool package ships only a *system* ydotoold service that runs as root on a root-only socket the app can't reach, and /dev/uinput is root-only. So Cut/Paste/Backspace silently no-op'd on GNOME/KDE Wayland. Add packaging/wayland/ with the standard fix: a udev rule giving the 'input' group access to /dev/uinput, a per-user ydotoold.service on the socket path the Wayland backend expects ($XDG_RUNTIME_DIR/.ydotool_socket), and a README with the one-time setup. The Flatpak path is unchanged (it bundles ydotool itself). Fix the stale gnome-wayland.md instruction that referenced a non-existent user unit. Verified on Fedora 44 GNOME/Wayland: with the udev rule + user ydotoold, ydotool injection lands keystrokes in the focused window and the backend reports can_paste=True. --- docs/gnome-wayland.md | 6 +++++- packaging/wayland/99-uinput.rules | 5 +++++ packaging/wayland/README.md | 34 ++++++++++++++++++++++++++++++ packaging/wayland/ydotoold.service | 15 +++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packaging/wayland/99-uinput.rules create mode 100644 packaging/wayland/README.md create mode 100644 packaging/wayland/ydotoold.service diff --git a/docs/gnome-wayland.md b/docs/gnome-wayland.md index 24b4899..16bffe2 100644 --- a/docs/gnome-wayland.md +++ b/docs/gnome-wayland.md @@ -85,7 +85,11 @@ cd linuxpop sudo dnf install wl-clipboard ydotool python3-gobject gtk3 \ python3-xlib xdotool xclip tesseract sudo usermod -aG input "$USER" # for evdev hotkey + double-click; re-login -systemctl --user enable --now ydotoold # or start ydotoold manually + +# Cut/Paste/Backspace injection on Wayland needs a per-user ydotoold + +# /dev/uinput access. See packaging/wayland/README.md for the one-time setup +# (udev rule + user service). Skip it if you only need selection-triggered +# popups; required for the keystroke actions. python main.py # backend auto-selected; GDK_BACKEND auto-set ``` diff --git a/packaging/wayland/99-uinput.rules b/packaging/wayland/99-uinput.rules new file mode 100644 index 0000000..58bce36 --- /dev/null +++ b/packaging/wayland/99-uinput.rules @@ -0,0 +1,5 @@ +# Let members of the 'input' group open /dev/uinput, so a per-user ydotoold +# can synthesize keystrokes (Cut / Paste / Backspace / Select-all) on Wayland. +# Install as /etc/udev/rules.d/99-uinput-linuxpop.rules then: +# sudo udevadm control --reload-rules && sudo udevadm trigger /dev/uinput +KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput" diff --git a/packaging/wayland/README.md b/packaging/wayland/README.md new file mode 100644 index 0000000..da49414 --- /dev/null +++ b/packaging/wayland/README.md @@ -0,0 +1,34 @@ +# Wayland keystroke injection setup (native installs) + +On Wayland (GNOME, KDE) the synthetic-input ban means LinuxPop can only inject +Cut / Paste / Backspace / Select-all through **ydotool**, which writes to +`/dev/uinput`. The **Flatpak bundles and starts ydotoold itself**, so this is +only needed for **native** installs (RPM / deb / running from source). + +It's a one-time setup. The packaged `ydotool.service` (a *system* service) runs +ydotoold as **root** on a root-only socket the app can't reach — so we run a +**per-user** ydotoold instead. + +```bash +# 1. Install ydotool and join the 'input' group +sudo dnf install ydotool # Fedora (apt install ydotool on Debian/Mint) +sudo usermod -aG input "$USER" + +# 2. Let the 'input' group use /dev/uinput +sudo install -m0644 99-uinput.rules /etc/udev/rules.d/99-uinput-linuxpop.rules +echo uinput | sudo tee /etc/modules-load.d/uinput.conf +sudo modprobe uinput +sudo udevadm control --reload-rules && sudo udevadm trigger /dev/uinput + +# 3. Run ydotoold as your user (retire the root system one if the distro enabled it) +sudo systemctl disable --now ydotool.service 2>/dev/null || true +install -Dm0644 ydotoold.service ~/.config/systemd/user/ydotoold.service +systemctl --user daemon-reload +systemctl --user enable --now ydotoold.service + +# 4. Log out and back in (so the 'input' group takes effect), then run LinuxPop. +``` + +LinuxPop points `YDOTOOL_SOCKET` at `$XDG_RUNTIME_DIR/.ydotool_socket`, which the +service creates. The same setup works on GNOME (`xwayland_gnome` backend) and KDE +(`wayland_kde` backend). X11 sessions don't need any of this — they use `xdotool`. diff --git a/packaging/wayland/ydotoold.service b/packaging/wayland/ydotoold.service new file mode 100644 index 0000000..5057fa4 --- /dev/null +++ b/packaging/wayland/ydotoold.service @@ -0,0 +1,15 @@ +[Unit] +Description=ydotoold user daemon (LinuxPop key injection on Wayland) +Documentation=man:ydotoold(8) + +[Service] +# Socket at $XDG_RUNTIME_DIR/.ydotool_socket - the path LinuxPop's Wayland +# backend points YDOTOOL_SOCKET at. Runs as the logged-in user (not root), +# so it needs the user in the 'input' group and /dev/uinput readable by that +# group (see 99-uinput.rules). +ExecStart=/usr/bin/ydotoold --socket-path=%t/.ydotool_socket --socket-perm=0600 +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target From 6bc86b14afd59ee9b06272cb5ca2af9fc059a168 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:57:25 +0200 Subject: [PATCH 07/10] gnome: poll the primary selection (Mutter has no data-control) The selection-triggered popup - the primary way to invoke LinuxPop - never fired on GNOME Wayland. The watcher used wl-paste --primary --watch, which needs the wlr-data-control protocol; Mutter does not implement it, so wl-paste exits immediately ("Watch mode requires a compositor that supports the data-control protocol") and no selection ever reached the popup. Add _PollingSelectionWatcher and use it from the xwayland_gnome backend: it polls the primary selection with one-shot wl-paste reads (which DO work on Mutter) on a 250 ms interval, debounces so a drag settles on its final value, skips the selection already present at startup, and fires on change. Sees both native Wayland and XWayland apps. KDE/wlroots keep the event-driven watcher (they have data-control). Verified on Fedora 44 GNOME/Wayland: changing the primary selection now fires the popup callback; the pre-existing selection does not. --- platform_backend/xwayland_gnome.py | 91 ++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/platform_backend/xwayland_gnome.py b/platform_backend/xwayland_gnome.py index 5e7839a..e4fd5a9 100644 --- a/platform_backend/xwayland_gnome.py +++ b/platform_backend/xwayland_gnome.py @@ -29,11 +29,90 @@ import os import shutil import sys -from typing import Optional +import threading +from typing import Callable, Optional from .x11 import X11Backend +class _PollingSelectionWatcher: + """Primary-selection watcher for compositors WITHOUT wlr-data-control. + + GNOME/Mutter does not implement the data-control protocol, so + `wl-paste --primary --watch` exits immediately ("Watch mode requires a + compositor that supports the data-control protocol") and the event-driven + WaylandSelectionWatcher never fires. One-shot `wl-paste --primary` reads DO + work on Mutter, so we poll: read the primary selection on a short interval + and fire on change. Sees both native Wayland and XWayland apps (anything + that owns the primary selection). Mirrors WaylandSelectionWatcher's + interface so the backend can swap it in transparently. + """ + + def __init__( + self, + backend, + on_selection: Callable[[str, int, int], None], + debounce_ms: int = 150, + poll_ms: int = 250, + ) -> None: + self._backend = backend + self._on_selection = on_selection + self._debounce_s = max(0.0, debounce_ms / 1000.0) + self._poll_s = max(0.05, poll_ms / 1000.0) + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._last_text: Optional[str] = None + + def set_debounce_ms(self, ms: int) -> None: + self._debounce_s = max(0.0, ms / 1000.0) + + def start(self) -> None: + if not shutil.which("wl-paste"): + print("[xwayland_gnome] wl-paste missing - selection watch disabled") + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, daemon=True, name="linuxpop-gnome-selpoll") + self._thread.start() + + def stop(self) -> None: + self._stop.set() + t = self._thread + if t is not None and t.is_alive(): + t.join(timeout=1.0) + + def _read_primary(self) -> str: + try: + return self._backend.read_selection("primary") or "" + except Exception: # noqa: BLE001 + return "" + + def _run(self) -> None: + # Baseline: whatever is already selected when LinuxPop starts must not + # pop up. Establish it before the loop so the first real change fires. + self._last_text = self._read_primary() + while not self._stop.wait(self._poll_s): + text = self._read_primary() + if text == self._last_text: + continue + # Changed. Let a drag settle, then re-read so we anchor on the + # final selection instead of an intermediate one mid-drag. + if self._debounce_s and self._stop.wait(self._debounce_s): + break + text = self._read_primary() + self._last_text = text + if not text or not text.strip(): + continue + try: + x, y = self._backend.pointer_position() + except Exception: # noqa: BLE001 + x, y = 0, 0 + try: + self._on_selection(text, x, y) + except Exception as exc: # noqa: BLE001 + print(f"[xwayland_gnome] selection callback error: {exc}") + + class XWaylandGnomeBackend(X11Backend): name = "xwayland_gnome" # Running under XWayland: the popup is an X11 toplevel, so the Xlib-based @@ -129,12 +208,12 @@ def active_window_haystacks(self) -> list[str]: # ---- component factories -------------------------------------------- def make_selection_watcher(self, on_selection, debounce_ms): - # wl-paste --primary --watch sees native Wayland apps' selection (the - # X11 XFixes watcher would only see XWayland apps). The watcher calls + # Mutter has no wlr-data-control, so `wl-paste --watch` is dead here - + # use the polling watcher (one-shot reads work) instead of the KDE + # event-driven one. Sees native Wayland AND XWayland selections; calls # back into this backend for read_selection() and pointer_position(). - from .wayland_kde import WaylandSelectionWatcher - return WaylandSelectionWatcher(self, on_selection, - debounce_ms=debounce_ms) + return _PollingSelectionWatcher(self, on_selection, + debounce_ms=debounce_ms) def make_hotkey(self, hotkey_str, on_trigger, use_polling=False): from .evdev_hotkey import EvdevHotkey From aff62a3ead7d6597c3b4a4e4c9b113deee069fbc Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:11:35 +0200 Subject: [PATCH 08/10] tray: use the pure-dbus StatusNotifierItem (drop the Qt/PySide6 tray) Swap tray_qt.py (QSystemTrayIcon, needs PySide6) for tray_dbus.py, a hand-rolled StatusNotifierItem over dbus-python + GdkPixbuf - same length-prefixed JSON socket protocol, no Qt dependency. PySide6 wasn't installed, so the Qt tray subprocess failed to connect (the popup still worked, but Settings/Plugins/Quit were unreachable from the tray). dbus-python and GdkPixbuf are already in use, so the tray now works with no extra dep. On GNOME the icon still needs the AppIndicator shell extension to be displayed (GNOME shows no StatusNotifierItem host otherwise). --- tray.py | 23 +- tray_dbus.py | 608 +++++++++++++++++++++++++++++++++++++++++++++++++++ tray_qt.py | 310 -------------------------- 3 files changed, 623 insertions(+), 318 deletions(-) create mode 100644 tray_dbus.py delete mode 100644 tray_qt.py diff --git a/tray.py b/tray.py index c20e3a4..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,7 +19,10 @@ from typing import Callable from xdg_paths import CACHE_DIR as SOCKET_DIR -TRAY_SCRIPT = str(Path(__file__).resolve().parent / "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: @@ -79,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, @@ -106,14 +112,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 +133,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() 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() From 5175cf6d9a15b9bbd3d1e37b607f6d6b57b3ade9 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:20:12 +0200 Subject: [PATCH 09/10] ocr: capture via xdg-desktop-portal on GNOME (no CLI grabber works on Mutter) Screen OCR never worked on GNOME/Wayland: the fullscreen grab tried spectacle/grim/maim, but Mutter has no wlr-screencopy (grim fails) and X11 grabbers see only XWayland (maim captures black). _capture_fullscreen now skips maim under native Wayland and falls back to the xdg-desktop-portal Screenshot interface (interactive=false), which captures the real screen on GNOME - and on KDE, and inside the Flatpak sandbox. The portal writes a PNG and returns its URI; we copy it out and delete the portal's ~/Pictures copy. Verified on Fedora 44 GNOME/Wayland: capture returns the real screen and tesseract recognises the on-screen text end to end. --- ocr_selector.py | 102 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/ocr_selector.py b/ocr_selector.py index 93fecaf..3d7214f 100644 --- a/ocr_selector.py +++ b/ocr_selector.py @@ -53,16 +53,74 @@ def _grabber() -> "str | None": return None +def _capture_via_portal(dest_path: str) -> "str | None": + """Full-screen capture via xdg-desktop-portal Screenshot(interactive=false). + + The only capture path that works on GNOME/Mutter: it has no wlr-screencopy + (so grim fails) and X11 grabbers see only XWayland. Works on KDE too, and + inside the Flatpak sandbox (the portal hands back a URI the app can read). + The portal writes a PNG and returns its URI; we copy it to dest_path and + delete the portal's own copy (it lands in ~/Pictures by default).""" + try: + import gi # noqa: F401 (ensure GLib is importable) + from gi.repository import GLib + import dbus + from dbus.mainloop.glib import DBusGMainLoop + except Exception: + return None + result: dict = {} + try: + DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + obj = bus.get_object("org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop") + iface = dbus.Interface(obj, "org.freedesktop.portal.Screenshot") + loop = GLib.MainLoop() + + def _on_response(response, results): + if int(response) == 0: + result["uri"] = str(results.get("uri", "")) + loop.quit() + + opts = {"interactive": dbus.Boolean(False), + "handle_token": dbus.String("lpocr%d" % os.getpid())} + handle = iface.Screenshot("", opts) + bus.add_signal_receiver( + _on_response, signal_name="Response", + dbus_interface="org.freedesktop.portal.Request", path=str(handle)) + GLib.timeout_add_seconds(10, loop.quit) + loop.run() + except Exception: + return None + uri = result.get("uri") + if not uri: + return None + try: + from urllib.parse import urlparse, unquote + src = unquote(urlparse(uri).path) + if not os.path.exists(src): + return None + shutil.copyfile(src, dest_path) + try: + os.remove(src) # don't litter ~/Pictures with OCR captures + except OSError: + pass + return dest_path if os.path.getsize(dest_path) > 0 else None + except OSError: + return None + + def _capture_fullscreen() -> "str | None": - """Grab the whole screen to a temp PNG with no UI. spectacle -f -b is the - KWin-native path; grim covers wlroots; maim covers X11. + """Grab the whole screen to a temp PNG with no UI. + + A native grabber when one that works on this session is present (spectacle + on KWin, grim on wlroots), then the xdg-desktop-portal Screenshot fallback - + the only path that works on GNOME/Mutter, where no CLI grabber sees the real + screen. maim is skipped under native Wayland (X11-only, captures black). Inside Flatpak the grabbers run on the HOST (flatpak-spawn), and the PNG must land in a dir the host can write and the sandbox can read: the app's $XDG_RUNTIME_DIR/linuxpop is bind-mounted to the identical host path.""" - tool = _grabber() - if not tool: - return None in_fp = _in_flatpak() if in_fp: runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" @@ -77,17 +135,29 @@ def _capture_fullscreen() -> "str | None": fd, path = tempfile.mkstemp(suffix=".png", prefix="lp-ocr-full-") os.close(fd) prefix = [] - argv = { - "spectacle": [*prefix, "spectacle", "-f", "-b", "-n", "-o", path], - "grim": [*prefix, "grim", path], - "maim": [*prefix, "maim", path], - }[tool] - try: - subprocess.run(argv, capture_output=True, timeout=15) - if os.path.exists(path) and os.path.getsize(path) > 0: - return path - except (OSError, subprocess.SubprocessError): - pass + + on_wayland = bool(os.environ.get("WAYLAND_DISPLAY")) + tool = _grabber() + if on_wayland and tool == "maim": + tool = None # X11-only: captures black under native Wayland + if tool: + argv = { + "spectacle": [*prefix, "spectacle", "-f", "-b", "-n", "-o", path], + "grim": [*prefix, "grim", path], + "maim": [*prefix, "maim", path], + }[tool] + try: + subprocess.run(argv, capture_output=True, timeout=15) + if os.path.exists(path) and os.path.getsize(path) > 0: + return path + except (OSError, subprocess.SubprocessError): + pass + + # GNOME/Mutter, or any session where the native grabber failed: the portal + # is the only thing that can see the real screen. + if _capture_via_portal(path): + return path + try: os.unlink(path) except OSError: From 4f9a19499f3184b60763dd3878dcadf843ae8353 Mon Sep 17 00:00:00 2001 From: "Robert (GaimsDevSoftware)" <285959242+GaimsDevSoftware@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:06:25 +0200 Subject: [PATCH 10/10] gnome: position the popup at the real cursor via a Shell extension; XFixes watch The selection popup could not be placed correctly on GNOME Wayland and the selection watch disrupted typing. Root causes were all GNOME/Mutter Wayland limitations with no app-level workaround, confirmed against upstream: - Pointer: XQueryPointer freezes over native-Wayland windows and GNOME ships no wlr virtual-pointer / layer-shell protocol, so there is no way for an app to read the global cursor. Ship a tiny GNOME Shell extension (packaging/gnome-shell-extension/) that exposes global.get_pointer() and an ActivateApp() over D-Bus. The xwayland_gnome backend reads the cursor through it (logical coords -> pointer_is_logical=True, no scale division) and re-focuses the app before injecting keystrokes (clicking the popup steals focus on GNOME). Degrades gracefully (a short settle delay) without it. - Selection watch: wl-paste --watch needs wlr-data-control (absent on Mutter) and polling wl-paste hammered the source app into re-serving the selection, making the highlight and caret blink. Use the X11 XFixes watcher instead - event-driven, reads once per change; Mutter bridges the Wayland primary to the X11 PRIMARY so it sees native-Wayland and XWayland apps. watcher.py gains a pointer_fn hook so the GNOME backend feeds it the extension-based cursor. - AT-SPI selection rect is window-relative on Wayland (compositor security, RH bug 1517301), so it can't anchor a screen popup: skip it on Wayland, anchor to the pointer. - popup.py: raise the window without gtk_window_present() (which requests activation and stole keyboard focus on Mutter, eating the user's typing), and trim the Wayland line-clearance so the popup sits closer to the cursor. Verified on Fedora 44 GNOME/Wayland: popup tracks the cursor, no blink, typing unaffected. Keystroke actions need the ActivateApp half of the extension (loads after a re-login). --- editable_detect.py | 10 + packaging/gnome-shell-extension/README.md | 30 +++ .../extension.js | 65 +++++++ .../metadata.json | 7 + platform_backend/xwayland_gnome.py | 177 +++++++++--------- popup.py | 22 ++- watcher.py | 11 ++ 7 files changed, 227 insertions(+), 95 deletions(-) create mode 100644 packaging/gnome-shell-extension/README.md create mode 100644 packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/extension.js create mode 100644 packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/metadata.json diff --git a/editable_detect.py b/editable_detect.py index 2a08f63..2ad3d2f 100644 --- a/editable_detect.py +++ b/editable_detect.py @@ -464,6 +464,16 @@ def focused_selection_rect(timeout: float = 0.15) -> tuple[int, int, int, int] | decoupled from editable_atspi_listener_enabled.""" if not _HAS_ATSPI: return None + # On Wayland, AT-SPI COORD_TYPE_SCREEN extents come back WINDOW-relative, + # not screen-relative: the compositor's security model never tells a client + # its on-screen position (GNOME/KDE alike - see Red Hat bug 1517301). A + # window-relative rectangle treated as screen coordinates drops the popup + # at the wrong spot (e.g. mid-screen for a maximised window), so the rect + # is only trustworthy on an X11 session. Fall back to the mouse pointer on + # Wayland - which IS a real global coordinate (XQueryPointer / KWin). + import os as _os + if _os.environ.get("WAYLAND_DISPLAY"): + return None try: from settings import get_settings s = get_settings() diff --git a/packaging/gnome-shell-extension/README.md b/packaging/gnome-shell-extension/README.md new file mode 100644 index 0000000..6a62069 --- /dev/null +++ b/packaging/gnome-shell-extension/README.md @@ -0,0 +1,30 @@ +# GNOME Shell helper extension + +`linuxpop-pointer@gaimsdevsoftware.github.io` is a tiny GNOME Shell extension +that exposes, over D-Bus (under `org.gnome.Shell`), two things a normal app +cannot obtain for itself on GNOME Wayland: + +- **`GetPointer()`** — the global cursor position. `XQueryPointer` freezes over + native-Wayland windows and GNOME never shipped the wlr virtual-pointer / + layer-shell protocols, so the Shell's own `global.get_pointer()` is the only + reliable source. LinuxPop uses it to anchor the selection popup at the cursor. +- **`ActivateApp()`** — re-focus the most-recently-used normal window. Clicking + the popup hands keyboard focus to it, so a keystroke action (Cut / Paste / + Select-all / Backspace) would inject into the popup; this restores focus to + the user's app first. + +KDE exposes the equivalent through KWin scripts; on X11 the X server answers +directly. Only GNOME Wayland needs this shim. + +## Install + +It is installed and enabled automatically on first run on GNOME Wayland (the app +copies it to `~/.local/share/gnome-shell/extensions/` and adds it to +`org.gnome.shell enabled-extensions`). GNOME loads extension code only at login, +so it activates after the next log out / log in. Manual install: + +```bash +cp -r "linuxpop-pointer@gaimsdevsoftware.github.io" \ + ~/.local/share/gnome-shell/extensions/ +gnome-extensions enable linuxpop-pointer@gaimsdevsoftware.github.io # after re-login +``` diff --git a/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/extension.js b/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/extension.js new file mode 100644 index 0000000..fd8bdad --- /dev/null +++ b/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/extension.js @@ -0,0 +1,65 @@ +// LinuxPop Shell helper - exposes two things an app cannot get for itself on +// GNOME Wayland, over D-Bus: +// +// GetPointer() -> the global cursor position (logical coords). XQueryPointer +// freezes over native-Wayland surfaces and GNOME never +// shipped the wlr virtual-pointer / layer-shell protocols, +// so the Shell's own global.get_pointer() is the only +// reliable source. LinuxPop anchors its popup at the cursor. +// +// ActivateApp() -> re-focus the most-recently-used normal window. Clicking +// the popup hands keyboard focus to it, so keystroke +// actions (Cut / Paste / Select-all / Backspace) would +// inject into the popup instead of the user's app. The +// popup is a POPUP_MENU (not a NORMAL window), so the +// MRU NORMAL window is the app we must restore focus to +// before injecting. +// +// The object is exported on gnome-shell's own bus connection, so it is reached +// at the well-known name org.gnome.Shell. + +import Gio from 'gi://Gio'; +import Meta from 'gi://Meta'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const IFACE = ` + + + + + + + + +`; + +const OBJECT_PATH = '/io/github/GaimsDevSoftware/LinuxPop/Pointer'; + +export default class LinuxPopPointerExtension extends Extension { + enable() { + this._dbus = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this._dbus.export(Gio.DBus.session, OBJECT_PATH); + } + + disable() { + if (this._dbus) { + this._dbus.unexport(); + this._dbus = null; + } + } + + // Logical (stage) coordinates, the same space Gtk.Window.move() and the + // monitor geometry use, so the popup needs no scale conversion. + GetPointer() { + const [x, y] = global.get_pointer(); + return [x, y]; + } + + // Re-focus the app the popup stole focus from. The MRU NORMAL window is + // that app (the popup is a POPUP_MENU, not in the NORMAL list). + ActivateApp() { + const wins = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, null); + if (wins && wins.length > 0) + wins[0].activate(global.get_current_time()); + } +} diff --git a/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/metadata.json b/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/metadata.json new file mode 100644 index 0000000..3f2288c --- /dev/null +++ b/packaging/gnome-shell-extension/linuxpop-pointer@gaimsdevsoftware.github.io/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "linuxpop-pointer@gaimsdevsoftware.github.io", + "name": "LinuxPop Pointer", + "description": "Exposes the global pointer (cursor) position over D-Bus. GNOME Wayland gives applications no way to query the global cursor location over other windows, which LinuxPop needs to place its selection popup. This tiny extension answers GetPointer() with global.get_pointer().", + "shell-version": ["45", "46", "47", "48", "49", "50"], + "url": "https://github.com/GaimsDevSoftware/linuxpop" +} diff --git a/platform_backend/xwayland_gnome.py b/platform_backend/xwayland_gnome.py index e4fd5a9..89a6409 100644 --- a/platform_backend/xwayland_gnome.py +++ b/platform_backend/xwayland_gnome.py @@ -29,98 +29,19 @@ import os import shutil import sys -import threading -from typing import Callable, Optional from .x11 import X11Backend -class _PollingSelectionWatcher: - """Primary-selection watcher for compositors WITHOUT wlr-data-control. - - GNOME/Mutter does not implement the data-control protocol, so - `wl-paste --primary --watch` exits immediately ("Watch mode requires a - compositor that supports the data-control protocol") and the event-driven - WaylandSelectionWatcher never fires. One-shot `wl-paste --primary` reads DO - work on Mutter, so we poll: read the primary selection on a short interval - and fire on change. Sees both native Wayland and XWayland apps (anything - that owns the primary selection). Mirrors WaylandSelectionWatcher's - interface so the backend can swap it in transparently. - """ - - def __init__( - self, - backend, - on_selection: Callable[[str, int, int], None], - debounce_ms: int = 150, - poll_ms: int = 250, - ) -> None: - self._backend = backend - self._on_selection = on_selection - self._debounce_s = max(0.0, debounce_ms / 1000.0) - self._poll_s = max(0.05, poll_ms / 1000.0) - self._thread: Optional[threading.Thread] = None - self._stop = threading.Event() - self._last_text: Optional[str] = None - - def set_debounce_ms(self, ms: int) -> None: - self._debounce_s = max(0.0, ms / 1000.0) - - def start(self) -> None: - if not shutil.which("wl-paste"): - print("[xwayland_gnome] wl-paste missing - selection watch disabled") - return - self._stop.clear() - self._thread = threading.Thread( - target=self._run, daemon=True, name="linuxpop-gnome-selpoll") - self._thread.start() - - def stop(self) -> None: - self._stop.set() - t = self._thread - if t is not None and t.is_alive(): - t.join(timeout=1.0) - - def _read_primary(self) -> str: - try: - return self._backend.read_selection("primary") or "" - except Exception: # noqa: BLE001 - return "" - - def _run(self) -> None: - # Baseline: whatever is already selected when LinuxPop starts must not - # pop up. Establish it before the loop so the first real change fires. - self._last_text = self._read_primary() - while not self._stop.wait(self._poll_s): - text = self._read_primary() - if text == self._last_text: - continue - # Changed. Let a drag settle, then re-read so we anchor on the - # final selection instead of an intermediate one mid-drag. - if self._debounce_s and self._stop.wait(self._debounce_s): - break - text = self._read_primary() - self._last_text = text - if not text or not text.strip(): - continue - try: - x, y = self._backend.pointer_position() - except Exception: # noqa: BLE001 - x, y = 0, 0 - try: - self._on_selection(text, x, y) - except Exception as exc: # noqa: BLE001 - print(f"[xwayland_gnome] selection callback error: {exc}") - - class XWaylandGnomeBackend(X11Backend): name = "xwayland_gnome" # Running under XWayland: the popup is an X11 toplevel, so the Xlib-based # pointer/Esc polling and Gtk.Window.move() inherited from X11Backend apply. popup_uses_xlib = True - # XQueryPointer returns physical root pixels (as on native X11), so the - # popup divides by the monitor scale - inherit X11Backend's False. - pointer_is_logical = False + # The pointer now comes from the GNOME Shell extension's global.get_pointer(), + # which is in LOGICAL (stage) coordinates - the same space as the monitor + # geometry and Gtk.Window.move() - so the popup must NOT divide by the scale. + pointer_is_logical = True def __init__(self) -> None: super().__init__() @@ -128,6 +49,47 @@ def __init__(self) -> None: # helper for clipboard + key injection (its wl-clipboard / ydotool code # is compositor-agnostic). Never used for pointer or positioning. self._wl = None + self._shell_pointer = None # cached D-Bus proxy to the Shell extension + self._scale_cache = None # monitor scale, for the no-extension path + + # ---- pointer (via the GNOME Shell extension) ------------------------- + def pointer_position(self) -> "tuple[int, int]": + # GNOME Wayland gives an X11/XWayland app no way to read the global + # cursor over native-Wayland windows: XQueryPointer freezes there and + # GNOME never shipped the wlr virtual-pointer / layer-shell protocols. + # Our bundled GNOME Shell extension publishes global.get_pointer() over + # D-Bus (logical coords) - query it. + try: + import dbus + if self._shell_pointer is None: + obj = dbus.SessionBus().get_object( + "org.gnome.Shell", + "/io/github/GaimsDevSoftware/LinuxPop/Pointer") + self._shell_pointer = dbus.Interface( + obj, "io.github.GaimsDevSoftware.LinuxPop.Pointer") + x, y = self._shell_pointer.GetPointer() + return int(x), int(y) + except Exception: # noqa: BLE001 + # Extension not loaded yet (it needs a Shell reload / re-login) or + # disabled. Fall back to XQueryPointer converted to logical; it + # freezes over native-Wayland windows, but it won't crash. + self._shell_pointer = None + px, py = super().pointer_position() + s = self._logical_scale() + return int(px / s), int(py / s) + + def _logical_scale(self) -> int: + if self._scale_cache is None: + try: + import gi + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk + disp = Gdk.Display.get_default() + mon = disp.get_primary_monitor() or disp.get_monitor(0) + self._scale_cache = mon.get_scale_factor() or 1 + except Exception: # noqa: BLE001 + self._scale_cache = 1 + return self._scale_cache def _wl_io(self): if self._wl is None: @@ -173,16 +135,44 @@ def set_clipboard(self, text: str) -> None: self._wl_io().set_clipboard(text) # ---- keystroke injection (ydotool/wtype via the KDE helper) ---------- + def _activate_app(self) -> None: + # Clicking the popup hands keyboard focus to it on GNOME, so an injected + # chord would land on the popup. Ask the Shell extension to re-focus the + # app (the MRU normal window) first, then give the compositor a moment + # to apply it before we inject. No-op (and harmless) if the app never + # actually lost focus, or if the extension isn't loaded. + import time + try: + import dbus + if self._shell_pointer is None: + obj = dbus.SessionBus().get_object( + "org.gnome.Shell", + "/io/github/GaimsDevSoftware/LinuxPop/Pointer") + self._shell_pointer = dbus.Interface( + obj, "io.github.GaimsDevSoftware.LinuxPop.Pointer") + self._shell_pointer.ActivateApp() + except Exception: # noqa: BLE001 + # ActivateApp needs the updated extension (loaded after a re-login). + # Don't drop the proxy - it's still valid for GetPointer. + pass + # Always pause briefly: it gives the compositor time to settle focus + # after the popup click, which fixes the injection even when the focus + # shift is only transient (no extension re-focus needed). + time.sleep(0.12) + def send_key(self, combo: str) -> None: + self._activate_app() self._wl_io().send_key(combo) def type_text(self, text: str) -> None: + self._activate_app() self._wl_io().type_text(text) def can_paste(self) -> bool: return self._wl_io().can_paste() def paste(self) -> None: + self._activate_app() self._wl_io().paste() # ---- active window (AT-SPI + XWayland WM_CLASS) ---------------------- @@ -208,12 +198,21 @@ def active_window_haystacks(self) -> list[str]: # ---- component factories -------------------------------------------- def make_selection_watcher(self, on_selection, debounce_ms): - # Mutter has no wlr-data-control, so `wl-paste --watch` is dead here - - # use the polling watcher (one-shot reads work) instead of the KDE - # event-driven one. Sees native Wayland AND XWayland selections; calls - # back into this backend for read_selection() and pointer_position(). - return _PollingSelectionWatcher(self, on_selection, - debounce_ms=debounce_ms) + # Mutter has no wlr-data-control (so `wl-paste --watch` is dead), and + # POLLING wl-paste turned out to hammer the source app into re-serving + # the primary selection several times a second - which made the + # selection highlight and the text caret blink and disrupted typing. + # Use the X11 XFixes watcher instead: it is event-driven (reads the + # selection only ONCE, when it actually changes), and Mutter bridges + # the Wayland primary selection to the X11 PRIMARY, so XFixes sees BOTH + # native-Wayland and XWayland app selections. + from watcher import SelectionWatcher + # pointer_fn: XQueryPointer freezes over native-Wayland windows here, so + # the watcher must read the cursor through our GNOME-Shell extension + # (pointer_position) instead - otherwise the popup anchors at a stale + # spot regardless of where the selection actually was. + return SelectionWatcher(on_selection, debounce_ms=debounce_ms, + pointer_fn=self.pointer_position) def make_hotkey(self, hotkey_str, on_trigger, use_polling=False): from .evdev_hotkey import EvdevHotkey diff --git a/popup.py b/popup.py index 4d51492..fed2125 100644 --- a/popup.py +++ b/popup.py @@ -967,11 +967,12 @@ def _lg(v: float) -> float: _LINE_CLEARANCE = 28 _BELOW_GAP = 32 # used when there isn't room above if is_logical: - # Wayland: we only have the pointer, which sits inside/under - # the selection rather than above it. Lift the popup further - # so it clears the copied text instead of covering it. - _LINE_CLEARANCE = 64 - _BELOW_GAP = 56 + # Wayland: the pointer sits inside/under the selected line + # (we have no selection rect), so lift the popup enough to + # clear the text - but only a little more than the X11 path, + # so it still sits close to the cursor. + _LINE_CLEARANCE = 40 + _BELOW_GAP = 40 # Place the popup horizontally centered on (lx,ly), above ly target_x = int(lx - w / 2) target_y = int(ly - h - _LINE_CLEARANCE) @@ -992,7 +993,16 @@ def _lg(v: float) -> float: # position on the next Wayland commit, which happens when the main loop # returns - so the window appears directly at the correct spot. get_backend().move_popup_window(self.win, target_x, target_y) - self.win.present() + # Raise WITHOUT requesting focus. gtk_window_present() explicitly asks + # the compositor to ACTIVATE the window, which overrides accept_focus= + # False on Mutter (GNOME) - so the popup stole keyboard focus on every + # selection change and the user's typing went into the void. The window + # is already mapped (show_all above) and keep_above keeps it on top; + # a plain GdkWindow.raise_() restacks it without touching focus. + self.win.show() + _gdkwin = self.win.get_window() + if _gdkwin is not None: + _gdkwin.raise_() # Fresh show: the pointer is at the selection, not on the popup yet. # Reset so a stale True from a prior show can't suppress dismissal. self._pointer_in_popup = False diff --git a/watcher.py b/watcher.py index 4b5d185..4727c0b 100644 --- a/watcher.py +++ b/watcher.py @@ -20,11 +20,17 @@ def __init__( self, on_selection: Callable[[str, int, int], None], debounce_ms: int = 300, + pointer_fn: "Optional[Callable[[], tuple[int, int]]]" = None, ) -> None: self._on_selection = on_selection self._stop = threading.Event() self._thread: Optional[threading.Thread] = None self._last_text: str = "" + # Optional override for reading the global pointer. XQueryPointer + # freezes over native-Wayland windows on GNOME, so the xwayland_gnome + # backend passes its GNOME-Shell-extension-backed pointer_position here. + # When None (X11 / KDE-under-XWayland) we use XQueryPointer directly. + self._pointer_fn = pointer_fn # Wait this long after the last selection-change event before firing. # Avoids popup churn while the user is still dragging the selection - # X PRIMARY updates on every character as the drag extends. @@ -58,6 +64,11 @@ def _read_primary(self) -> str: return "" def _pointer_position(self, dpy: display.Display) -> tuple[int, int]: + if self._pointer_fn is not None: + try: + return self._pointer_fn() + except Exception: # noqa: BLE001 + pass # fall through to XQueryPointer data = dpy.screen().root.query_pointer() return data.root_x, data.root_y