diff --git a/docs/gnome-wayland.md b/docs/gnome-wayland.md new file mode 100644 index 0000000..16bffe2 --- /dev/null +++ b/docs/gnome-wayland.md @@ -0,0 +1,112 @@ +# 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 + +# 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 +``` + +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). diff --git a/editable_detect.py b/editable_detect.py index 6a2cd2d..2ad3d2f 100644 --- a/editable_detect.py +++ b/editable_detect.py @@ -48,29 +48,52 @@ # 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 _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 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 +109,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 +458,31 @@ 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 + # 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 - if not bool(get_settings().get("editable_atspi_listener_enabled")): + 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 @@ -486,6 +539,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/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: 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/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 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..89a6409 --- /dev/null +++ b/platform_backend/xwayland_gnome.py @@ -0,0 +1,231 @@ +"""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 .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 + # 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__() + # 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 + 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: + 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 _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) ---------------------- + 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): + # 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 + 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). 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/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 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() 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