diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1383442..eef91a8 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.0" + "version": "0.8.1" }, "plugins": [ { "name": "kwin-mcp", "source": "./integrations/claude-code", - "version": "0.8.0", + "version": "0.8.1", "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 afcabf7..6c9532f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.1] - 2026-09-07 + ### Fixed +- `session_start`/`session_connect` crashed entirely when KWin's EIS D-Bus interface was unavailable: `EISClient._setup` left `get_object`/`Interface`/`connectToEIS` unprotected, and `dbus.DBusException` is not a `RuntimeError` (the only type core.py catches to degrade to "no input backend"). The dbus block now raises `RuntimeError("KWin EIS interface unavailable: {exc}")` with error chaining (adopted from upstream isac322/kwin-mcp#42, fix e) +- The bash wrapper hardcoded the Arch-only `/usr/lib/at-spi-bus-launcher` path: on Debian/Ubuntu/Fedora the binary lives in `/usr/libexec` (or `/usr/lib/at-spi2-core`), so the launcher silently no-oped and the session's accessibility bus was dead. The path is now resolved on the Python side before the wrapper is assembled — first existing candidate from `/usr/libexec`, `/usr/lib`, `/usr/lib/at-spi2-core`, then `shutil.which`, then the Arch default (adopted from upstream isac322/kwin-mcp#42, fix c) +- `capture_screenshot_to_file` unconditionally invoked the spectacle CLI, contrary to its own documentation, and minimal/virtual sessions may not have spectacle installed at all. It now tries the ScreenShot2 D-Bus capture first and falls back to spectacle; when both fail, the error carries both causes. The shared single-frame helper drains the pixel pipe concurrently with the D-Bus call (KWin streams pixels before replying) and carries a 5s timeout instead of dbus-python's 25s default (adopted from upstream isac322/kwin-mcp#42, fix f) - `session_start` could hang forever when the KWin wrapper never printed `READY` (dead KWin, missing binaries) or waited forever for the Wayland socket: the parent now reads the wrapper's stdout with a 25s deadline, and the wrapper's socket wait is bounded (150 x 0.1s = 15s, reports `NOSOCKET` and exits 1). Startup failures now include KWin's stderr, and `launch_app` no longer leaks the host `DISPLAY` into isolated sessions, where X11 applications would silently open on the user's real desktop (adopted from upstream isac322/kwin-mcp#50) - EIS input injection started emulating before the compositor had resumed the devices, which libei rejects (`device is not emulating`) and which silently dropped every injected event: `_negotiate_devices` now waits for `EI_EVENT_DEVICE_RESUMED` on the pointer and keyboard before `ei_device_start_emulating` and fails with an explicit error if a device never resumes (adopted from upstream isac322/kwin-mcp#42) - Segfault on Python 3.14 caused by missing `argtypes` on variadic `ei_seat_bind_capabilities` ctypes call diff --git a/README.md b/README.md index 9ad0a20..2076ba0 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.0, 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.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. ### Using uv (recommended) diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index b1fb570..50db760 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.0", + "version": "0.8.1", "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 933820f..6d36447 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.0", + "version": "0.8.1", "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 db9c8cc..37adc4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kwin-mcp" -version = "0.8.0" +version = "0.8.1" 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/input.py b/src/kwin_mcp/input.py index d06dc24..a466d0a 100644 --- a/src/kwin_mcp/input.py +++ b/src/kwin_mcp/input.py @@ -338,19 +338,27 @@ def __init__(self, dbus_address: str) -> None: def _setup(self) -> None: """Connect to KWin EIS and negotiate devices.""" - eis_obj = self._bus.get_object("org.kde.KWin", "/org/kde/KWin/EIS/RemoteDesktop") - self._eis_iface = dbus.Interface(eis_obj, "org.kde.KWin.EIS.RemoteDesktop") - - # Request all relevant capabilities - caps = ( - _EI_CAP_POINTER - | _EI_CAP_POINTER_ABSOLUTE - | _EI_CAP_KEYBOARD - | _EI_CAP_TOUCH - | _EI_CAP_BUTTON - | _EI_CAP_SCROLL - ) - result = self._eis_iface.connectToEIS(dbus.Int32(caps)) + # KWin only exposes the EIS interface when it supports remote input; + # translate the D-Bus failure so callers can treat the input backend as + # optional (core.py degrades to "no input backend" on RuntimeError; + # ported from upstream isac322/kwin-mcp#42). + try: + eis_obj = self._bus.get_object("org.kde.KWin", "/org/kde/KWin/EIS/RemoteDesktop") + self._eis_iface = dbus.Interface(eis_obj, "org.kde.KWin.EIS.RemoteDesktop") + + # Request all relevant capabilities + caps = ( + _EI_CAP_POINTER + | _EI_CAP_POINTER_ABSOLUTE + | _EI_CAP_KEYBOARD + | _EI_CAP_TOUCH + | _EI_CAP_BUTTON + | _EI_CAP_SCROLL + ) + result = self._eis_iface.connectToEIS(dbus.Int32(caps)) + except dbus.DBusException as exc: + msg = f"KWin EIS interface unavailable: {exc}" + raise RuntimeError(msg) from exc fd = result[0].take() self._cookie = int(result[1]) diff --git a/src/kwin_mcp/screenshot.py b/src/kwin_mcp/screenshot.py index 12adb95..884fa1e 100644 --- a/src/kwin_mcp/screenshot.py +++ b/src/kwin_mcp/screenshot.py @@ -4,12 +4,18 @@ import os import subprocess +import threading import time from pathlib import Path import dbus import dbus.bus +# Upper bound for a single ScreenShot2 capture. Generous for a real capture +# (tens of milliseconds) yet short enough that the spectacle fallback stays +# responsive when KWin never answers (ported from upstream isac322/kwin-mcp#42). +_CAPTURE_TIMEOUT_S = 5.0 + def capture_screenshot_to_file( dbus_address: str = "", @@ -20,6 +26,11 @@ def capture_screenshot_to_file( ) -> Path: """Capture a screenshot and save to a file. + Tries the fast KWin ScreenShot2 D-Bus route first and falls back to the + spectacle CLI when D-Bus capture is unauthorized or unavailable (e.g. live + sessions without KWIN_SCREENSHOT_NO_PERMISSION_CHECKS, or minimal sessions + without spectacle installed) (ported from upstream isac322/kwin-mcp#42). + Args: dbus_address: D-Bus session bus address for the isolated session. wayland_socket: Wayland socket name for the isolated session. @@ -36,12 +47,23 @@ def capture_screenshot_to_file( timestamp = time.strftime("%Y%m%d_%H%M%S") output_path = output_dir / f"screenshot_{timestamp}.png" - _capture_via_spectacle( - dbus_address, - wayland_socket, - output_path=output_path, - include_cursor=include_cursor, - ) + try: + return capture_screenshot_dbus( + dbus_address, + output_path, + include_cursor=include_cursor, + ) + except (dbus.DBusException, RuntimeError) as exc: + try: + _capture_via_spectacle( + dbus_address, + wayland_socket, + output_path=output_path, + include_cursor=include_cursor, + ) + except RuntimeError as fallback_exc: + msg = f"D-Bus screenshot failed ({exc}); spectacle fallback unusable ({fallback_exc})" + raise RuntimeError(msg) from fallback_exc return output_path @@ -74,32 +96,12 @@ def capture_screenshot_dbus( screenshot_obj = bus.get_object("org.kde.KWin", "/org/kde/KWin/ScreenShot2") iface = dbus.Interface(screenshot_obj, "org.kde.KWin.ScreenShot2") - read_fd, write_fd = os.pipe() - try: - options = {"include-cursor": dbus.Boolean(include_cursor)} - results = iface.CaptureActiveScreen(options, dbus.types.UnixFd(write_fd)) - finally: - os.close(write_fd) - - try: - chunks = [] - while True: - chunk = os.read(read_fd, 65536) - if not chunk: - break - chunks.append(chunk) - finally: - os.close(read_fd) - - data = b"".join(chunks) + options = {"include-cursor": dbus.Boolean(include_cursor)} + data, width, height, stride = _capture_raw_frame(iface, options) if not data: msg = "KWin ScreenShot2 returned no data" raise RuntimeError(msg) - width = int(results["width"]) - height = int(results["height"]) - stride = int(results["stride"]) - # KWin returns raw ARGB32_Premultiplied (Qt format 6) in native byte order. # On little-endian systems, bytes are stored as BGRA. img = Image.frombytes("RGBA", (width, height), data, "raw", "BGRA", stride) @@ -107,6 +109,54 @@ def capture_screenshot_dbus( return output_path +def _capture_raw_frame( + iface: dbus.Interface, + options: dict[str, dbus.Boolean], +) -> tuple[bytes, int, int, int]: + """Capture one raw frame over ScreenShot2, returning (data, w, h, stride). + + KWin streams the pixels into the pipe before it answers the D-Bus call, + and a frame is far larger than the pipe buffer, so the pipe is drained by + a reader thread while the call is in flight (a synchronous drain after the + reply would deadlock both sides). The reader owns read_fd and closes it in + its finally block, keeping a still-blocked read from surviving the call. + + The call carries an explicit timeout: a compositor that never answers + would otherwise burn dbus-python's 25s default before callers can fall + back to spectacle (ported from upstream isac322/kwin-mcp#42). + + Unlike upstream, an empty frame is not an error here: the frame burst + path historically skipped empty frames, and keeping the check with the + callers preserves that behavior. + """ + read_fd, write_fd = os.pipe() + chunks: list[bytes] = [] + + def drain() -> None: + try: + while True: + chunk = os.read(read_fd, 65536) + if not chunk: + break + chunks.append(chunk) + finally: + os.close(read_fd) + + reader = threading.Thread(target=drain, daemon=True) + reader.start() + try: + results = iface.CaptureActiveScreen( + options, + dbus.types.UnixFd(write_fd), + timeout=_CAPTURE_TIMEOUT_S, + ) + finally: + os.close(write_fd) + reader.join(timeout=_CAPTURE_TIMEOUT_S) + + return b"".join(chunks), int(results["width"]), int(results["height"]), int(results["stride"]) + + def capture_frame_burst( dbus_address: str, output_dir: Path, @@ -176,29 +226,7 @@ def _capture_frame_burst_dbus( if now < target_time: time.sleep(target_time - now) - read_fd, write_fd = os.pipe() - try: - results = iface.CaptureActiveScreen(options, dbus.types.UnixFd(write_fd)) - finally: - os.close(write_fd) - try: - chunks = [] - while True: - chunk = os.read(read_fd, 65536) - if not chunk: - break - chunks.append(chunk) - finally: - os.close(read_fd) - - raw_frames.append( - ( - b"".join(chunks), - int(results["width"]), - int(results["height"]), - int(results["stride"]), - ) - ) + raw_frames.append(_capture_raw_frame(iface, options)) # Phase 2: Convert raw frames to PNG (timing-insensitive) frame_paths: list[Path] = [] diff --git a/src/kwin_mcp/session.py b/src/kwin_mcp/session.py index ab7d138..713128b 100644 --- a/src/kwin_mcp/session.py +++ b/src/kwin_mcp/session.py @@ -20,6 +20,23 @@ from enum import Enum from pathlib import Path +# at-spi-bus-launcher is not on PATH and its location is distro-specific: +# /usr/lib on Arch, /usr/libexec on Debian/Ubuntu/Fedora (ported from upstream +# isac322/kwin-mcp#42). +_AT_SPI_LAUNCHER_CANDIDATES = ( + "/usr/libexec/at-spi-bus-launcher", + "/usr/lib/at-spi-bus-launcher", + "/usr/lib/at-spi2-core/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] + class SessionType(Enum): """Type of KWin session.""" @@ -381,7 +398,9 @@ def _build_wrapper_script(self, config: SessionConfig) -> str: # Start the AT-SPI accessibility bus. # ATSPI_DBUS_IMPLEMENTATION is set in _build_env() to force dbus-daemon # instead of dbus-broker (which reuses the host's AT-SPI bus). -/usr/lib/at-spi-bus-launcher --launch-immediately & +# 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 & AT_SPI_PID=$! sleep 0.2 diff --git a/tests/test_input_eis_error.py b/tests/test_input_eis_error.py new file mode 100644 index 0000000..1e22071 --- /dev/null +++ b/tests/test_input_eis_error.py @@ -0,0 +1,71 @@ +"""Tests for the EIS D-Bus error contract in EISClient._setup. + +Ported from upstream isac322/kwin-mcp#42: the dbus calls in ``_setup`` +(get_object → Interface → connectToEIS) were unprotected, and +``dbus.DBusException`` is not a ``RuntimeError`` — so a KWin without a usable +EIS interface crashed the whole ``session_start``/``session_connect`` instead +of degrading to "no input backend" (core.py catches only RuntimeError). +""" + +from __future__ import annotations + +from typing import Any + +import dbus +import pytest + +import kwin_mcp.input as input_module +from kwin_mcp.input import EISClient + + +class _FailingBus: + """BusConnection stub whose get_object raises the anticipated D-Bus error.""" + + def __init__(self, exc: Exception) -> None: + self._exc = exc + + def get_object(self, *args: Any, **kwargs: Any) -> None: + raise self._exc + + +def _client_with_bus(bus: Any) -> EISClient: + """An EISClient that skipped __init__ (no D-Bus connection, no libei load).""" + client = EISClient.__new__(EISClient) + client._bus = bus + return client + + +def test_setup_translates_dbus_exception_to_runtime_error() -> None: + """dbus.DBusException from get_object → RuntimeError naming the EIS interface.""" + exc = dbus.DBusException("org.freedesktop.DBus.Error.ServiceUnknown: no org.kde.KWin") + client = _client_with_bus(_FailingBus(exc)) + with pytest.raises(RuntimeError, match="KWin EIS interface unavailable") as caught: + client._setup() + assert "ServiceUnknown" in str(caught.value) + # Error chaining preserved for diagnostics. + assert isinstance(caught.value.__cause__, dbus.DBusException) + + +def test_setup_translates_connect_failure_from_interface_proxy(monkeypatch) -> None: + """A DBusException surfacing later (Interface/connectToEIS stage) is also + translated: the whole dbus block is covered, not just get_object.""" + + class _ThrowingIface: + def __init__(self, *args: Any) -> None: + pass + + # Mirrors the real D-Bus method name (camelCase per the KWin interface). + 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: + return object() + + client = _client_with_bus(_OkBus()) + with pytest.raises(RuntimeError, match="KWin EIS interface unavailable") as caught: + client._setup() + assert "not supported" in str(caught.value) diff --git a/tests/test_screenshot_fallback.py b/tests/test_screenshot_fallback.py new file mode 100644 index 0000000..290933c --- /dev/null +++ b/tests/test_screenshot_fallback.py @@ -0,0 +1,102 @@ +"""Tests for the ScreenShot2-first capture with spectacle fallback. + +Ported from upstream isac322/kwin-mcp#42: ``capture_screenshot_to_file`` +unconditionally called ``_capture_via_spectacle``, contrary to its own +documentation — and minimal/virtual sessions may not have spectacle at all, +while ScreenShot2 works. Now the D-Bus route is tried first and spectacle is +the fallback; when both fail, the error carries both causes. + +The frame burst path keeps its historical behavior: empty frames are skipped +(not errors), so ``_capture_raw_frame`` itself does not raise on empty data. +""" + +from __future__ import annotations + +import dbus +import pytest + +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: + """A successful D-Bus capture returns immediately; spectacle is not called.""" + calls: list[str] = [] + + def fake_dbus(address: str, path, *, include_cursor: bool = False): + calls.append("dbus") + path.write_bytes(b"png") + return path + + monkeypatch.setattr(screenshot_module, "capture_screenshot_dbus", fake_dbus) + monkeypatch.setattr( + screenshot_module, + "_capture_via_spectacle", + lambda *a, **k: calls.append("spectacle"), + ) + + path = capture_screenshot_to_file("unix:path=/tmp/dbus", "wayland-0", output_dir=tmp_path) + assert calls == ["dbus"] + assert path.parent == tmp_path + + +def test_dbus_failure_falls_back_to_spectacle(monkeypatch, tmp_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): + calls.append("dbus") + raise dbus.DBusException("not authorized") + + def fake_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False): + calls.append("spectacle") + output_path.write_bytes(b"png") + + monkeypatch.setattr(screenshot_module, "capture_screenshot_dbus", failing_dbus) + monkeypatch.setattr(screenshot_module, "_capture_via_spectacle", fake_spectacle) + + path = capture_screenshot_to_file("unix:path=/tmp/dbus", "wayland-0", output_dir=tmp_path) + assert calls == ["dbus", "spectacle"] + assert path.exists() + + +def test_both_routes_failing_reports_both_errors(monkeypatch, tmp_path) -> None: + """When D-Bus and spectacle both fail, the error names both causes.""" + + def failing_dbus(address: str, path, *, include_cursor: bool = False): + raise dbus.DBusException("not authorized") + + def failing_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False): + raise RuntimeError("spectacle not found") + + monkeypatch.setattr(screenshot_module, "capture_screenshot_dbus", failing_dbus) + monkeypatch.setattr(screenshot_module, "_capture_via_spectacle", failing_spectacle) + + with pytest.raises(RuntimeError, match=r"not authorized.*spectacle not found"): + 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: + """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): + return b"", 0, 0, 0 + + class _StubBus: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def get_object(self, *args: object, **kwargs: object) -> object: + return 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) + 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 diff --git a/tests/test_session_startup.py b/tests/test_session_startup.py index 54c32ad..2f2e6e3 100644 --- a/tests/test_session_startup.py +++ b/tests/test_session_startup.py @@ -14,6 +14,10 @@ - Startup failures now include KWin's stderr (pattern from upstream PR #42): the old code read stderr after ``stop()``, which had already cleared ``self._process``, so the message was always empty. +- The hardcoded ``/usr/lib/at-spi-bus-launcher`` wrapper path (Arch-only) + silently no-oped on Debian/Ubuntu/Fedora (``/usr/libexec/...``), leaving a + dead accessibility bus: the launcher is now resolved on the Python side + before the wrapper is assembled (upstream PR #42, fix c). """ from __future__ import annotations @@ -23,6 +27,7 @@ from types import SimpleNamespace from typing import Any, cast +import kwin_mcp.session as session_module from kwin_mcp.session import Session, SessionConfig, SessionInfo @@ -82,6 +87,46 @@ def test_wrapper_script_bounded_socket_wait_and_display_unset() -> None: assert "-u DISPLAY" in script +def test_at_spi_launcher_prefers_existing_candidate(monkeypatch, tmp_path) -> None: + """The first existing candidate wins: on Debian/Ubuntu/Fedora the launcher + lives in /usr/libexec, and that path must land in the wrapper.""" + libexec = tmp_path / "libexec-launcher" + libexec.write_text("") + monkeypatch.setattr( + session_module, + "_AT_SPI_LAUNCHER_CANDIDATES", + (str(libexec), "/nonexistent/a", "/nonexistent/b"), + ) + monkeypatch.setattr(session_module.shutil, "which", lambda name: "/from-which/launcher") + assert session_module._at_spi_bus_launcher() == str(libexec) + + +def test_at_spi_launcher_falls_back_to_which(monkeypatch) -> None: + """No candidate exists → shutil.which, then the Arch default as last resort.""" + monkeypatch.setattr( + session_module, "_AT_SPI_LAUNCHER_CANDIDATES", ("/nonexistent/a", "/nonexistent/b") + ) + monkeypatch.setattr( + session_module.shutil, + "which", + lambda name: "/usr/local/bin/at-spi-bus-launcher", + ) + 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" + + +def test_wrapper_script_contains_resolved_launcher(monkeypatch) -> None: + """The wrapper embeds the resolved launcher path, not a hardcoded one.""" + 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()) + assert "/resolved/launcher --launch-immediately" in script + assert "/usr/lib/at-spi-bus-launcher" not in script + + def test_launch_app_strips_host_display(monkeypatch, tmp_path) -> None: """launch_app removes the host DISPLAY from the app environment so X11 apps cannot silently open on the user's real desktop.""" diff --git a/uv.lock b/uv.lock index 2516d67..43080ca 100644 --- a/uv.lock +++ b/uv.lock @@ -291,7 +291,7 @@ wheels = [ [[package]] name = "kwin-mcp" -version = "0.8.0" +version = "0.8.1" source = { editable = "." } dependencies = [ { name = "dbus-python" },