From 429c6e239c445171e3d80ea94cf2d1aed90eee0a Mon Sep 17 00:00:00 2001 From: SemmerSky <148617085+SemmerSky@users.noreply.github.com> Date: Wed, 20 May 2026 20:58:11 +0200 Subject: [PATCH] feat: add KDE Plasma 6 window positioning handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing kscript handler stopped working in Plasma 5.71 due to API changes. On Plasma 6 Wayland, NormCap overlay windows pile up on the primary monitor instead of spanning all screens correctly. New kwin6 handler using the updated Plasma 6 JavaScript API: - workspace.windowList() (or workspace.windows as fallback property) - window.frameGeometry = Qt.rect(...) for positioning Implementation details: - Uses jeepney for D-Bus calls (consistent with ADR 006) - json.dumps() for the window title in the JS snippet — prevents injection if the title contains quotes or backslashes - Unloads any leftover script before loading fresh (fixes repeated captures hanging in rare cases) - Cleans up temp .js file in a finally block on all code paths - @functools.cache on _get_plasma_major_version() to avoid repeated plasmashell subprocess calls Includes 13 tests covering compatibility checks, JS generation, special character escaping, and D-Bus error handling. Tested on Fedora 44, KDE Plasma 6.3, Wayland, 3-monitor setup. Co-Authored-By: Claude Sonnet 4.6 --- normcap/positioning/handlers/kwin6.py | 170 ++++++++++++++++++++++++++ normcap/positioning/main.py | 10 +- normcap/positioning/models.py | 5 +- tests/tests_positioning/__init__.py | 0 tests/tests_positioning/test_kwin6.py | 170 ++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 normcap/positioning/handlers/kwin6.py create mode 100644 tests/tests_positioning/__init__.py create mode 100644 tests/tests_positioning/test_kwin6.py diff --git a/normcap/positioning/handlers/kwin6.py b/normcap/positioning/handlers/kwin6.py new file mode 100644 index 000000000..5be71475a --- /dev/null +++ b/normcap/positioning/handlers/kwin6.py @@ -0,0 +1,170 @@ +"""KDE Plasma 6 window positioning via KWin D-Bus scripting. + +This replaces kscript.py which stopped working in Plasma 5.71 due to API changes. +Plasma 6 uses workspace.windowList() and window.frameGeometry instead of +workspace.clientList() / client.geometry. +""" + +import functools +import json +import logging +import re +import subprocess +import tempfile +from pathlib import Path + +from jeepney.io.blocking import Proxy, open_dbus_connection +from jeepney.wrappers import Message, MessageGenerator, new_method_call +from PySide6 import QtWidgets + +from normcap.system import info +from normcap.system.models import DesktopEnvironment, Screen + +logger = logging.getLogger(__name__) + +install_instructions = "" + +_MIN_PLASMA_MAJOR = 6 + + +@functools.cache +def _get_plasma_major_version() -> int | None: + try: + completed = subprocess.run( + ["plasmashell", "--version"], # noqa: S607 + check=True, + capture_output=True, + text=True, + timeout=5, + ) + m = re.search(r"(\d+)\.", completed.stdout.strip()) + return int(m.group(1)) if m else None + except Exception: + logger.debug("Could not retrieve KDE Plasma version", exc_info=True) + return None + + +class DBusKwinScripting6(MessageGenerator): + interface = "org.kde.kwin.Scripting" + + def __init__( + self, + object_path: str = "/Scripting", + bus_name: str = "org.kde.KWin", + ) -> None: + super().__init__(object_path=object_path, bus_name=bus_name) + + def load_script(self, script_file: str, plugin_name: str) -> Message: + # Plasma 6 loadScript requires both filename and plugin name + return new_method_call(self, "loadScript", "ss", (script_file, plugin_name)) + + def unload_script(self, plugin_name: str) -> Message: + return new_method_call(self, "unloadScript", "s", (plugin_name,)) + + def start(self) -> Message: + return new_method_call(self, "start") + + +class DBusKWinIntrospectable(MessageGenerator): + interface = "org.freedesktop.DBus.Introspectable" + + def __init__(self) -> None: + super().__init__(object_path="/Scripting", bus_name="org.kde.KWin") + + def introspect(self) -> Message: + return new_method_call(self, "Introspect") + + +def is_compatible() -> bool: + if info.desktop_environment() != DesktopEnvironment.KDE: + return False + major = _get_plasma_major_version() + return major is not None and major >= _MIN_PLASMA_MAJOR + + +def is_installed() -> bool: + try: + with open_dbus_connection() as router: + proxy = Proxy(DBusKWinIntrospectable(), router) + introspection = proxy.introspect()[0] + except Exception as exc: + logger.debug("Couldn't inspect KWin: %s", exc) + return False + else: + return "org.kde.kwin.Scripting" in introspection + + +def move(window: QtWidgets.QMainWindow, screen: Screen) -> None: + """Move NormCap overlay window to the correct screen on KDE Plasma 6. + + Uses KWin D-Bus scripting with the updated Plasma 6 JavaScript API + (workspace.windowList / window.frameGeometry). + + Args: + window: Qt Window to be re-positioned. + screen: Geometry of the target screen. + """ + title_id = window.windowTitle() + + logger.debug( + "Moving window '%s' to %s via KWin Plasma 6 scripting", title_id, screen + ) + + # Plasma 6 JS API changes across minor versions: + # - workspace.windowList() (function) OR workspace.windows (property) + # - window.caption may be a property or a method depending on version + # - window.frameGeometry = Qt.rect(x, y, w, h) is stable since Plasma 6.0 + # + # json.dumps() produces a properly escaped JS string literal (handles quotes, + # backslashes, newlines, and all other special characters in window titles). + safe_title = json.dumps(title_id) + js_code = f""" + var clients = (typeof workspace.windowList === 'function') + ? workspace.windowList() + : workspace.windows; + for (var i = 0; i < clients.length; i++) {{ + var cap = (typeof clients[i].caption === 'function') + ? clients[i].caption() + : clients[i].caption; + if (cap === {safe_title}) {{ + clients[i].frameGeometry = Qt.rect( + {screen.left}, {screen.top}, {screen.width}, {screen.height} + ); + }} + }} + """ + + # Use delete=False so KWin can still read the file after loadScript() returns. + # Manual cleanup in finally to ensure no leftover temp files on any code path. + tmp_path: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".js", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(js_code) + tmp_path = tmp.name + + with open_dbus_connection() as router: + proxy = Proxy(DBusKwinScripting6(), router) + # Unload any leftover script from a previous run before loading fresh. + try: + proxy.unload_script(plugin_name="normcap_positioning") + except Exception as exc: + logger.debug( + "kwin6: unload_script failed (expected on first run): %s", exc + ) + response = proxy.load_script( + script_file=tmp_path, plugin_name="normcap_positioning" + ) + if response[0] < 0: + raise RuntimeError( # noqa: TRY301 + f"KWin loadScript returned error code: {response[0]}" + ) + proxy.start() + + except Exception: + logger.warning("Failed to move window via KWin Plasma 6 scripting!") + logger.debug("KWin Plasma 6 scripting exception details:", exc_info=True) + finally: + if tmp_path is not None: + Path(tmp_path).unlink(missing_ok=True) diff --git a/normcap/positioning/main.py b/normcap/positioning/main.py index 30ce77bf0..94a6acfbb 100644 --- a/normcap/positioning/main.py +++ b/normcap/positioning/main.py @@ -1,11 +1,12 @@ """Hacks for moving windows to a certain screen on Wayland.""" +import functools import logging from PySide6 import QtWidgets from normcap.gui.constants import URLS # TODO: Remove -from normcap.positioning.handlers import kscript, window_calls +from normcap.positioning.handlers import kscript, kwin6, window_calls from normcap.positioning.models import Handler, HandlerProtocol from normcap.system.models import Screen @@ -15,10 +16,12 @@ _positioning_handlers: dict[Handler, HandlerProtocol] = { Handler.WINDOW_CALLS: window_calls, # TODO: Add Window Calls Extended handler + Handler.KWIN6: kwin6, Handler.KSCRIPT: kscript, } +@functools.cache def get_available_handlers() -> list[Handler]: compatible_handlers = [ h for h in Handler if _positioning_handlers[h].is_compatible() @@ -97,8 +100,9 @@ def move(window: QtWidgets.QMainWindow, screen: Screen) -> None: window: Qt Window to be re-positioned. screen: Geometry of the target screen. """ - for handler in get_available_handlers(): - _move(handler=handler, window=window, screen=screen) + handlers = get_available_handlers() + if handlers: + _move(handler=handlers[0], window=window, screen=screen) return logger.error( diff --git a/normcap/positioning/models.py b/normcap/positioning/models.py index 405cecef8..e9683d60f 100644 --- a/normcap/positioning/models.py +++ b/normcap/positioning/models.py @@ -40,5 +40,8 @@ class Handler(enum.IntEnum): # Preferable on Wayland + Gnome WINDOW_CALLS = enum.auto() - # Might work on some Wayland + KDE. The newer the system, the less likely to work. + # KDE Plasma 6+ (Wayland) — uses updated workspace.windowList() JS API + KWIN6 = enum.auto() + + # KDE Plasma <= 5.70 — uses deprecated workspace.clientList() JS API KSCRIPT = enum.auto() diff --git a/tests/tests_positioning/__init__.py b/tests/tests_positioning/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tests_positioning/test_kwin6.py b/tests/tests_positioning/test_kwin6.py new file mode 100644 index 000000000..6f2dd92af --- /dev/null +++ b/tests/tests_positioning/test_kwin6.py @@ -0,0 +1,170 @@ +import json +import sys +from pathlib import Path +from unittest import mock + +import pytest + +from normcap.positioning.handlers import kwin6 +from normcap.system import info +from normcap.system.models import DesktopEnvironment, Screen + + +def test_is_compatible_false_on_non_kde(monkeypatch): + monkeypatch.setattr(info, "desktop_environment", lambda: DesktopEnvironment.GNOME) + assert not kwin6.is_compatible() + + +def test_is_compatible_false_on_plasma5(monkeypatch): + monkeypatch.setattr(info, "desktop_environment", lambda: DesktopEnvironment.KDE) + monkeypatch.setattr(kwin6, "_get_plasma_major_version", lambda: 5) + assert not kwin6.is_compatible() + + +def test_is_compatible_true_on_plasma6(monkeypatch): + monkeypatch.setattr(info, "desktop_environment", lambda: DesktopEnvironment.KDE) + monkeypatch.setattr(kwin6, "_get_plasma_major_version", lambda: 6) + assert kwin6.is_compatible() + + +def test_is_compatible_false_when_version_unknown(monkeypatch): + monkeypatch.setattr(info, "desktop_environment", lambda: DesktopEnvironment.KDE) + monkeypatch.setattr(kwin6, "_get_plasma_major_version", lambda: None) + assert not kwin6.is_compatible() + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux-only D-Bus test") +def test_is_installed_returns_bool(monkeypatch): + # is_installed() must return a bool without raising. + # Skip actual D-Bus check — just verify it doesn't crash. + result = kwin6.is_installed() + assert isinstance(result, bool) + + +def _make_mock_router_class(): + class MockRouter: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + return MockRouter + + +def test_move_writes_valid_js_and_calls_dbus(monkeypatch): + """move() must write a temp JS file and invoke the KWin D-Bus scripting API.""" + written_js: list[str] = [] + load_script_args: list[tuple] = [] + + class MockProxy: + def __init__(self, gen, router): + pass + + def unload_script(self, plugin_name): + pass + + def load_script(self, script_file, plugin_name): + written_js.append(Path(script_file).read_text()) + load_script_args.append((script_file, plugin_name)) + return (1,) + + def start(self): + pass + + monkeypatch.setattr(kwin6, "open_dbus_connection", _make_mock_router_class()) + monkeypatch.setattr(kwin6, "Proxy", MockProxy) + + window = mock.MagicMock() + window.windowTitle.return_value = "NormCap" + screen = Screen( + left=0, top=0, right=1919, bottom=1079, device_pixel_ratio=1.0, index=0 + ) + + kwin6.move(window=window, screen=screen) + + assert written_js, "No JS was written to the temp file" + assert "NormCap" in written_js[0] + assert "frameGeometry" in written_js[0] + + +@pytest.mark.parametrize( + "title", + [ + 'title with "double quotes"', + "title with 'single quotes'", + "title\nwith\nnewline", + "title\\with\\backslash", + "title\twith\ttab", + "title with ", + ], +) +def test_move_escapes_special_chars_in_title(monkeypatch, title): + """Window titles with special characters must not break the generated JS literal.""" + written_js: list[str] = [] + + class MockProxy: + def __init__(self, gen, router): + pass + + def unload_script(self, plugin_name): + pass + + def load_script(self, script_file, plugin_name): + written_js.append(Path(script_file).read_text()) + return (1,) + + def start(self): + pass + + monkeypatch.setattr(kwin6, "open_dbus_connection", _make_mock_router_class()) + monkeypatch.setattr(kwin6, "Proxy", MockProxy) + + window = mock.MagicMock() + window.windowTitle.return_value = title + screen = Screen(left=0, top=0, right=99, bottom=99, device_pixel_ratio=1.0, index=0) + + kwin6.move(window=window, screen=screen) + + assert written_js, "No JS was written" + js = written_js[0] + + # The title must appear as a properly JSON-encoded string literal. + # json.dumps handles all special characters: quotes, backslashes, newlines, etc. + expected_literal = json.dumps(title) + assert expected_literal in js, ( + f"Expected JSON-encoded title {expected_literal!r} not found in JS:\n{js}" + ) + + +def test_move_does_not_raise_when_load_script_fails(monkeypatch): + """move() must log a warning but not raise when KWin rejects the script. + + loadScript() returns a negative int on error (e.g. invalid path or + KWin internal error). move() should log and return, not propagate. + """ + + class MockProxy: + def __init__(self, gen, router): + pass + + def unload_script(self, plugin_name): + pass + + def load_script(self, script_file, plugin_name): + return (-1,) # negative = error + + def start(self): + pass + + monkeypatch.setattr(kwin6, "open_dbus_connection", _make_mock_router_class()) + monkeypatch.setattr(kwin6, "Proxy", MockProxy) + + window = mock.MagicMock() + window.windowTitle.return_value = "NormCap" + screen = Screen( + left=0, top=0, right=1919, bottom=1079, device_pixel_ratio=1.0, index=0 + ) + + # Must not raise — move() catches exceptions and logs a warning + kwin6.move(window=window, screen=screen)