diff --git a/AGENTS.md b/AGENTS.md index a62c9e30..6e51428c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Scope -This repository is a Linux/Wayland speech-to-text application. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks. +This repository is a Linux desktop speech-to-text application supporting Wayland and X11. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks. ## Repository map diff --git a/README.md b/README.md index ed3a2f86..4c2c0820 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991 --- -**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, works on anything with Wayland. +**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, and built for both Wayland and X11. --- @@ -47,7 +47,8 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991 ### Prerequisites - **Linux** with systemd (Arch, Debian, Ubuntu, Fedora, openSUSE, etc.) -- **Requires a Wayland session** (GNOME, KDE Plasma Wayland, Sway, Hyprland, Niri) +- **Wayland or X11 session** (GNOME, KDE Plasma, Sway, Hyprland, Niri, etc.). GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration. +- **Clipboard/window tools:** `wl-clipboard` and `wtype` on Wayland; `python-pyperclip`, `xclip`, `xdotool`, and `xprop` on X11 (installed by the dependency script) - **Waybar or Noctalia** (optional, for status bar) - **gtk4 + PyCairo** (optional, for visualizer) - **NVIDIA GPU** (optional, for CUDA acceleration) diff --git a/config/systemd/hyprwhspr.service b/config/systemd/hyprwhspr.service index b0ea4929..59ddff74 100644 --- a/config/systemd/hyprwhspr.service +++ b/config/systemd/hyprwhspr.service @@ -13,7 +13,7 @@ After=wireplumber.service [Service] Type=simple -ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do ls "$XDG_RUNTIME_DIR"/wayland-* >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "Wayland socket not found"; exit 1' +ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do if [ -n "$WAYLAND_DISPLAY" ]; then case "$WAYLAND_DISPLAY" in /*) wayland_socket="$WAYLAND_DISPLAY" ;; *) wayland_socket="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ;; esac; [ -S "$wayland_socket" ] && exit 0; fi; [ -n "$DISPLAY" ] && exit 0; if [ -n "$XDG_RUNTIME_DIR" ]; then for wayland_socket in "${XDG_RUNTIME_DIR}"/wayland-*; do [ -S "$wayland_socket" ] && exit 0; done; fi; sleep 0.25; done; echo "No usable Wayland socket or X11 DISPLAY found"; exit 1' ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboar[d]" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.soc[k]" 2>/dev/null ) || true' Environment=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 826bc229..cf51e549 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -693,7 +693,7 @@ The recording-status indicator — the **mic OSD** — gives visual feedback whi `mic_osd_enabled` turns the mic OSD on; *how* it's shown is chosen automatically at startup: - **Overlay mode** — compositors with layer-shell support (Hyprland, Sway, niri, KDE Plasma Wayland) get the animated always-on-top overlay. Requires GTK4, PyCairo, and `gtk4-layer-shell`. -- **Notification mode** — GNOME/Mutter lacks layer-shell, so status shows as desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. Only requires `notify-send` (libnotify). +- **Notification mode** — GNOME/Mutter and X11 sessions use desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. The layer-shell overlay is Wayland-only. Notifications require `notify-send` (libnotify). Set `mic_osd_enabled: false` to turn off both. The service log records which mode was selected: @@ -879,7 +879,7 @@ Automatically converts spoken words to symbols and punctuation: ## Paste and clipboard behavior -hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. The hotkey goes out via `wtype` (Wayland virtual-keyboard), falling back to `ydotool key`. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes). +hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. Wayland prefers `wl-clipboard` plus `wtype` (falling back to `ydotool key`). X11 uses `python-pyperclip` with `xclip`, and `xdotool`/`xprop` for focused-window and terminal detection. `xsel` is also accepted as a clipboard fallback when installed. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes). ### Paste mode @@ -954,10 +954,11 @@ hyprwhspr saves your clipboard before injection and restores it afterward — di ### GNOME/Mutter notes -GNOME/Mutter lacks layer-shell and blocks `wtype`, so hyprwhspr behaves differently there: +GNOME/Mutter lacks layer-shell, so visual feedback uses notifications. Injection depends on the session: - **Window detection** uses the AT-SPI accessibility bridge — `hyprwhspr setup` offers to enable it (`gsettings set org.gnome.desktop.interface toolkit-accessibility true`). Without it, GNOME can't tell terminals apart and paste falls back to Ctrl+V. An explicit `paste_mode` (with no `applications` rules) skips the probe entirely. -- **Direct typing:** ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste. +- **GNOME Wayland direct typing:** Mutter blocks `wtype`, so ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste. +- **GNOME X11 clipboard paste:** X11 uses `xclip` and a normal paste chord rather than the Wayland-only direct-typing workaround. GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration. - **Non-Latin layouts** (Thai, Russian, Arabic, …): no physical key produces a `v` keysym, so hyprwhspr briefly switches to a Latin input source for the paste chord and restores your layout after — just keep a Latin source in Settings → Keyboard → Input Sources. ### Post-transcription hook @@ -1255,8 +1256,8 @@ Check your session: # Verify graphical-session.target is active systemctl --user is-active graphical-session.target -# Verify Wayland env is available to systemd services -systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|NIRI_SOCKET' +# Verify the active display environment is available to systemd services +systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|NIRI_SOCKET' ``` If `WAYLAND_DISPLAY` is missing, add to `~/.config/hypr/hyprland.conf`: @@ -1272,6 +1273,16 @@ socket for its own process and children. The compositor environment export above is still the recommended fix because it makes the correct display available to all systemd user services. +For X11, `DISPLAY` must be present and `XAUTHORITY` should be imported when your +session uses it: + +```bash +systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP +systemctl --user show-environment | grep -E 'DISPLAY|XAUTHORITY|XDG_SESSION_TYPE' +``` + +Do not set `WAYLAND_DISPLAY` in an explicit `XDG_SESSION_TYPE=x11` session. + **Niri:** hyprwhspr uses `niri msg --json focused-window` to detect the focused app and choose the correct paste shortcut. That requires `NIRI_SOCKET` to be available in the systemd user environment used by `hyprwhspr.service`. diff --git a/lib/src/cli/maintenance.py b/lib/src/cli/maintenance.py index f70208c6..6d8e0d4e 100644 --- a/lib/src/cli/maintenance.py +++ b/lib/src/cli/maintenance.py @@ -3,6 +3,7 @@ management and installation validation """ +import os import shutil import subprocess from pathlib import Path @@ -40,6 +41,11 @@ except ImportError: from output_control import log_info, log_success, log_warning, log_error +try: + from ..session_environment import classify_display_environment +except ImportError: + from session_environment import classify_display_environment + from ._shared import (HYPRWHSPR_ROOT, SERVICE_NAME, _check_ydotool_version, _is_niri_session, _validate_hyprwhspr_root) @@ -772,20 +778,29 @@ def validate_command(): except Exception: pass - # Check Wayland compositor environment in systemd user environment + # Check graphical session environment in the systemd user environment. try: result = subprocess.run( ['systemctl', '--user', 'show-environment'], capture_output=True, text=True, timeout=5, check=False ) env_output = result.stdout if result.returncode == 0 else '' - if 'WAYLAND_DISPLAY=' in env_output: - log_success("✓ WAYLAND_DISPLAY set in systemd user environment") + display_kind = classify_display_environment( + env_output, os.environ.get('XDG_SESSION_TYPE', '') + ) + if display_kind == 'wayland': + log_success("✓ Wayland display set in systemd user environment") + elif display_kind == 'x11': + log_success("✓ X11 DISPLAY set in systemd user environment") else: - log_warning("⚠ WAYLAND_DISPLAY not found in systemd user environment") - print(" Add the relevant compositor environment export to your startup config.") - print(" Hyprland example:") - print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + log_warning("⚠ No Wayland or X11 display found in systemd user environment") + if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'x11': + print(" Import the X11 session environment:") + print(" systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE") + else: + print(" Add the relevant compositor environment export to your startup config.") + print(" Hyprland example:") + print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") if _is_niri_session(): if 'NIRI_SOCKET=' in env_output: diff --git a/lib/src/cli/systemd.py b/lib/src/cli/systemd.py index 4f61575c..2caccd28 100644 --- a/lib/src/cli/systemd.py +++ b/lib/src/cli/systemd.py @@ -122,10 +122,11 @@ def setup_systemd(mode: str = 'install'): # Import the compositor environment visible to this setup process into the # systemd user manager. Niri's focused-window IPC needs NIRI_SOCKET; Hyprland # detection needs HYPRLAND_INSTANCE_SIGNATURE; wtype/wl-clipboard need the - # Wayland display environment. + # graphical-session environment used by Wayland and X11 integrations. run_command([ 'systemctl', '--user', 'import-environment', - 'WAYLAND_DISPLAY', 'XDG_CURRENT_DESKTOP', + 'WAYLAND_DISPLAY', 'DISPLAY', 'XAUTHORITY', 'XDG_SESSION_TYPE', + 'XDG_CURRENT_DESKTOP', 'XDG_SESSION_DESKTOP', 'DESKTOP_SESSION', 'HYPRLAND_INSTANCE_SIGNATURE', 'NIRI_SOCKET', ], check=False) diff --git a/lib/src/session_environment.py b/lib/src/session_environment.py index 6bf3c58d..55a4f724 100644 --- a/lib/src/session_environment.py +++ b/lib/src/session_environment.py @@ -7,6 +7,25 @@ from pathlib import Path +def classify_display_environment(environment: str, expected_session_type: str = ""): + """Return the usable display kind exported by a systemd environment dump.""" + values = {} + for line in environment.splitlines(): + name, separator, value = line.partition("=") + if separator: + values[name] = value + session_type = (values.get("XDG_SESSION_TYPE") or expected_session_type).lower() + if session_type == "wayland": + return "wayland" if values.get("WAYLAND_DISPLAY") else None + if session_type == "x11": + return "x11" if values.get("DISPLAY") else None + if values.get("WAYLAND_DISPLAY"): + return "wayland" + if values.get("DISPLAY"): + return "x11" + return None + + def ensure_wayland_display(): """ Populate WAYLAND_DISPLAY from XDG_RUNTIME_DIR when systemd has not imported it. @@ -17,6 +36,9 @@ def ensure_wayland_display(): if os.environ.get("WAYLAND_DISPLAY"): return + if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11": + return + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") if not runtime_dir: print("[WARN] WAYLAND_DISPLAY unset and XDG_RUNTIME_DIR missing", flush=True) diff --git a/lib/src/text_injector.py b/lib/src/text_injector.py index 37c5de2b..69a84d85 100644 --- a/lib/src/text_injector.py +++ b/lib/src/text_injector.py @@ -24,16 +24,31 @@ class _LazyPyperclip: _module = None + def _load(self): + if self._module is not None: + return self._module + try: + import pyperclip as module + except ImportError as exc: + raise RuntimeError( + "clipboard fallback unavailable; install pyperclip and xclip (or xsel)" + ) from exc + + # Avoid pyperclip's GTK fallback: mic_osd may already have selected GTK4, + # while pyperclip requests GTK3. Prefer subprocess-only X11 backends. + try: + if shutil.which("xclip"): + module.set_clipboard("xclip") + elif shutil.which("xsel"): + module.set_clipboard("xsel") + except Exception: + # Preserve pyperclip's own error for the eventual copy/paste call. + pass + self._module = module + return module + def __getattr__(self, name): - if self._module is None: - try: - import pyperclip as module - except ImportError as exc: - raise RuntimeError( - "clipboard fallback unavailable; install pyperclip (and xclip on X11)" - ) from exc - self._module = module - return getattr(self._module, name) + return getattr(self._load(), name) pyperclip = _LazyPyperclip() @@ -158,9 +173,11 @@ def __init__(self, config_manager=None): # Configuration self.config_manager = config_manager - # Detect available injectors + # Detect available injectors once for the active display protocol. + self.session_type = os.environ.get('XDG_SESSION_TYPE', '').lower() self.ydotool_available = self._check_ydotool() - self.wtype_available = shutil.which('wtype') is not None + self.wtype_available = not self._is_x11_session() and shutil.which('wtype') is not None + self.xdotool_available = self._is_x11_session() and shutil.which('xdotool') is not None # Private ydotoold instance (lazily started on first uinput-fallback use, so # wtype-only sessions never spawn it). Replaces the old shared/managed @@ -168,15 +185,20 @@ def __init__(self, config_manager=None): self._ydotoold = YdotooldSession() self._atspi_unavailable = False - if not self.ydotool_available and not self.wtype_available: - print("⚠️ No injection backend found (wtype or ydotool). hyprwhspr requires wtype or ydotool for paste injection.") - elif not self.wtype_available and self.ydotool_available: + if not self.ydotool_available and not self.wtype_available and not self.xdotool_available: + print("⚠️ No injection backend found. Install wtype or ydotool on Wayland, or xdotool on X11.") + elif not self._is_x11_session() and not self.wtype_available and self.ydotool_available: print("ℹ️ wtype not found. Falling back to ydotool for paste hotkey injection.") def _check_ydotool(self) -> bool: """Check if ydotool is usable (both the client and the ydotoold daemon).""" return YdotooldSession.is_available() + def _is_x11_session(self) -> bool: + """Use the display protocol captured when this injector was initialized.""" + session_type = getattr(self, 'session_type', os.environ.get('XDG_SESSION_TYPE', '').lower()) + return session_type == 'x11' + def _run_ydotool(self, args, timeout): """Run a ydotool client command against our private ydotoold daemon. @@ -575,13 +597,15 @@ def _chord_is_usable(self, chord: str) -> bool: if not parsed: return False _modifiers, key = parsed + if getattr(self, 'xdotool_available', False): + return True if self.wtype_available and self._wtype_key_name(key) is not None: return True if self.ydotool_available and self._keycode_for_chord_key(key) is not None: return True # No injection backend at all → can't paste regardless; a parseable chord # shouldn't trigger a spurious misconfiguration warning. - return not self.wtype_available and not self.ydotool_available + return not self.wtype_available and not self.ydotool_available and not getattr(self, 'xdotool_available', False) def _resolve_paste_chord(self, window_info: Optional[Dict[str, Any]] = None): """Resolve application rule / explicit paste_mode / legacy / auto into a chord. @@ -706,6 +730,27 @@ def _send_paste_keys_wtype(self, paste_chord: str) -> bool: print(f"wtype paste failed: {e}") return False + def _send_paste_keys_xdotool(self, paste_chord: str) -> bool: + """Send a symbolic paste chord through the native X11 input path.""" + paste_chord = PASTE_MODE_CHORDS.get(paste_chord, paste_chord) + parsed = self._parse_key_chord(paste_chord) + if not parsed: + return False + modifiers, key = parsed + chord = '+'.join([*modifiers, key]) + try: + result = subprocess.run( + ['xdotool', 'key', '--clearmodifiers', chord], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + return True + stderr = (result.stderr or b'').decode('utf-8', 'ignore') + print(f" xdotool paste failed: {stderr}") + except Exception as e: + print(f"xdotool paste failed: {e}") + return False + @staticmethod def _wtype_key_name(key: str) -> Optional[str]: """Translate a normalized chord key into the xkb keysym name wtype expects.""" @@ -863,7 +908,7 @@ def _gnome_restore_layout(self, prev_idx): def _save_clipboard(self) -> Optional[bytes]: """Save current clipboard contents. Returns raw bytes or None.""" - if shutil.which("wl-paste"): + if not self._is_x11_session() and shutil.which("wl-paste"): try: result = subprocess.run(["wl-paste", "--no-newline"], capture_output=True, timeout=2) if result.returncode == 0: @@ -879,16 +924,30 @@ def _save_clipboard(self) -> Optional[bytes]: pass return None + def _try_wl_copy(self, data: bytes) -> Tuple[bool, Optional[str]]: + """Try the Wayland clipboard writer and retain a useful failure detail.""" + if self._is_x11_session() or not shutil.which("wl-copy"): + return False, None + try: + result = subprocess.run(["wl-copy"], input=data, timeout=2) + if result.returncode == 0: + return True, None + return False, f"wl-copy exited with status {result.returncode}" + except Exception as exc: + return False, f"wl-copy failed: {exc}" + def _copy_text_to_clipboard(self, text: str) -> bool: """Copy text to the clipboard without triggering paste.""" + copied, wayland_error = self._try_wl_copy(text.encode("utf-8")) + if copied: + return True + # Fallback: pyperclip (X11, or wl-copy present but no compositor to reach) try: - if shutil.which("wl-copy"): - subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True, timeout=2) - else: - pyperclip.copy(text) + pyperclip.copy(text) return True except Exception as e: - print(f"ERROR: Clipboard copy failed: {e}") + detail = f" after {wayland_error}" if wayland_error else "" + print(f"ERROR: Clipboard copy failed{detail}: {e}") return False def _restore_clipboard(self, saved: Optional[bytes], injected: Optional[bytes] = None, delay: float = 5.0): @@ -909,16 +968,21 @@ def _restore(): if current != injected: return - if shutil.which("wl-copy"): - subprocess.run(["wl-copy"], input=saved, check=True, timeout=2) - else: - # pyperclip is text-only; only restore if the saved bytes are - # valid UTF-8 text. Binary clipboard data (images, etc.) cannot - # be round-tripped through pyperclip without corruption. + restored, wayland_error = self._try_wl_copy(saved) + if not restored: + # Fallback: pyperclip (X11, or wl-copy present but no compositor to + # reach). It's text-only; only restore if the saved bytes are valid + # UTF-8 text. Binary clipboard data (images, etc.) cannot be + # round-tripped through pyperclip without corruption. try: pyperclip.copy(saved.decode("utf-8")) except UnicodeDecodeError: - pass # Binary data — skip rather than corrupt + if wayland_error: + print(f"Warning: Could not restore binary clipboard: {wayland_error}") + # Binary data cannot safely pass through the text fallback. + except Exception as exc: + detail = f" after {wayland_error}" if wayland_error else "" + raise RuntimeError(f"clipboard fallback failed{detail}: {exc}") from exc except Exception as e: print(f"Warning: Could not restore clipboard: {e}") @@ -929,7 +993,15 @@ def _send_enter_if_auto_submit(self): if not (self.config_manager and self.config_manager.get_setting('auto_submit', False)): return try: - if self.ydotool_available: + if self._is_x11_session() and getattr(self, 'xdotool_available', False): + enter_result = subprocess.run( + ['xdotool', 'key', '--clearmodifiers', 'Return'], + capture_output=True, timeout=1, + ) + if enter_result.returncode != 0: + stderr = (enter_result.stderr or b'').decode('utf-8', 'ignore') + print(f" xdotool Enter key failed: {stderr}") + elif self.ydotool_available: enter_result = self._run_ydotool(['key', '28:1', '28:0'], timeout=1) # 28 = Enter if enter_result is None or enter_result.returncode != 0: stderr = (enter_result.stderr or b"").decode("utf-8", "ignore") if enter_result else "ydotoold unavailable" @@ -943,7 +1015,7 @@ def _send_enter_if_auto_submit(self): stderr = (enter_result.stderr or b"").decode("utf-8", "ignore") print(f" wtype Enter key failed: {stderr}") else: - print(" auto_submit enabled but no key-injection tool available (ydotool or wtype required)") + print(" auto_submit enabled but no key-injection tool available") except Exception as e: print(f" auto_submit Enter key failed: {e}") @@ -1189,13 +1261,16 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: return False time.sleep(0.15) - # Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back - # to ydotool's uinput chord. ydotool key chords DO reach Mutter (uinput + # Send paste hotkey through the session-native path first: xdotool on + # X11 or wtype on Wayland. Fall back to ydotool's uinput chord. ydotool + # key chords DO reach Mutter (uinput # is seen as a real device, unlike wtype's virtual-keyboard protocol # which Mutter blocks), so we use them on GNOME too — this is the path # taken when direct typing was skipped for a non-US layout / non-ASCII text. pasted = False - if self.wtype_available: + if self._is_x11_session() and getattr(self, 'xdotool_available', False): + pasted = self._send_paste_keys_xdotool(paste_chord) + elif self.wtype_available: pasted = self._send_paste_keys_wtype(paste_chord) if pasted: # wtype sends Wayland modifier events; clear ydotool's uinput modifier @@ -1215,7 +1290,12 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool: finally: self._gnome_restore_layout(_prev_layout) - if not pasted and not self.wtype_available and not self.ydotool_available: + if ( + not pasted + and not self.wtype_available + and not self.ydotool_available + and not getattr(self, 'xdotool_available', False) + ): print("No key-injection tool available; text is on the clipboard.") # Text is clipboard-only: don't restore old clipboard (would erase it) # and don't auto-submit (nothing was pasted into the field). diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh index 3a2dc374..fa2f5b0a 100755 --- a/scripts/install-deps.sh +++ b/scripts/install-deps.sh @@ -310,6 +310,9 @@ install_deps_apt() { pipewire-pulse \ pulseaudio-utils \ wl-clipboard \ + xclip \ + xdotool \ + x11-utils \ wget # Optional Python packages (not present on all Debian/Ubuntu releases) @@ -364,7 +367,10 @@ install_deps_dnf() { pipewire \ pipewire-pulseaudio \ ydotool \ - wl-clipboard + wl-clipboard \ + xclip \ + xdotool \ + xprop # Optional Python packages (not present on all Fedora releases — e.g. python3-sounddevice # was dropped in F43). Anything missing here is picked up by pip in install_pip_packages. @@ -412,6 +418,24 @@ install_deps_zypper() { ydotool \ wl-clipboard + # X11 package availability varies between Leap and Tumbleweed. Install the + # native equivalents when published, without adding unsupported repositories. + local x11_packages=() + local x11_pkg + for x11_pkg in xclip xdotool xprop; do + if zypper info -t package "$x11_pkg" &> /dev/null; then + x11_packages+=("$x11_pkg") + else + log_warning "X11 dependency $x11_pkg is unavailable in enabled repositories" + fi + done + if [[ ${#x11_packages[@]} -gt 0 ]]; then + sudo zypper install -y "${x11_packages[@]}" + fi + if [[ ${#x11_packages[@]} -lt 3 ]]; then + log_warning "X11 support is incomplete; enable a supported distribution repository or install xclip, xdotool, and xprop manually" + fi + # Optional dbus package naming differs across openSUSE releases if sudo zypper info -t package python3-dbus-python &> /dev/null; then sudo zypper install -y python3-dbus-python diff --git a/tests/test_session_environment.py b/tests/test_session_environment.py index e4b6512f..7856cab0 100644 --- a/tests/test_session_environment.py +++ b/tests/test_session_environment.py @@ -10,10 +10,26 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "lib" / "src")) -from session_environment import ensure_wayland_display +from session_environment import classify_display_environment, ensure_wayland_display class SessionEnvironmentTests(unittest.TestCase): + def test_classifies_wayland_x11_and_missing_systemd_environments(self): + self.assertEqual( + classify_display_environment("WAYLAND_DISPLAY=wayland-1\nDISPLAY=:0\n"), + "wayland", + ) + self.assertEqual(classify_display_environment("DISPLAY=:0\nXAUTHORITY=/tmp/auth\n"), "x11") + self.assertIsNone(classify_display_environment("DISPLAY=\nWAYLAND_DISPLAY=\n")) + self.assertIsNone( + classify_display_environment("XDG_SESSION_TYPE=x11\nWAYLAND_DISPLAY=wayland-0\n") + ) + self.assertIsNone( + classify_display_environment("XDG_SESSION_TYPE=wayland\nDISPLAY=:0\n") + ) + self.assertIsNone(classify_display_environment("DISPLAY=:0\n", "wayland")) + self.assertIsNone(classify_display_environment("WAYLAND_DISPLAY=wayland-0\n", "x11")) + def _socket_file_at(self, path, mtime=100): path.write_text("", encoding="utf-8") path.chmod(0o600) @@ -99,6 +115,20 @@ def test_does_nothing_when_runtime_dir_has_no_sockets(self): ensure_wayland_display() self.assertIsNone(os.environ.get("WAYLAND_DISPLAY")) + def test_explicit_x11_session_ignores_stale_wayland_socket(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._socket_file_at(Path(tmpdir) / "wayland-1") + with ( + mock.patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0", "XDG_RUNTIME_DIR": tmpdir}, + clear=True, + ), + self._socket_mode_patch(), + ): + ensure_wayland_display() + self.assertIsNone(os.environ.get("WAYLAND_DISPLAY")) + def test_does_not_raise_when_xdg_runtime_dir_unset_or_missing(self): with mock.patch.dict(os.environ, {}, clear=True): ensure_wayland_display() diff --git a/tests/test_text_injector_injection.py b/tests/test_text_injector_injection.py index 42291011..28dfa28c 100644 --- a/tests/test_text_injector_injection.py +++ b/tests/test_text_injector_injection.py @@ -11,7 +11,7 @@ sys.path.insert(0, str(ROOT / "lib" / "src")) sys.modules.setdefault("pyperclip", types.SimpleNamespace(copy=lambda text: None, paste=lambda: "")) -from text_injector import TextInjector +from text_injector import TextInjector, _LazyPyperclip class ConfigStub: @@ -26,8 +26,10 @@ class TextInjectorInjectionTests(unittest.TestCase): def _injector(self): injector = TextInjector.__new__(TextInjector) injector.config_manager = ConfigStub() + injector.session_type = "wayland" injector.ydotool_available = True injector.wtype_available = False + injector.xdotool_available = False # Private ydotoold daemon manager: not running by default (so # _clear_stuck_modifiers is a no-op), but ensure_running() succeeds when a # ydotool command is actually issued. @@ -37,6 +39,113 @@ def _injector(self): injector._ydotoold.socket_env.return_value = {"YDOTOOL_SOCKET": "/run/x.sock"} return injector + def test_wayland_clipboard_success_does_not_use_fallback(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=0) + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-copy"), + mock.patch("text_injector.subprocess.run", return_value=completed) as run, + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + self.assertTrue(injector._copy_text_to_clipboard("hello")) + + run.assert_called_once() + fallback.assert_not_called() + + def test_failed_wayland_clipboard_uses_x11_fallback(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=1) + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-copy"), + mock.patch("text_injector.subprocess.run", return_value=completed), + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + self.assertTrue(injector._copy_text_to_clipboard("hello")) + + fallback.assert_called_once_with("hello") + + def test_clipboard_save_falls_back_after_failed_wayland_read(self): + injector = self._injector() + completed = types.SimpleNamespace(returncode=1, stdout=b"") + with ( + mock.patch("text_injector.shutil.which", return_value="/usr/bin/wl-paste"), + mock.patch("text_injector.subprocess.run", return_value=completed), + mock.patch("text_injector.pyperclip.paste", return_value="saved") as fallback, + ): + self.assertEqual(injector._save_clipboard(), b"saved") + + fallback.assert_called_once_with() + + def test_restore_fallback_restores_utf8_but_skips_binary(self): + injector = self._injector() + + class ImmediateThread: + def __init__(self, target, daemon): + self.target = target + + def start(self): + self.target() + + with ( + mock.patch("text_injector.time.sleep"), + mock.patch("text_injector.threading.Thread", ImmediateThread), + mock.patch("text_injector.shutil.which", return_value=None), + mock.patch("text_injector.pyperclip.copy") as fallback, + ): + injector._restore_clipboard("café".encode(), delay=0) + injector._restore_clipboard(b"\xff\x00", delay=0) + + fallback.assert_called_once_with("café") + + def test_x11_skips_wayland_clipboard_tools_and_uses_xdotool_chord(self): + injector = self._injector() + injector.session_type = "x11" + injector.ydotool_available = False + injector.wtype_available = False + injector.xdotool_available = True + clipboard = types.SimpleNamespace(copy=mock.Mock(), paste=mock.Mock(return_value="old")) + completed = types.SimpleNamespace(returncode=0, stderr=b"") + + with ( + mock.patch("text_injector.pyperclip", clipboard), + mock.patch("text_injector.shutil.which") as which, + mock.patch("text_injector.subprocess.run", return_value=completed) as run, + ): + self.assertEqual(injector._save_clipboard(), b"old") + self.assertTrue(injector._copy_text_to_clipboard("hello")) + self.assertTrue(injector._send_paste_keys_xdotool("ctrl_shift")) + + which.assert_not_called() + clipboard.copy.assert_called_once_with("hello") + run.assert_called_once_with( + ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"], + capture_output=True, + timeout=5, + ) + + def test_x11_injection_prefers_xdotool_without_wtype_or_ydotool(self): + injector = self._injector() + injector.session_type = "x11" + injector.ydotool_available = False + injector.wtype_available = False + injector.xdotool_available = True + with ( + mock.patch.object(injector, "_get_active_window_info", return_value=None), + mock.patch.object(injector, "_save_clipboard", return_value=b"old"), + mock.patch.object(injector, "_copy_text_to_clipboard", return_value=True), + mock.patch.object(injector, "_send_paste_keys_xdotool", return_value=True) as xdotool, + mock.patch.object(injector, "_send_paste_keys_wtype") as wtype, + mock.patch.object(injector, "_send_paste_keys_slow") as ydotool, + mock.patch.object(injector, "_restore_clipboard"), + mock.patch.object(injector, "_send_enter_if_auto_submit"), + mock.patch("text_injector.time.sleep"), + ): + self.assertTrue(injector._inject_via_clipboard_and_hotkey("hello")) + + xdotool.assert_called_once_with("ctrl+v") + wtype.assert_not_called() + ydotool.assert_not_called() + def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self): injector = self._injector() @@ -679,5 +788,29 @@ def test_valid_application_chord_does_not_warn(self): self.assertEqual(buf.getvalue(), "") +class LazyPyperclipTests(unittest.TestCase): + def test_selects_xclip_then_xsel_on_first_use(self): + for available, expected in (("xclip", "xclip"), ("xsel", "xsel")): + module = types.SimpleNamespace( + set_clipboard=mock.Mock(), copy=mock.Mock(), paste=mock.Mock(return_value="") + ) + lazy = _LazyPyperclip() + with ( + mock.patch.dict(sys.modules, {"pyperclip": module}), + mock.patch( + "text_injector.shutil.which", + side_effect=lambda name, selected=available: f"/usr/bin/{name}" if name == selected else None, + ), + ): + lazy.copy("text") + + module.set_clipboard.assert_called_once_with(expected) + module.copy.assert_called_once_with("text") + + def test_construction_does_not_import_optional_dependency(self): + lazy = _LazyPyperclip() + self.assertIsNone(lazy._module) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_x11_service_contract.py b/tests/test_x11_service_contract.py new file mode 100644 index 00000000..cadf3b74 --- /dev/null +++ b/tests/test_x11_service_contract.py @@ -0,0 +1,30 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class X11ServiceContractTests(unittest.TestCase): + def test_readiness_uses_selected_wayland_socket_or_display(self): + service = (ROOT / "config/systemd/hyprwhspr.service").read_text(encoding="utf-8") + prestart = next(line for line in service.splitlines() if line.startswith("ExecStartPre=")) + + self.assertIn('${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}', prestart) + self.assertIn('[ -S ', prestart) + self.assertIn('[ -n "$DISPLAY" ]', prestart) + self.assertIn('${XDG_RUNTIME_DIR}"/wayland-*', prestart) + self.assertIn('/*)', prestart) + self.assertNotIn('/tmp/.X11-unix/X*', prestart) + + def test_setup_imports_wayland_and_x11_session_variables(self): + setup = (ROOT / "lib/src/cli/systemd.py").read_text(encoding="utf-8") + for variable in ( + "WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION", + ): + self.assertIn(f"'{variable}'", setup) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/src/layouts/BlogPost.astro b/website/src/layouts/BlogPost.astro index 19a55b92..afc1604c 100644 --- a/website/src/layouts/BlogPost.astro +++ b/website/src/layouts/BlogPost.astro @@ -54,7 +54,7 @@ const { title, description, tag, date, readTime } = Astro.props;
- Hyprland, GNOME, KDE Plasma, Sway — any Wayland - compositor with systemd. + Wayland and X11 across Hyprland, GNOME, KDE Plasma, + and Sway. GNOME/X11 on Ubuntu 24.04 is validated.