diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index 39187f9..28fc5da 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -106,8 +106,9 @@ The installer now installs these automatically, but if you hit a CMake error lik `Could not find OpenSSL` during a manual reinstall, add the above first. **ydotool** — `ydotool` is not packaged in Debian's standard repos. The installer falls back -gracefully to `wtype` for most Wayland compositors. If you specifically need `ydotool` -(e.g. for KDE Plasma Wayland), compile it from source: +gracefully to IBus or `wtype` for most Wayland compositors. KDE Plasma Wayland users should +first select **IBus Wayland** in **System Settings -> Keyboard -> Virtual Keyboard**. If you +specifically need `ydotool` as a fallback, compile it from source: ```bash sudo apt install -y git cmake libevdev-dev @@ -358,8 +359,9 @@ Vocalinux requires text injection tools to work with X11 or Wayland: - **xdotool**: Required for X11 sessions ### For Wayland -- **wtype**: Recommended for Wayland native sessions -- **ydotool**: Universal alternative that works with both X11 and Wayland +- **IBus**: Recommended for KDE Plasma Wayland and generally the most reliable direct text input path +- **wtype**: Recommended for wlroots-style Wayland compositors such as sway +- **ydotool**: Universal alternative that works with both X11 and Wayland, but requires `ydotoold` - **xdotool**: May work via XWayland fallback ### Installation by Distribution @@ -399,7 +401,7 @@ For unsupported or experimental distributions: | Desktop Environment | Status | Notes | |---------------------|--------|-------| | GNOME | ✅ Full Support | Primary target, uses AppIndicator | -| KDE Plasma | ✅ Full Support | Works with AppIndicator | +| KDE Plasma | ✅ Full Support | On Wayland, select IBus Wayland in System Settings -> Keyboard -> Virtual Keyboard | | Xfce | ✅ Full Support | Works with AppIndicator | | Cinnamon | ✅ Full Support | Works with AppIndicator | | MATE | ✅ Full Support | Works with AppIndicator | @@ -428,7 +430,7 @@ Practical caveat: | Display Server | Status | Notes | |----------------|--------|-------| | X11 | ✅ Full Support | Uses xdotool for text injection | -| Wayland | ✅ Full Support | Uses wtype (native) or xdotool (XWayland) | +| Wayland | ✅ Full Support | Uses IBus, wtype, ydotool, or xdotool fallback depending on compositor | | XWayland | ✅ Full Support | Automatically detected, falls back to xdotool if needed | ## Architecture Support diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 97461e9..1c2e7d4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -559,12 +559,33 @@ sudo apt install xdotool # Test: xdotool type "hello" ``` +For KDE Plasma Wayland: + +1. Install IBus if it is missing: + ```bash + # Ubuntu/Debian + sudo apt install ibus + + # Fedora + sudo dnf install ibus + + # Arch + sudo pacman -S ibus + ``` +2. Open **System Settings -> Keyboard -> Virtual Keyboard**. +3. Select **IBus Wayland**. +4. Restart Vocalinux, or log out and back in if text still does not appear. + For Wayland: ```bash sudo apt install wtype # Test: wtype "hello" ``` +On KDE Plasma Wayland, `wtype` may fail because the compositor does not expose +the required virtual keyboard protocol to regular clients. Use **IBus Wayland** +from KDE System Settings for the most reliable direct text injection. + ### Model Download Fails **Symptom:** Can't download speech models diff --git a/install.sh b/install.sh index 54bb5ee..1c11653 100755 --- a/install.sh +++ b/install.sh @@ -29,6 +29,21 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +is_kde_plasma_session() { + local desktop="${XDG_CURRENT_DESKTOP:-} ${DESKTOP_SESSION:-} ${GDMSESSION:-}" + local kde_session="${KDE_FULL_SESSION:-}" + local desktop_lower="${desktop,,}" + local kde_session_lower="${kde_session,,}" + + [[ "$desktop_lower" == *kde* || "$desktop_lower" == *plasma* || "$kde_session_lower" == "true" ]] +} + +print_kde_wayland_ibus_hint() { + print_warning "KDE Plasma Wayland detected." + print_info "For direct dictation into apps, open System Settings -> Keyboard -> Virtual Keyboard and select 'IBus Wayland'." + print_info "After changing it, restart Vocalinux or log out and back in." +} + get_vocalinux_pids() { pgrep -f "vocalinux" 2>/dev/null | while read -r pid; do [ -z "$pid" ] && continue @@ -1900,6 +1915,9 @@ install_text_input_tools() { fi print_info "Detected session type: $SESSION_TYPE" + if [[ "$SESSION_TYPE" == "wayland" ]] && is_kde_plasma_session; then + print_kde_wayland_ibus_hint + fi # Install appropriate tools based on session type and distribution case "$SESSION_TYPE" in diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index bd6712f..182a795 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -24,6 +24,28 @@ from .ibus_engine import ( logger = logging.getLogger(__name__) +def _is_kde_plasma_session() -> bool: + """Return True when the current desktop session appears to be KDE Plasma.""" + if os.environ.get("KDE_FULL_SESSION", "").lower() == "true": + return True + + desktop_values = [ + os.environ.get("XDG_CURRENT_DESKTOP", ""), + os.environ.get("DESKTOP_SESSION", ""), + os.environ.get("GDMSESSION", ""), + ] + desktop = " ".join(value for value in desktop_values if value).lower() + return "kde" in desktop or "plasma" in desktop + + +def _kde_wayland_ibus_hint() -> str: + """Return the KDE Plasma Wayland IBus setup hint.""" + return ( + "Open System Settings -> Keyboard -> Virtual Keyboard, select " + "'IBus Wayland', then restart Vocalinux or log out and back in." + ) + + class DesktopEnvironment(Enum): """Enum representing the desktop environment.""" @@ -88,6 +110,11 @@ class TextInjector: "Wayland compositor does not support virtual " f"keyboard protocol: {error_output}" ) + if _is_kde_plasma_session(): + logger.warning( + "KDE Plasma Wayland detected. wtype is not a reliable " + f"text injection path on this compositor. {_kde_wayland_ibus_hint()}" + ) if shutil.which("xdotool"): logger.info("Automatically switching to XWayland fallback with xdotool") self.environment = DesktopEnvironment.WAYLAND_XDOTOOL @@ -153,10 +180,17 @@ class TextInjector: # This is important because IBus may be installed but not being used, # e.g., when the user has configured ydotool or Fcitx instead if not is_ibus_active_input_method(): - logger.info( - "IBus is installed but not the active input method. " - "Falling back to alternative text injection method." - ) + if self.environment == DesktopEnvironment.WAYLAND and _is_kde_plasma_session(): + logger.warning( + "IBus is installed but is not active for KDE Plasma Wayland. " + f"{_kde_wayland_ibus_hint()} Falling back to alternative " + "text injection method." + ) + else: + logger.info( + "IBus is installed but not the active input method. " + "Falling back to alternative text injection method." + ) # Check if ibus-daemon is running before attempting setup elif not is_ibus_daemon_running(): logger.info( @@ -230,7 +264,8 @@ class TextInjector: "- xdotool: sudo apt install xdotool (X11/XWayland only)\n" "\n" "For KDE Plasma Wayland users: wtype is not supported. " - "Install ydotool or wl-copy for clipboard fallback:\n" + f"{_kde_wayland_ibus_hint()}\n" + "Or install ydotool/wl-copy for fallback:\n" " sudo apt install ydotool\n" " sudo systemctl enable --now ydotoold\n" "Or for clipboard fallback: sudo apt install wl-copy" @@ -619,12 +654,23 @@ class TextInjector: self._inject_with_wayland_tool(text) except subprocess.CalledProcessError as e: stderr_msg = e.stderr.strip() if e.stderr else "No stderr output" + unsupported_wayland = ( + "compositor does not support" in str(e).lower() + or "compositor does not support" in stderr_msg.lower() + ) logger.warning( f"Wayland tool failed: {e}. stderr: {stderr_msg}. Falling back to xdotool" ) - if "compositor does not support" in str( - e - ).lower() + " " + stderr_msg.lower() and shutil.which("xdotool"): + if ( + unsupported_wayland + and current_env == DesktopEnvironment.WAYLAND + and _is_kde_plasma_session() + ): + logger.warning( + "KDE Plasma Wayland rejected virtual keyboard injection. " + f"{_kde_wayland_ibus_hint()}" + ) + if unsupported_wayland and shutil.which("xdotool"): logger.info( "Switching to XWayland fallback - will re-check for better tools" ) diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index ae620b3..845dae1 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -40,6 +40,32 @@ class TestDesktopEnvironmentEnum(unittest.TestCase): self.assertEqual(DesktopEnvironment.UNKNOWN.value, "unknown") +class TestKdePlasmaDetection(unittest.TestCase): + def test_detects_xdg_current_desktop_kde(self): + from vocalinux.text_injection.text_injector import _is_kde_plasma_session + + with patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=True): + self.assertTrue(_is_kde_plasma_session()) + + def test_detects_kde_full_session(self): + from vocalinux.text_injection.text_injector import _is_kde_plasma_session + + with patch.dict(os.environ, {"KDE_FULL_SESSION": "true"}, clear=True): + self.assertTrue(_is_kde_plasma_session()) + + def test_detects_desktop_session_plasma(self): + from vocalinux.text_injection.text_injector import _is_kde_plasma_session + + with patch.dict(os.environ, {"DESKTOP_SESSION": "plasma"}, clear=True): + self.assertTrue(_is_kde_plasma_session()) + + def test_ignores_non_kde_session(self): + from vocalinux.text_injection.text_injector import _is_kde_plasma_session + + with patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=True): + self.assertFalse(_is_kde_plasma_session()) + + def _make_injector(env) -> Any: from vocalinux.text_injection.text_injector import TextInjector @@ -124,6 +150,75 @@ class TestCheckDependencies(unittest.TestCase): with self.assertRaises(RuntimeError): obj._check_dependencies() + def test_kde_wayland_inactive_ibus_logs_virtual_keyboard_hint(self): + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.WAYLAND) + + with patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "KDE"}, + clear=True, + ): + with patch( + "vocalinux.text_injection.text_injector.is_ibus_available", + return_value=True, + ): + with patch( + "vocalinux.text_injection.text_injector.is_ibus_active_input_method", + return_value=False, + ): + with patch( + "shutil.which", + side_effect=lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None, + ): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + with self.assertLogs( + "vocalinux.text_injection.text_injector", + level="WARNING", + ) as logs: + obj._check_dependencies() + + log_output = "\n".join(logs.output) + self.assertIn("KDE Plasma Wayland", log_output) + self.assertIn("Virtual Keyboard", log_output) + + def test_kde_wayland_wtype_probe_logs_ibus_hint(self): + from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector + + def which(cmd): + if cmd in {"wtype", "xdotool"}: + return f"/usr/bin/{cmd}" + return None + + with patch.dict( + os.environ, + {"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "KDE"}, + clear=True, + ): + with patch( + "vocalinux.text_injection.text_injector.is_ibus_available", + return_value=False, + ): + with patch("shutil.which", side_effect=which): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, + stderr="compositor does not support virtual keyboard", + ) + with patch.object(TextInjector, "_test_xdotool_fallback"): + with self.assertLogs( + "vocalinux.text_injection.text_injector", + level="WARNING", + ) as logs: + injector = TextInjector() + + log_output = "\n".join(logs.output) + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + self.assertIn("KDE Plasma Wayland detected", log_output) + self.assertIn("IBus Wayland", log_output) + class TestInjectText(unittest.TestCase): def test_inject_x11(self): @@ -147,6 +242,34 @@ class TestInjectText(unittest.TestCase): self.assertTrue(mock_run.called) self.assertTrue(result) + def test_kde_wayland_wtype_failure_logs_ibus_hint_and_uses_xdotool(self): + from vocalinux.text_injection.text_injector import DesktopEnvironment + + obj = _make_injector(DesktopEnvironment.WAYLAND) + obj.wayland_tool = "wtype" + error = subprocess.CalledProcessError( + 1, + ["wtype", "hello"], + stderr="compositor does not support virtual keyboard", + ) + + with patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=True): + with patch.object(obj, "_inject_with_wayland_tool", side_effect=error): + with patch.object(obj, "_inject_with_xdotool") as mock_xdotool: + with patch("shutil.which", return_value="/usr/bin/xdotool"): + with self.assertLogs( + "vocalinux.text_injection.text_injector", + level="WARNING", + ) as logs: + result = obj.inject_text("hello") + + self.assertTrue(result) + self.assertEqual(obj.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + mock_xdotool.assert_called_once_with("hello") + log_output = "\n".join(logs.output) + self.assertIn("KDE Plasma Wayland rejected virtual keyboard injection", log_output) + self.assertIn("IBus Wayland", log_output) + def test_inject_ibus(self): from vocalinux.text_injection.text_injector import DesktopEnvironment