diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index eef91a8..5f44109 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,13 +8,13 @@ }, "metadata": { "description": "kwin-mcp marketplace \u2014 MCP server and skill for Linux KDE Plasma 6 Wayland desktop GUI automation (virtual sessions for headless testing, live sessions for real-desktop / container / kiosk control).", - "version": "0.8.1" + "version": "0.8.2" }, "plugins": [ { "name": "kwin-mcp", "source": "./integrations/claude-code", - "version": "0.8.1", + "version": "0.8.2", "description": "Bundles the kwin-mcp MCP server (uvx kwin-mcp) plus the kwin-desktop-automation skill that teaches session-mode selection, the observe-act-verify loop, US-QWERTY vs Unicode typing, and platform pitfalls (surface-local coordinates, QMenu invisibility, EIS edge limits, clipboard opt-in).", "category": "automation", "keywords": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9532f..171e6a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.2] - 2026-09-07 + +### Fixed + +- The last-resort fallback of the at-spi-bus-launcher resolver returned `candidates[0]` (`/usr/libexec/...`, the Debian/Ubuntu/Fedora layout) when no candidate file existed and `shutil.which` found nothing — a dead path on Arch. The fallback is now the Arch default `/usr/lib/at-spi-bus-launcher`, and the resolved path is shell-quoted when embedded into the session's bash wrapper +- The reason an `InputBackend` (KWin EIS) setup failed was silently swallowed when `session_start`/`session_connect` degraded to "no input backend"/ydotool; the exception text is now logged as a warning before the degradation (backend selection unchanged) + +### Internal + +- Test cleanups (dead assignment, tautological assert) and type hints on test fakes/helpers + ## [0.8.1] - 2026-09-07 ### Fixed diff --git a/README.md b/README.md index 2076ba0..99414e2 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ sudo apt install wl-clipboard wtype wayland-utils ## Installation -> **Note**: the `kwin-mcp` name on PyPI tracks the upstream project (`isac322/kwin-mcp`, v0.7.0, MCP SDK 1.x). This repository is the actively maintained fork (v0.8.1, MCP SDK 2.x); install it from git or from a source checkout. The `uvx kwin-mcp` config examples in the [Configuration](#configuration) section resolve to the PyPI package. +> **Note**: the `kwin-mcp` name on PyPI tracks the upstream project (`isac322/kwin-mcp`, v0.7.0, MCP SDK 1.x). This repository is the actively maintained fork (v0.8.2, MCP SDK 2.x); install it from git or from a source checkout. The `uvx kwin-mcp` config examples in the [Configuration](#configuration) section resolve to the PyPI package. ### Using uv (recommended) diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 50db760..25fc56f 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://claude.ai/schemas/plugin.json", "name": "kwin-mcp", - "version": "0.8.1", + "version": "0.8.2", "description": "MCP server + skill for Linux KDE Plasma 6 Wayland GUI automation. Provides 30 MCP tools (mouse, keyboard, touch, clipboard, screenshot, AT-SPI2 accessibility tree, window mgmt, D-Bus passthrough) plus the kwin-desktop-automation skill that guides session-mode selection, observation/action sequencing, and platform pitfalls.", "author": { "name": "Byeonghoon Yoo", diff --git a/integrations/opencode/plugin/package.json b/integrations/opencode/plugin/package.json index 6d36447..10aa56b 100644 --- a/integrations/opencode/plugin/package.json +++ b/integrations/opencode/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@isac322/kwin-mcp-opencode", - "version": "0.8.1", + "version": "0.8.2", "description": "OpenCode plugin for kwin-mcp \u2014 auto-registers the kwin-mcp MCP server (uvx kwin-mcp) and ships the kwin-desktop-automation skill on backend startup.", "type": "module", "main": "./dist/index.js", diff --git a/pyproject.toml b/pyproject.toml index 37adc4c..a3c4066 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kwin-mcp" -version = "0.8.1" +version = "0.8.2" description = "MCP server for Linux desktop GUI automation on KDE Plasma 6 Wayland — virtual testing and live desktop automation" readme = "README.md" license = "MIT" diff --git a/src/kwin_mcp/core.py b/src/kwin_mcp/core.py index fd4453f..3fc5a59 100644 --- a/src/kwin_mcp/core.py +++ b/src/kwin_mcp/core.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import logging import os import shlex import shutil @@ -24,6 +25,8 @@ from kwin_mcp.screenshot import capture_frame_burst, capture_screenshot_to_file from kwin_mcp.session import LiveSession, Session, SessionConfig +logger = logging.getLogger(__name__) + # Install hints for external binaries _INSTALL_HINTS: dict[str, str] = { "wl-paste": ( @@ -246,7 +249,10 @@ def session_start( time.sleep(0.5) try: self._input = InputBackend(info.dbus_address) - except RuntimeError: + except RuntimeError as exc: + logger.warning( + "KWin EIS input backend unavailable, degrading to no input backend: %s", exc + ) self._input = None input_status = "Input backend: KWin EIS" if self._input else "No input backend available" @@ -306,7 +312,10 @@ def session_connect( try: self._input = InputBackend(dbus_addr) result += "\nInput backend: KWin EIS" - except RuntimeError: + except RuntimeError as exc: + logger.warning( + "KWin EIS input backend unavailable, falling back to ydotool if present: %s", exc + ) self._input = None if shutil.which("ydotool"): result += "\nInput backend: ydotool (EIS unavailable)" diff --git a/src/kwin_mcp/session.py b/src/kwin_mcp/session.py index 713128b..f404f01 100644 --- a/src/kwin_mcp/session.py +++ b/src/kwin_mcp/session.py @@ -10,6 +10,7 @@ import contextlib import os import queue +import shlex import shutil import signal import subprocess @@ -29,13 +30,19 @@ "/usr/lib/at-spi2-core/at-spi-bus-launcher", ) +# Last-resort default when no candidate exists and PATH lookup fails: the +# Arch layout (the primary development platform). A literal rather than a +# candidate index so reordering _AT_SPI_LAUNCHER_CANDIDATES cannot silently +# repoint the fallback at another distro's path. +_AT_SPI_LAUNCHER_FALLBACK = "/usr/lib/at-spi-bus-launcher" + def _at_spi_bus_launcher() -> str: """Locate the AT-SPI bus launcher binary for the current distribution.""" for candidate in _AT_SPI_LAUNCHER_CANDIDATES: if Path(candidate).exists(): return candidate - return shutil.which("at-spi-bus-launcher") or _AT_SPI_LAUNCHER_CANDIDATES[0] + return shutil.which("at-spi-bus-launcher") or _AT_SPI_LAUNCHER_FALLBACK class SessionType(Enum): @@ -400,7 +407,7 @@ def _build_wrapper_script(self, config: SessionConfig) -> str: # instead of dbus-broker (which reuses the host's AT-SPI bus). # The launcher path is distro-specific: resolved on the Python side # (_at_spi_bus_launcher) before this wrapper is assembled. -{_at_spi_bus_launcher()} --launch-immediately & +{shlex.quote(_at_spi_bus_launcher())} --launch-immediately & AT_SPI_PID=$! sleep 0.2 diff --git a/tests/test_input_eis_error.py b/tests/test_input_eis_error.py index 1e22071..5365d97 100644 --- a/tests/test_input_eis_error.py +++ b/tests/test_input_eis_error.py @@ -46,7 +46,9 @@ def test_setup_translates_dbus_exception_to_runtime_error() -> None: assert isinstance(caught.value.__cause__, dbus.DBusException) -def test_setup_translates_connect_failure_from_interface_proxy(monkeypatch) -> None: +def test_setup_translates_connect_failure_from_interface_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: """A DBusException surfacing later (Interface/connectToEIS stage) is also translated: the whole dbus block is covered, not just get_object.""" @@ -59,7 +61,6 @@ def connectToEIS(self, *args: Any) -> None: # noqa: N802 raise dbus.DBusException("org.kde.KWin.EIS.RemoteDesktop: not supported") monkeypatch.setattr(input_module.dbus, "Interface", _ThrowingIface) - client = _client_with_bus(object()) class _OkBus: def get_object(self, *args: Any, **kwargs: Any) -> object: diff --git a/tests/test_screenshot_fallback.py b/tests/test_screenshot_fallback.py index 290933c..e267006 100644 --- a/tests/test_screenshot_fallback.py +++ b/tests/test_screenshot_fallback.py @@ -12,18 +12,23 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import dbus import pytest +if TYPE_CHECKING: + from pathlib import Path + import kwin_mcp.screenshot as screenshot_module from kwin_mcp.screenshot import capture_screenshot_to_file -def test_dbus_success_skips_spectacle(monkeypatch, tmp_path) -> None: +def test_dbus_success_skips_spectacle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """A successful D-Bus capture returns immediately; spectacle is not called.""" calls: list[str] = [] - def fake_dbus(address: str, path, *, include_cursor: bool = False): + def fake_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path: calls.append("dbus") path.write_bytes(b"png") return path @@ -40,15 +45,23 @@ def fake_dbus(address: str, path, *, include_cursor: bool = False): assert path.parent == tmp_path -def test_dbus_failure_falls_back_to_spectacle(monkeypatch, tmp_path) -> None: +def test_dbus_failure_falls_back_to_spectacle( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """A D-Bus failure (DBusException) degrades to spectacle, not an error.""" calls: list[str] = [] - def failing_dbus(address: str, path, *, include_cursor: bool = False): + def failing_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path: calls.append("dbus") raise dbus.DBusException("not authorized") - def fake_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False): + def fake_spectacle( + address: str, + socket: str, + *, + output_path: Path, + include_cursor: bool = False, + ) -> None: calls.append("spectacle") output_path.write_bytes(b"png") @@ -60,13 +73,21 @@ def fake_spectacle(address: str, socket: str, *, output_path, include_cursor: bo assert path.exists() -def test_both_routes_failing_reports_both_errors(monkeypatch, tmp_path) -> None: +def test_both_routes_failing_reports_both_errors( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """When D-Bus and spectacle both fail, the error names both causes.""" - def failing_dbus(address: str, path, *, include_cursor: bool = False): + def failing_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path: raise dbus.DBusException("not authorized") - def failing_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False): + def failing_spectacle( + address: str, + socket: str, + *, + output_path: Path, + include_cursor: bool = False, + ) -> None: raise RuntimeError("spectacle not found") monkeypatch.setattr(screenshot_module, "capture_screenshot_dbus", failing_dbus) @@ -76,15 +97,24 @@ def failing_spectacle(address: str, socket: str, *, output_path, include_cursor: capture_screenshot_to_file("unix:path=/tmp/dbus", "wayland-0", output_dir=tmp_path) -def test_frame_burst_skips_empty_frames(monkeypatch, tmp_path) -> None: +def test_frame_burst_skips_empty_frames(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Regression guard for the frame burst path: an empty frame is skipped in phase 2 instead of aborting the burst (pre-refactor behavior).""" from PIL import Image - def fake_raw_frame(iface, options): + saved: list[object] = [] + + def fake_raw_frame( + iface: dbus.Interface, + options: dict[str, dbus.Boolean], + ) -> tuple[bytes, int, int, int]: return b"", 0, 0, 0 + def fake_frombytes(*args: object, **kwargs: object) -> object: + saved.append(args) + return object() + class _StubBus: def __init__(self, *args: object, **kwargs: object) -> None: pass @@ -95,8 +125,10 @@ def get_object(self, *args: object, **kwargs: object) -> object: monkeypatch.setattr(screenshot_module.dbus.bus, "BusConnection", _StubBus) monkeypatch.setattr(screenshot_module.dbus, "Interface", lambda *a: object()) monkeypatch.setattr(screenshot_module, "_capture_raw_frame", fake_raw_frame) + monkeypatch.setattr(Image, "frombytes", fake_frombytes) frames = screenshot_module._capture_frame_burst_dbus( "unix:path=/tmp/dbus", tmp_path, [0], include_cursor=False ) assert frames == [] - assert Image # PIL import inside the function is exercised by the call above + # Phase 2 must not attempt a PNG conversion of the empty frame either. + assert saved == [] diff --git a/tests/test_session_startup.py b/tests/test_session_startup.py index 2f2e6e3..d3988e1 100644 --- a/tests/test_session_startup.py +++ b/tests/test_session_startup.py @@ -114,18 +114,24 @@ def test_at_spi_launcher_falls_back_to_which(monkeypatch) -> None: assert session_module._at_spi_bus_launcher() == "/usr/local/bin/at-spi-bus-launcher" monkeypatch.setattr(session_module.shutil, "which", lambda name: None) - assert session_module._at_spi_bus_launcher() == "/nonexistent/a" + assert session_module._at_spi_bus_launcher() == "/usr/lib/at-spi-bus-launcher" def test_wrapper_script_contains_resolved_launcher(monkeypatch) -> None: - """The wrapper embeds the resolved launcher path, not a hardcoded one.""" + """The wrapper embeds the resolved launcher path, not a hardcoded one, and + shell-quotes it so paths with special characters stay one argument.""" monkeypatch.setattr(session_module, "_at_spi_bus_launcher", lambda: "/resolved/launcher") session = Session() session._socket_name = "wayland-mcp-test" script = session._build_wrapper_script(SessionConfig()) + # shlex.quote leaves a plain path untouched (no spurious quotes). assert "/resolved/launcher --launch-immediately" in script assert "/usr/lib/at-spi-bus-launcher" not in script + monkeypatch.setattr(session_module, "_at_spi_bus_launcher", lambda: "/opt/my tools/launcher") + quoted = session._build_wrapper_script(SessionConfig()) + assert "'/opt/my tools/launcher' --launch-immediately" in quoted + def test_launch_app_strips_host_display(monkeypatch, tmp_path) -> None: """launch_app removes the host DISPLAY from the app environment so X11 diff --git a/uv.lock b/uv.lock index 43080ca..0e0da7a 100644 --- a/uv.lock +++ b/uv.lock @@ -291,7 +291,7 @@ wheels = [ [[package]] name = "kwin-mcp" -version = "0.8.1" +version = "0.8.2" source = { editable = "." } dependencies = [ { name = "dbus-python" },