* fix(injection): read WM_CLASS with xprop so xdotool stops dumping core
`xdotool getwindowclassname` hands an uninitialized XClassHint to XFree
when the window carries no WM_CLASS, so it aborts with SIGABRT
("free(): invalid size") and leaves a coredump behind. On KDE Plasma
Wayland that is the normal case, not an edge case: while a native
Wayland client holds focus, KWin points XWayland's _NET_ACTIVE_WINDOW at
a property-less window, and `xdotool getactivewindow` reports it happily.
Every paste-shortcut probe therefore killed a child process and filled
the journal with coredumps. Injection still worked, because the abort
only made the probe return an empty class, so the symptom was silent
except in `coredumpctl`.
read_wm_class() now reads the property with xprop, which handles a
missing WM_CLASS as an ordinary empty result, and falls back to xdotool
only when xprop is absent and the window looks like a real client (it has
a pid or a title). The debug window-info logging in text_injector.py goes
through the same helper.
Reproduced on Fedora 44, Plasma 6 Wayland, xdotool 3.20211022.1:
`xdotool getwindowclassname 0x200000` exits 134 on the window that
`_NET_ACTIVE_WINDOW` points at.
* fix(injection): keep xdotool classname for class-only X11 clients
When xprop is missing, always allow the xdotool classname fallback so a
real terminal that exposes WM_CLASS but no title or _NET_WM_PID still
classifies as a terminal (Ctrl+Shift+V). xprop remains the primary path,
so Plasma Wayland with xprop never calls getwindowclassname.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""Tests for focused-window identity and terminal detection."""
|
|
|
|
import json
|
|
import unittest
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
from vocalinux.text_injection.focused_window import (
|
|
FocusedWindow,
|
|
get_focused_window,
|
|
is_focused_window_terminal,
|
|
looks_like_terminal,
|
|
)
|
|
|
|
|
|
class TestLooksLikeTerminal(unittest.TestCase):
|
|
"""Unit tests for standalone terminal identity matching."""
|
|
|
|
def test_known_terminal_process(self):
|
|
self.assertTrue(looks_like_terminal(FocusedWindow(process_name="kitty")))
|
|
|
|
def test_gnome_terminal_app_id(self):
|
|
self.assertTrue(looks_like_terminal(FocusedWindow(app_id="org.gnome.Terminal")))
|
|
|
|
def test_ghostty_class(self):
|
|
self.assertTrue(looks_like_terminal(FocusedWindow(wm_class="com.mitchellh.ghostty")))
|
|
|
|
def test_xfce_terminal_class(self):
|
|
self.assertTrue(looks_like_terminal(FocusedWindow(wm_class="Xfce4-terminal")))
|
|
|
|
def test_st_is_exact_token_only(self):
|
|
self.assertTrue(looks_like_terminal(FocusedWindow(process_name="st")))
|
|
self.assertFalse(looks_like_terminal(FocusedWindow(process_name="steam")))
|
|
|
|
def test_editor_is_not_a_terminal(self):
|
|
self.assertFalse(
|
|
looks_like_terminal(
|
|
FocusedWindow(app_id="code", title="terminal.py — Visual Studio Code")
|
|
)
|
|
)
|
|
|
|
def test_cursor_nested_terminal_panel_is_not_auto_detected(self):
|
|
self.assertFalse(
|
|
looks_like_terminal(FocusedWindow(app_id="cursor", title="bash — Terminal"))
|
|
)
|
|
|
|
def test_browser_is_not_a_terminal(self):
|
|
self.assertFalse(looks_like_terminal(FocusedWindow(app_id="firefox", title="Terminal")))
|
|
|
|
def test_empty_window_is_not_a_terminal(self):
|
|
self.assertFalse(looks_like_terminal(FocusedWindow()))
|
|
|
|
def test_non_string_identity_is_ignored(self):
|
|
mock_value = MagicMock()
|
|
window = FocusedWindow(app_id=mock_value, wm_class=mock_value, process_name=mock_value)
|
|
self.assertFalse(looks_like_terminal(window))
|
|
|
|
|
|
class TestGetFocusedWindow(unittest.TestCase):
|
|
"""Probe helpers should fail closed and parse compositor JSON."""
|
|
|
|
def test_wayland_hyprland_active_window(self):
|
|
payload = {"class": "kitty", "title": "zsh", "pid": 4242}
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "hyprctl" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=json.dumps(payload))
|
|
with patch(
|
|
"vocalinux.text_injection.focused_window.open",
|
|
mock_open(read_data="kitty\n"),
|
|
):
|
|
window = get_focused_window()
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.wm_class, "kitty")
|
|
self.assertEqual(window.process_name, "kitty")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_wayland_niri_focused_window(self):
|
|
payload = {"app_id": "Alacritty", "title": "nvim", "pid": 99}
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-1"}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "niri" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=json.dumps(payload))
|
|
window = get_focused_window()
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.app_id, "Alacritty")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_wayland_sway_walks_tree_for_focused_node(self):
|
|
tree = {
|
|
"nodes": [
|
|
{
|
|
"app_id": "firefox",
|
|
"focused": False,
|
|
"nodes": [
|
|
{
|
|
"app_id": "foot",
|
|
"name": "zsh",
|
|
"focused": True,
|
|
"floating_nodes": [],
|
|
"nodes": [],
|
|
}
|
|
],
|
|
"floating_nodes": [],
|
|
}
|
|
],
|
|
"floating_nodes": [],
|
|
}
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "swaymsg" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout=json.dumps(tree))
|
|
window = get_focused_window()
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.app_id, "foot")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_x11_uses_xdotool_class(self):
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": ""}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "xdotool" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
|
|
def _run(cmd, **_kwargs):
|
|
mapping = {
|
|
("xdotool", "getactivewindow"): "12345",
|
|
("xdotool", "getwindowclassname", "12345"): "konsole",
|
|
("xdotool", "getwindowname", "12345"): "zsh",
|
|
("xdotool", "getwindowpid", "12345"): "77",
|
|
}
|
|
return MagicMock(returncode=0, stdout=mapping.get(tuple(cmd), ""))
|
|
|
|
mock_run.side_effect = _run
|
|
with patch(
|
|
"vocalinux.text_injection.focused_window.open",
|
|
mock_open(read_data="konsole\n"),
|
|
):
|
|
window = get_focused_window()
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.wm_class, "konsole")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_x11_reads_wm_class_with_xprop_never_xdotool(self):
|
|
"""xdotool getwindowclassname aborts on class-less windows; use xprop."""
|
|
commands: list[tuple[str, ...]] = []
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": ""}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/" + cmd if cmd in {"xdotool", "xprop"} else None
|
|
)
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
|
|
def _run(cmd, **_kwargs):
|
|
commands.append(tuple(cmd))
|
|
mapping = {
|
|
("xdotool", "getactivewindow"): "12345",
|
|
("xdotool", "getwindowname", "12345"): "zsh",
|
|
("xdotool", "getwindowpid", "12345"): "77",
|
|
("xprop", "-id", "12345", "WM_CLASS"): (
|
|
'WM_CLASS(STRING) = "konsole", "konsole"'
|
|
),
|
|
}
|
|
return MagicMock(returncode=0, stdout=mapping.get(tuple(cmd), ""))
|
|
|
|
mock_run.side_effect = _run
|
|
with patch(
|
|
"vocalinux.text_injection.focused_window.open",
|
|
mock_open(read_data="konsole\n"),
|
|
):
|
|
window = get_focused_window()
|
|
self.assertNotIn("getwindowclassname", [arg for cmd in commands for arg in cmd])
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.wm_class, "konsole konsole")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_x11_placeholder_classname_abort_is_contained(self):
|
|
"""Without xprop, the placeholder still probes classname; abort is swallowed."""
|
|
commands: list[tuple[str, ...]] = []
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": ""}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "xdotool" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
|
|
def _run(cmd, **_kwargs):
|
|
commands.append(tuple(cmd))
|
|
if tuple(cmd) == ("xdotool", "getactivewindow"):
|
|
return MagicMock(returncode=0, stdout="2097152")
|
|
if tuple(cmd) == ("xdotool", "getwindowname", "2097152"):
|
|
return MagicMock(returncode=0, stdout="")
|
|
# getwindowpid / getwindowclassname fail: no pid, no class.
|
|
# Classname abort is contained by _run_text (non-zero / OSError).
|
|
return MagicMock(returncode=1, stdout="")
|
|
|
|
mock_run.side_effect = _run
|
|
window = get_focused_window()
|
|
self.assertIn("getwindowclassname", [arg for cmd in commands for arg in cmd])
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.wm_class, "")
|
|
self.assertFalse(looks_like_terminal(window))
|
|
|
|
def test_x11_class_only_client_uses_xdotool_when_xprop_missing(self):
|
|
"""A real X11 terminal with WM_CLASS but no title/pid must still classify."""
|
|
commands: list[tuple[str, ...]] = []
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": ""}, clear=False):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which") as mock_which:
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/" + cmd if cmd == "xdotool" else None
|
|
with patch("vocalinux.text_injection.focused_window.subprocess.run") as mock_run:
|
|
|
|
def _run(cmd, **_kwargs):
|
|
commands.append(tuple(cmd))
|
|
if tuple(cmd) == ("xdotool", "getactivewindow"):
|
|
return MagicMock(returncode=0, stdout="12345")
|
|
if tuple(cmd) == ("xdotool", "getwindowname", "12345"):
|
|
return MagicMock(returncode=0, stdout="")
|
|
if tuple(cmd) == ("xdotool", "getwindowclassname", "12345"):
|
|
return MagicMock(returncode=0, stdout="konsole")
|
|
# getwindowpid fails: some clients expose class but not pid.
|
|
return MagicMock(returncode=1, stdout="")
|
|
|
|
mock_run.side_effect = _run
|
|
window = get_focused_window()
|
|
self.assertIn("getwindowclassname", [arg for cmd in commands for arg in cmd])
|
|
self.assertIsNotNone(window)
|
|
assert window is not None
|
|
self.assertEqual(window.wm_class, "konsole")
|
|
self.assertTrue(looks_like_terminal(window))
|
|
|
|
def test_probe_failure_returns_none(self):
|
|
with patch("vocalinux.text_injection.focused_window.shutil.which", return_value=None):
|
|
self.assertIsNone(get_focused_window())
|
|
|
|
def test_is_focused_window_terminal_false_when_unknown(self):
|
|
with patch(
|
|
"vocalinux.text_injection.focused_window.get_focused_window",
|
|
return_value=None,
|
|
):
|
|
self.assertFalse(is_focused_window_terminal())
|