From 0e9e9879906e3817f13cac1178b8d8e02e3cd6ee Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 5 Jul 2026 21:38:38 +0200 Subject: [PATCH 1/4] feat(flatpak): add Qt clipboard fallback and disable hotkeys/autopaste in sandbox --- app/blitztext_linux.py | 2 +- app/hotkey_service.py | 3 +++ app/paste_service.py | 60 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index e806aa8..4f24c43 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -92,7 +92,7 @@ def _infer_wayland_display() -> Optional[str]: def _is_hotkey_device_access_error(err_msg: str) -> bool: """Return True for hotkey startup errors that can fall back to GUI/tray.""" text = err_msg.casefold() - return any(marker in text for marker in ("tastatur", "input", "evdev")) + return any(marker in text for marker in ("tastatur", "input", "evdev", "flatpak", "/dev/input", "/dev/uinput")) def _require_display_environment() -> None: diff --git a/app/hotkey_service.py b/app/hotkey_service.py index e60045b..a83ec4b 100644 --- a/app/hotkey_service.py +++ b/app/hotkey_service.py @@ -426,6 +426,9 @@ def _group_names() -> Set[str]: def _build_missing_keyboard_message(context: str = "") -> str: + if os.path.exists("/.flatpak-info"): + return "Globale Hotkeys sind im Flatpak nicht verfügbar" + suffix = f" {context}" if context else "" hints = [] if not os.path.exists("/dev/uinput"): diff --git a/app/paste_service.py b/app/paste_service.py index 87e1b04..87f0a2a 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -136,6 +136,10 @@ def paste(self, text: str, force_autopaste: Optional[bool] = None) -> None: return do_autopaste = self.autopaste if force_autopaste is None else bool(force_autopaste) + if do_autopaste and shutil.which("ydotool") is None: + logger.info("ydotool fehlt, Auto-Paste wird deaktiviert.") + do_autopaste = False + logger.info("Paste request: %d chars, autopaste=%s", len(text), do_autopaste) previous_clipboard = self._read_clipboard() if do_autopaste else None self._copy_to_clipboard(text) @@ -163,6 +167,9 @@ def _copy_to_clipboard(self, text: str) -> None: if _has_x11_clipboard(): self._xclip_copy(text) return + if _has_qt_clipboard(): + self._qt_copy(text) + return raise PasteServiceError( "Kein nutzbares Clipboard-Backend gefunden. Installieren: sudo apt install wl-clipboard xclip" ) @@ -174,6 +181,8 @@ def _read_clipboard(self) -> Optional[str]: elif _has_x11_clipboard(): command = ["xclip", "-selection", "clipboard", "-o"] timeout = _XCLIP_PASTE_TIMEOUT + elif _has_qt_clipboard(): + return self._qt_read() else: return None try: @@ -341,6 +350,44 @@ def _ydotool_paste(self) -> bool: logger.info("Auto-paste key injection completed with %s.", shortcut) return not wayland_unknown + def _qt_copy(self, text: str) -> None: + try: + from PyQt6.QtWidgets import QApplication + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QThread + app = QApplication.instance() + if not app: + raise PasteServiceError("Qt QApplication nicht verfuegbar.") + cb = app.clipboard() + if not cb: + raise PasteServiceError("Qt Clipboard nicht verfuegbar.") + + if QThread.currentThread() == app.thread(): + cb.setText(text) + else: + QMetaObject.invokeMethod(cb, "setText", Qt.ConnectionType.BlockingQueuedConnection, Q_ARG(str, text)) + logger.info("Clipboard write completed via Qt (%d chars).", len(text)) + except ImportError as exc: + raise PasteServiceError("PyQt6 Import fehlgeschlagen.") from exc + + def _qt_read(self) -> Optional[str]: + try: + from PyQt6.QtWidgets import QApplication + from PyQt6.QtCore import QThread + app = QApplication.instance() + if not app: + return None + cb = app.clipboard() + if not cb: + return None + + if QThread.currentThread() == app.thread(): + return cb.text() + else: + return None + except Exception as exc: + logger.debug("Qt clipboard read failed: %s", exc) + return None + def check_dependencies() -> list[str]: """Gibt eine Liste fehlender System-Abhaengigkeiten zurueck. @@ -348,9 +395,10 @@ def check_dependencies() -> list[str]: Verwendet von install.sh-Verifikation und Einstellungs-Dialog. """ missing = [] - if shutil.which("wl-copy") is None and shutil.which("xclip") is None: + is_flatpak = os.path.exists("/.flatpak-info") + if shutil.which("wl-copy") is None and shutil.which("xclip") is None and not is_flatpak: missing.append("wl-clipboard oder xclip") - if shutil.which("ydotool") is None: + if shutil.which("ydotool") is None and not is_flatpak: missing.append("ydotool") return missing @@ -369,6 +417,14 @@ def _has_x11_clipboard() -> bool: return bool(os.environ.get("DISPLAY") and shutil.which("xclip") is not None) +def _has_qt_clipboard() -> bool: + try: + from PyQt6.QtWidgets import QApplication + return QApplication.instance() is not None + except ImportError: + return False + + def _looks_like_missing_ydotoold(stderr: str) -> bool: lowered = stderr.lower() return any(marker in lowered for marker in _YDOTOOL_MISSING_DAEMON_MARKERS) From 4895de6c30b711a3227ba042636f42302663b99d Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 5 Jul 2026 23:16:05 +0200 Subject: [PATCH 2/4] test(flatpak): cover runtime fallback behavior --- tests/test_flatpak_fallback.py | 112 +++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_flatpak_fallback.py diff --git a/tests/test_flatpak_fallback.py b/tests/test_flatpak_fallback.py new file mode 100644 index 0000000..7968bf6 --- /dev/null +++ b/tests/test_flatpak_fallback.py @@ -0,0 +1,112 @@ +import pytest +from unittest.mock import patch, MagicMock + +from app.paste_service import PasteService, check_dependencies +from app.hotkey_service import _build_missing_keyboard_message + + +# 1. PasteService.check_dependencies() +def test_check_dependencies_flatpak(): + def mock_exists(path): + return path == "/.flatpak-info" + + def mock_which(cmd): + return None # wl-copy, xclip, ydotool als fehlend + + with patch("app.paste_service.os.path.exists", side_effect=mock_exists), \ + patch("app.paste_service.shutil.which", side_effect=mock_which): + missing = check_dependencies() + assert missing == [] # Keine harte fehlende Abhängigkeit im Flatpak + + +def test_check_dependencies_no_flatpak(): + def mock_exists(path): + return False + + def mock_which(cmd): + return None + + with patch("app.paste_service.os.path.exists", side_effect=mock_exists), \ + patch("app.paste_service.shutil.which", side_effect=mock_which): + missing = check_dependencies() + assert "wl-clipboard oder xclip" in missing + assert "ydotool" in missing + + +# 2. HotkeyService Flatpak-Hinweis +def test_hotkey_service_flatpak_hint(): + def mock_exists(path): + return path == "/.flatpak-info" + + with patch("app.hotkey_service.os.path.exists", side_effect=mock_exists): + msg = _build_missing_keyboard_message() + assert msg == "Globale Hotkeys sind im Flatpak nicht verfügbar" + + +# 3. PasteService Auto-Paste ohne ydotool +def test_paste_service_autopaste_without_ydotool(): + def mock_which(cmd): + if cmd == "ydotool": + return None + if cmd == "wl-copy": + return "/usr/bin/wl-copy" + return None + + svc = PasteService(autopaste=True) + + with patch("app.paste_service.shutil.which", side_effect=mock_which), \ + patch("app.paste_service._has_wayland_clipboard", return_value=True), \ + patch("app.paste_service.PasteService._wl_copy") as mock_wl_copy, \ + patch("app.paste_service.PasteService._ydotool_paste") as mock_ydotool: + + svc.paste("test text") + + # Clipboard copy continues + mock_wl_copy.assert_called_once_with("test text") + + # Auto-Paste is skipped, no ydotool called + mock_ydotool.assert_not_called() + + +# 4. PasteService Qt-Clipboard-Fallback +def test_paste_service_qt_clipboard_fallback(): + svc = PasteService(autopaste=False) + + with patch("app.paste_service._has_wayland_clipboard", return_value=False), \ + patch("app.paste_service._has_x11_clipboard", return_value=False), \ + patch("app.paste_service._has_qt_clipboard", return_value=True), \ + patch("app.paste_service.PasteService._qt_copy") as mock_qt_copy: + + svc.paste("fallback text") + mock_qt_copy.assert_called_once_with("fallback text") + + +def test_paste_service_qt_read_thread_safety(): + svc = PasteService(autopaste=False) + + app_mock = MagicMock() + app_mock.thread.return_value = "main_thread" + + cb_mock = MagicMock() + cb_mock.text.return_value = "clipboard content" + app_mock.clipboard.return_value = cb_mock + + def mock_current_thread_gui(): + return "main_thread" + + def mock_current_thread_other(): + return "other_thread" + + # Test 1: GUI Thread reads successfully + with patch("PyQt6.QtWidgets.QApplication.instance", return_value=app_mock), \ + patch("PyQt6.QtCore.QThread.currentThread", side_effect=mock_current_thread_gui): + + result = svc._qt_read() + assert result == "clipboard content" + + # Test 2: Non-GUI Thread returns None + with patch("PyQt6.QtWidgets.QApplication.instance", return_value=app_mock), \ + patch("PyQt6.QtCore.QThread.currentThread", side_effect=mock_current_thread_other): + + result = svc._qt_read() + assert result is None From 13e20bba66bdfeec4be1348081d431d45135eb74 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 6 Jul 2026 00:18:57 +0200 Subject: [PATCH 3/4] fix(flatpak): marshal Qt clipboard writes to GUI thread --- app/paste_service.py | 19 +++++++++++++++++-- tests/test_flatpak_fallback.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/paste_service.py b/app/paste_service.py index 87f0a2a..4dedc7f 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -353,7 +353,8 @@ def _ydotool_paste(self) -> bool: def _qt_copy(self, text: str) -> None: try: from PyQt6.QtWidgets import QApplication - from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QThread + from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QThread, QObject, pyqtSlot + app = QApplication.instance() if not app: raise PasteServiceError("Qt QApplication nicht verfuegbar.") @@ -364,7 +365,21 @@ def _qt_copy(self, text: str) -> None: if QThread.currentThread() == app.thread(): cb.setText(text) else: - QMetaObject.invokeMethod(cb, "setText", Qt.ConnectionType.BlockingQueuedConnection, Q_ARG(str, text)) + class QtClipboardHelper(QObject): + @pyqtSlot(str) + def set_text(self, t: str): + c = QApplication.instance().clipboard() + if c: + c.setText(t) + + helper = QtClipboardHelper() + helper.moveToThread(app.thread()) + QMetaObject.invokeMethod( + helper, + "set_text", + Qt.ConnectionType.BlockingQueuedConnection, + Q_ARG(str, text) + ) logger.info("Clipboard write completed via Qt (%d chars).", len(text)) except ImportError as exc: raise PasteServiceError("PyQt6 Import fehlgeschlagen.") from exc diff --git a/tests/test_flatpak_fallback.py b/tests/test_flatpak_fallback.py index 7968bf6..59b5dd5 100644 --- a/tests/test_flatpak_fallback.py +++ b/tests/test_flatpak_fallback.py @@ -110,3 +110,23 @@ def mock_current_thread_other(): result = svc._qt_read() assert result is None + + +def test_paste_service_qt_copy_real_thread(): + import threading + from PyQt6.QtWidgets import QApplication + app = QApplication.instance() or QApplication(["-platform", "offscreen"]) + + svc = PasteService(autopaste=False) + + def background_task(): + svc._qt_copy("threaded content test") + + t = threading.Thread(target=background_task) + t.start() + + while t.is_alive(): + app.processEvents() + t.join(0.01) + + assert app.clipboard().text() == "threaded content test" From 0f5ab822c16b6f89dc3140a91a6c7f77dc992915 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Mon, 6 Jul 2026 01:24:47 +0200 Subject: [PATCH 4/4] fix(flatpak): restore Qt clipboard fallback and decouple paste tests from host ydotool Addresses both Codex review findings on PR #47: - P2: _restore_clipboard now handles the Qt clipboard backend, mirroring _copy_to_clipboard's Wayland/X11/Qt order. Previously a Qt-only host wrote the new text via Qt but left the transcribed text in the clipboard after autopaste instead of restoring the original. - P1: TestPasteClipboardRestore autopaste cases now mock app.paste_service.shutil.which so the paste() ydotool guard no longer short-circuits on CI runners that lack the host ydotool binary. --- app/paste_service.py | 3 ++ tests/test_paste_service.py | 77 ++++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/app/paste_service.py b/app/paste_service.py index 4dedc7f..67e7814 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -222,6 +222,9 @@ def _restore_clipboard(self, previous: Optional[str]) -> None: if _has_x11_clipboard(): self._xclip_copy(previous) return + if _has_qt_clipboard(): + self._qt_copy(previous) + return raise PasteServiceError("Kein Clipboard-Backend fuer Restore verfuegbar.") except PasteServiceError: logger.warning("Originalzwischenablage konnte nicht wiederhergestellt werden") diff --git a/tests/test_paste_service.py b/tests/test_paste_service.py index 04a0966..4be23a4 100644 --- a/tests/test_paste_service.py +++ b/tests/test_paste_service.py @@ -219,6 +219,15 @@ def test_restores_x11_clipboard_with_empty_string(self): service._restore_clipboard("") xclip_copy_mock.assert_called_once_with("") + def test_restores_qt_clipboard_with_saved_text(self): + service = PasteService() + with patch("app.paste_service._has_wayland_clipboard", return_value=False): + with patch("app.paste_service._has_x11_clipboard", return_value=False): + with patch("app.paste_service._has_qt_clipboard", return_value=True): + with patch.object(service, "_qt_copy") as qt_copy_mock: + service._restore_clipboard("qt-restore") + qt_copy_mock.assert_called_once_with("qt-restore") + def test_logs_warning_on_restore_failure(self): service = PasteService() with patch("app.paste_service._has_wayland_clipboard", return_value=True): @@ -310,32 +319,34 @@ def test_paste_restores_clipboard_and_cleans_up_copyq_after_successful_autopaste service = PasteService(autopaste=True) calls: list[str] = [] - with patch.object(service, "_read_clipboard", side_effect=lambda: calls.append("read") or "vorher"): - with patch.object(service, "_copy_to_clipboard", side_effect=lambda text: calls.append(f"copy:{text}")): - with patch.object(service, "_ydotool_paste", side_effect=lambda: calls.append("ydotool") or True): - with patch("app.paste_service.time.sleep", side_effect=lambda _: calls.append("sleep")): - with patch.object( - service, - "_restore_clipboard", - side_effect=lambda value: calls.append(f"restore:{value}"), - ): + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch.object(service, "_read_clipboard", side_effect=lambda: calls.append("read") or "vorher"): + with patch.object(service, "_copy_to_clipboard", side_effect=lambda text: calls.append(f"copy:{text}")): + with patch.object(service, "_ydotool_paste", side_effect=lambda: calls.append("ydotool") or True): + with patch("app.paste_service.time.sleep", side_effect=lambda _: calls.append("sleep")): with patch.object( service, - "_cleanup_copyq", - side_effect=lambda value: calls.append(f"cleanup:{value}"), + "_restore_clipboard", + side_effect=lambda value: calls.append(f"restore:{value}"), ): - service.paste("neu", force_autopaste=True) + with patch.object( + service, + "_cleanup_copyq", + side_effect=lambda value: calls.append(f"cleanup:{value}"), + ): + service.paste("neu", force_autopaste=True) assert calls == ["read", "copy:neu", "ydotool", "sleep", "restore:vorher", "cleanup:neu"] def test_paste_does_not_restore_or_cleanup_when_autopaste_disabled(self): service = PasteService(autopaste=True) - with patch.object(service, "_read_clipboard") as read_mock: - with patch.object(service, "_copy_to_clipboard") as copy_mock: - with patch.object(service, "_ydotool_paste") as paste_mock: - with patch.object(service, "_restore_clipboard") as restore_mock: - with patch.object(service, "_cleanup_copyq") as cleanup_mock: - service.paste("neu", force_autopaste=False) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch.object(service, "_read_clipboard") as read_mock: + with patch.object(service, "_copy_to_clipboard") as copy_mock: + with patch.object(service, "_ydotool_paste") as paste_mock: + with patch.object(service, "_restore_clipboard") as restore_mock: + with patch.object(service, "_cleanup_copyq") as cleanup_mock: + service.paste("neu", force_autopaste=False) read_mock.assert_not_called() copy_mock.assert_called_once_with("neu") paste_mock.assert_not_called() @@ -344,13 +355,14 @@ def test_paste_does_not_restore_or_cleanup_when_autopaste_disabled(self): def test_paste_does_not_restore_or_cleanup_when_ydotool_did_not_paste(self): service = PasteService(autopaste=True) - with patch.object(service, "_read_clipboard", return_value="vorher") as read_mock: - with patch.object(service, "_copy_to_clipboard") as copy_mock: - with patch.object(service, "_ydotool_paste", return_value=False) as paste_mock: - with patch("app.paste_service.time.sleep") as sleep_mock: - with patch.object(service, "_restore_clipboard") as restore_mock: - with patch.object(service, "_cleanup_copyq") as cleanup_mock: - service.paste("neu", force_autopaste=True) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch.object(service, "_read_clipboard", return_value="vorher") as read_mock: + with patch.object(service, "_copy_to_clipboard") as copy_mock: + with patch.object(service, "_ydotool_paste", return_value=False) as paste_mock: + with patch("app.paste_service.time.sleep") as sleep_mock: + with patch.object(service, "_restore_clipboard") as restore_mock: + with patch.object(service, "_cleanup_copyq") as cleanup_mock: + service.paste("neu", force_autopaste=True) read_mock.assert_called_once_with() copy_mock.assert_called_once_with("neu") paste_mock.assert_called_once_with() @@ -360,13 +372,14 @@ def test_paste_does_not_restore_or_cleanup_when_ydotool_did_not_paste(self): def test_paste_restores_empty_previous_clipboard_and_cleans_up_copyq(self): service = PasteService(autopaste=True) - with patch.object(service, "_read_clipboard", return_value="") as read_mock: - with patch.object(service, "_copy_to_clipboard") as copy_mock: - with patch.object(service, "_ydotool_paste", return_value=True) as paste_mock: - with patch("app.paste_service.time.sleep") as sleep_mock: - with patch.object(service, "_restore_clipboard") as restore_mock: - with patch.object(service, "_cleanup_copyq") as cleanup_mock: - service.paste("neu", force_autopaste=True) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch.object(service, "_read_clipboard", return_value="") as read_mock: + with patch.object(service, "_copy_to_clipboard") as copy_mock: + with patch.object(service, "_ydotool_paste", return_value=True) as paste_mock: + with patch("app.paste_service.time.sleep") as sleep_mock: + with patch.object(service, "_restore_clipboard") as restore_mock: + with patch.object(service, "_cleanup_copyq") as cleanup_mock: + service.paste("neu", force_autopaste=True) read_mock.assert_called_once_with() copy_mock.assert_called_once_with("neu") paste_mock.assert_called_once_with()