Skip to content
Open
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
170 changes: 170 additions & 0 deletions normcap/positioning/handlers/kwin6.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 7 additions & 3 deletions normcap/positioning/main.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion normcap/positioning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Empty file.
170 changes: 170 additions & 0 deletions tests/tests_positioning/test_kwin6.py
Original file line number Diff line number Diff line change
@@ -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 <script>alert(1)</script>",
],
)
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)