diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index e9adcfe..14c653e 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -108,6 +108,7 @@ cthulhu_python_sources = files([ 'translation_manager.py', 'tutorialgenerator.py', 'typing_echo_presenter.py', + 'wnck_support.py', 'where_am_i_presenter.py', ]) diff --git a/src/cthulhu/plugins/OCR/plugin.py b/src/cthulhu/plugins/OCR/plugin.py index a3927ee..996e6dd 100644 --- a/src/cthulhu/plugins/OCR/plugin.py +++ b/src/cthulhu/plugins/OCR/plugin.py @@ -27,6 +27,7 @@ from gi.repository import Atspi from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug from cthulhu import settings_manager +from cthulhu.wnck_support import load_wnck # Note: Removed complex beep system - simple announcements work perfectly! @@ -68,17 +69,18 @@ try: except ImportError: WEBCOLORS_AVAILABLE = False -# GTK/GDK/Wnck +# GTK/GDK try: - import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") - gi.require_version("Wnck", "3.0") - from gi.repository import Gtk, Gdk, Wnck + from gi.repository import Gtk, Gdk GTK_AVAILABLE = True -except ImportError: +except Exception: GTK_AVAILABLE = False +Wnck = load_wnck() +WNCK_AVAILABLE = Wnck is not None + logger = logging.getLogger(__name__) class OCRDesktop(Plugin): @@ -151,7 +153,7 @@ class OCRDesktop(Plugin): if not PYTESSERACT_AVAILABLE: missing_deps.append("python-pytesseract") if not GTK_AVAILABLE: - missing_deps.append("GTK3/GDK/Wnck") + missing_deps.append("GTK3/GDK") if missing_deps: debug.printMessage(debug.LEVEL_INFO, @@ -359,6 +361,10 @@ class OCRDesktop(Plugin): if not GTK_AVAILABLE: debug.printMessage(debug.LEVEL_INFO, "OCRDesktop: GTK not available for screenshots", True) return False + + if not WNCK_AVAILABLE: + debug.printMessage(debug.LEVEL_INFO, "OCRDesktop: Wnck not available for active window screenshots", True) + return False try: time.sleep(0.3) # Brief delay @@ -869,4 +875,4 @@ class OCRDesktop(Plugin): return True except Exception as e: debug.printMessage(debug.LEVEL_INFO, f"OCRDesktop: Error copying to clipboard: {e}", True) - return False \ No newline at end of file + return False diff --git a/src/cthulhu/wnck_support.py b/src/cthulhu/wnck_support.py new file mode 100644 index 0000000..107c60b --- /dev/null +++ b/src/cthulhu/wnck_support.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""Helpers for loading Wnck only when the session can support it.""" + +from __future__ import annotations + +import importlib +import os +from types import ModuleType + + +def get_session_type() -> str: + sessionType = (os.environ.get("XDG_SESSION_TYPE") or "").strip().lower() + if sessionType: + return sessionType + if os.environ.get("WAYLAND_DISPLAY"): + return "wayland" + if os.environ.get("DISPLAY"): + return "x11" + return "unknown" + + +def can_use_wnck(session_type: str | None = None) -> bool: + return (session_type or get_session_type()).strip().lower() == "x11" + + +def load_wnck(session_type: str | None = None) -> ModuleType | None: + if not can_use_wnck(session_type): + return None + + gi = importlib.import_module("gi") + gi.require_version("Wnck", "3.0") + return importlib.import_module("gi.repository.Wnck") diff --git a/tests/test_wnck_session_regressions.py b/tests/test_wnck_session_regressions.py new file mode 100644 index 0000000..1a9cad5 --- /dev/null +++ b/tests/test_wnck_session_regressions.py @@ -0,0 +1,66 @@ +import sys +import unittest +from pathlib import Path +from unittest import mock + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cthulhu import wnck_support +from cthulhu.plugins.OCR import plugin as ocr_plugin + + +class WnckSupportTests(unittest.TestCase): + def test_load_wnck_skips_import_when_session_is_wayland(self): + with mock.patch.object( + wnck_support.importlib, + "import_module", + side_effect=AssertionError("import_module should not be called"), + ): + self.assertIsNone(wnck_support.load_wnck(session_type="wayland")) + + def test_load_wnck_imports_wnck_when_session_is_x11(self): + fakeGi = mock.Mock() + fakeWnck = object() + + def importModule(name): + if name == "gi": + return fakeGi + if name == "gi.repository.Wnck": + return fakeWnck + raise AssertionError(f"Unexpected import: {name}") + + with mock.patch.object(wnck_support.importlib, "import_module", side_effect=importModule): + self.assertIs(wnck_support.load_wnck(session_type="x11"), fakeWnck) + + fakeGi.require_version.assert_called_once_with("Wnck", "3.0") + + +class OCRWnckHandlingTests(unittest.TestCase): + def test_check_dependencies_does_not_fail_when_wnck_is_unavailable(self): + testPlugin = ocr_plugin.OCRDesktop.__new__(ocr_plugin.OCRDesktop) + + with ( + mock.patch.object(ocr_plugin, "PIL_AVAILABLE", True), + mock.patch.object(ocr_plugin, "PYTESSERACT_AVAILABLE", True), + mock.patch.object(ocr_plugin, "GTK_AVAILABLE", True), + mock.patch.object(ocr_plugin, "WNCK_AVAILABLE", False), + ): + self.assertTrue(testPlugin._checkDependencies()) + + def test_screen_shot_window_returns_false_when_wnck_is_unavailable(self): + testPlugin = ocr_plugin.OCRDesktop.__new__(ocr_plugin.OCRDesktop) + + with ( + mock.patch.object(ocr_plugin, "GTK_AVAILABLE", True), + mock.patch.object(ocr_plugin, "WNCK_AVAILABLE", False), + ): + self.assertFalse(testPlugin._screenShotWindow()) + + +if __name__ == "__main__": + unittest.main()