-
Notifications
You must be signed in to change notification settings - Fork 0
feat(flatpak): add runtime fallbacks for sandbox #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0e9e987
4895de6
13e20bb
0f5ab82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
TimInTech marked this conversation as resolved.
|
||
| 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: | ||
|
|
@@ -213,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") | ||
|
|
@@ -341,16 +353,70 @@ 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, QObject, pyqtSlot | ||
|
|
||
| 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: | ||
| 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 | ||
|
|
||
| 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 | ||
|
Comment on lines
+401
to
+404
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Qt fallback is the selected clipboard backend and paste is invoked from the transcription worker (see Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bewusst als akzeptierter Narrow Edge-Case / Folgepunkt behandelt — Thread absichtlich NICHT resolved. Der Fix in 13e20bb marshallt Qt-Clipboard-Writes auf den GUI-Thread; Qt-Reads aus dem Worker-Thread bleiben bewusst konservativ: Auf Standard-Wayland/X11-Hosts sowie bei GUI-Thread-Paste ( |
||
| 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. | ||
|
|
||
| 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 +435,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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| 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 | ||
|
|
||
|
|
||
| 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" |
Uh oh!
There was an error while loading. Please reload this page.