Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion integrations/opencode/plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
34 changes: 21 additions & 13 deletions src/kwin_mcp/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
130 changes: 79 additions & 51 deletions src/kwin_mcp/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand All @@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -74,39 +96,67 @@ 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)
img.save(output_path, "PNG")
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,
Expand Down Expand Up @@ -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] = []
Expand Down
21 changes: 20 additions & 1 deletion src/kwin_mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions tests/test_input_eis_error.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading