Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions docs/gnome-wayland.md
Original file line number Diff line number Diff line change
@@ -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=<value>`.
- `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).
159 changes: 144 additions & 15 deletions editable_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading