diff --git a/app/paste_service.py b/app/paste_service.py index 25e7d2a..09df046 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -34,14 +34,15 @@ # Strg+Shift+V fuer Terminals (dort ist Strg+V meist "nichts tun" oder Copy). # KEY_LEFTSHIFT=42 zusaetzlich zu KEY_LEFTCTRL=29, KEY_V=47. _CTRL_SHIFT_V_KEYCODES = ["29:1", "42:1", "47:1", "47:0", "42:0", "29:0"] -# Bekannte Terminal-Emulator-Fensterklassen (lowercase-Vergleich). X11-only -- -# unter Wayland gibt es ohne Compositor-spezifische Erweiterung keine -# generische "aktives Fenster"-Abfrage wie xdotool sie fuer X11 bietet. +# Bekannte Terminal-Emulator-Fensterklassen (lowercase-Vergleich). +# Unter Wayland kann xdotool die aktive native Fensterklasse oft nicht erkennen; +# dann nutzt Auto-Paste einen sicheren Terminal-Fallback und laesst das Clipboard stehen. _KNOWN_TERMINAL_WINDOW_CLASSES = frozenset( { "gnome-terminal-server", "xterm", "konsole", + "org.kde.konsole", "kitty", "alacritty", "kgx", @@ -76,8 +77,7 @@ def _detect_active_window_class() -> Optional[str]: - # X11-only: xdotool benoetigt DISPLAY und kann unter Wayland ohne - # Compositor-spezifische Erweiterungen das aktive Fenster nicht generisch abfragen. + """Return the active X11/XWayland window class if xdotool can see it.""" if not os.environ.get("DISPLAY"): return None if shutil.which("xdotool") is None: @@ -138,6 +138,7 @@ 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) + 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) @@ -258,7 +259,7 @@ def _wl_copy(self, text: str) -> None: stderr=subprocess.DEVNULL, timeout=_WL_COPY_TIMEOUT, ) - logger.debug("wl-copy: %d Zeichen ins Clipboard geschrieben.", len(text)) + logger.info("Clipboard write completed via wl-copy (%d chars).", len(text)) except subprocess.TimeoutExpired as exc: raise PasteServiceError( f"wl-copy reagierte nicht innerhalb von {_WL_COPY_TIMEOUT:.0f}s" @@ -276,7 +277,7 @@ def _xclip_copy(self, text: str) -> None: stderr=subprocess.DEVNULL, timeout=_WL_COPY_TIMEOUT, ) - logger.debug("xclip: %d Zeichen ins Clipboard geschrieben.", len(text)) + logger.info("Clipboard write completed via xclip (%d chars).", len(text)) except subprocess.TimeoutExpired as exc: raise PasteServiceError( f"xclip reagierte nicht innerhalb von {_WL_COPY_TIMEOUT:.0f}s" @@ -293,11 +294,24 @@ def _ydotool_paste(self) -> bool: return False # Kurze Pause, damit der neue Clipboard-Inhalt vor Ctrl+V sicher anliegt time.sleep(_PASTE_DELAY) - keycodes = _CTRL_SHIFT_V_KEYCODES if _is_terminal_active() else _CTRL_V_KEYCODES - if keycodes is _CTRL_SHIFT_V_KEYCODES: - logger.debug("Aktives Fenster ist ein Terminal -- sende Ctrl+Shift+V via ydotool.") + window_class = _detect_active_window_class() + wayland_unknown = window_class is None and bool(os.environ.get("WAYLAND_DISPLAY")) + is_terminal = bool(window_class and window_class in _KNOWN_TERMINAL_WINDOW_CLASSES) + if wayland_unknown: + keycodes = _CTRL_SHIFT_V_KEYCODES + shortcut = "Ctrl+Shift+V" + logger.warning( + "Active window could not be detected under Wayland -- sending Ctrl+Shift+V " + "fallback and keeping the new text in the clipboard." + ) else: - logger.debug("Aktives Fenster ist kein Terminal -- sende Ctrl+V via ydotool.") + keycodes = _CTRL_SHIFT_V_KEYCODES if is_terminal else _CTRL_V_KEYCODES + shortcut = "Ctrl+Shift+V" if is_terminal else "Ctrl+V" + logger.info( + "Auto-paste sending %s via ydotool (active_window_class=%s).", + shortcut, + window_class or "unknown", + ) try: result = subprocess.run( ["ydotool", "key", "--key-delay", str(self.key_delay_ms), *keycodes], @@ -324,9 +338,10 @@ def _ydotool_paste(self) -> bool: "(Text liegt bereits im Clipboard)." ) return False - logger.warning("ydotool Ctrl+V fehlgeschlagen (rc=%d): %s", result.returncode, stderr) + logger.warning("ydotool %s fehlgeschlagen (rc=%d): %s", shortcut, result.returncode, stderr) return False - return True + logger.info("Auto-paste key injection completed with %s.", shortcut) + return not wayland_unknown def check_dependencies() -> list[str]: diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..8696ba3 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +# NOTE: tests/conftest.py's autouse `_block_real_notifications` fixture patches +# `subprocess.run` process-wide (it patches the shared `subprocess` module via +# `app.notify.subprocess.run`). Resolving real tool paths must therefore use +# `shutil.which`, not `subprocess.run(["which", ...])`. +BASH = shutil.which("bash") + + +def _write_stub(path: Path, body: str) -> None: + path.write_text(f"#!{BASH}\n{body}\n", encoding="utf-8") + path.chmod(0o755) + + +def _prepare_install_script(tmp_path: Path, repo_root: Path) -> tuple[Path, Path]: + """Builds a fake repo + PATH so install.sh can run to completion without + touching real system packages, sudo, or systemd state. + + Strategy: shadow only the specific external commands whose real-world + invocation would be destructive or non-deterministic (dpkg-query, + systemctl, pgrep, ydotool, sudo, pip) by prepending a stub bin directory + to the inherited PATH. Everything else (grep, sed, id, python3, ...) + keeps using the real inherited PATH, since those calls are read-only and + their real behavior is safe and deterministic across Ubuntu/Debian hosts. + """ + repo_dir = tmp_path / "repo" + scripts_dir = repo_dir / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + script_path = scripts_dir / "install.sh" + script_path.write_text((repo_root / "scripts" / "install.sh").read_text(encoding="utf-8"), encoding="utf-8") + script_path.chmod(0o755) + + systemd_dir = repo_dir / "systemd" + systemd_dir.mkdir(parents=True, exist_ok=True) + (systemd_dir / "blitztext-linux.service").write_text( + (repo_root / "systemd" / "blitztext-linux.service").read_text(encoding="utf-8"), encoding="utf-8" + ) + + venv_bin = repo_dir / ".venv" / "bin" + venv_bin.mkdir(parents=True, exist_ok=True) + _write_stub(venv_bin / "pip", "exit 0") + + stub_bin = tmp_path / "bin" + stub_bin.mkdir(parents=True, exist_ok=True) + + _write_stub(stub_bin / "dpkg-query", 'printf "install ok installed\\n"\nexit 0') + _write_stub(stub_bin / "ydotool", "exit 0") + _write_stub(stub_bin / "pgrep", "exit 1") + _write_stub(stub_bin / "sudo", 'exec "$@"') + _write_stub( + stub_bin / "systemctl", + r""" +args="$*" +case "$args" in + *"ydotool.service"*) + exit 1 + ;; + *"is-active --quiet blitztext-linux"*) + [[ "${BT_TEST_APP_ACTIVE:-0}" == "1" ]] && exit 0 || exit 1 + ;; + *"is-enabled --quiet blitztext-linux"*) + [[ "${BT_TEST_APP_ENABLED:-0}" == "1" ]] && exit 0 || exit 1 + ;; + *"daemon-reload"*) + exit 0 + ;; + *"user enable blitztext-linux"*) + exit 0 + ;; + *) + exit 1 + ;; +esac +""".strip("\n"), + ) + + return script_path, stub_bin + + +def _run_script( + script_path: Path, + home_dir: Path, + runtime_dir: Path, + stub_bin: Path, + *, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "HOME": str(home_dir), + "XDG_RUNTIME_DIR": str(runtime_dir), + "PATH": f"{stub_bin}{os.pathsep}{env['PATH']}", + } + ) + if extra_env: + env.update(extra_env) + proc = subprocess.Popen( + [BASH, str(script_path)], + cwd=str(script_path.parent), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr) + + +def test_install_dies_on_invalid_blitztext_no_hotkey_value(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_install_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script( + script_path, home_dir, runtime_dir, stub_bin, extra_env={"BLITZTEXT_NO_HOTKEY": "2"} + ) + + assert result.returncode == 1 + assert "BLITZTEXT_NO_HOTKEY muss 0 oder 1 sein" in result.stderr + + +def test_install_warns_before_daemon_reload_when_app_is_active(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_install_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script( + script_path, + home_dir, + runtime_dir, + stub_bin, + extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ACTIVE": "1"}, + ) + + assert result.returncode == 0, result.stderr + assert "blitztext-linux läuft gerade" in result.stdout + assert "daemon-reload kann die App unerwartet beenden" in result.stdout + assert "systemctl --user stop blitztext-linux" in result.stdout + + +def test_install_stays_quiet_before_daemon_reload_when_app_is_inactive(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_install_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script( + script_path, + home_dir, + runtime_dir, + stub_bin, + extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ACTIVE": "0"}, + ) + + assert result.returncode == 0, result.stderr + assert "blitztext-linux läuft gerade" not in result.stdout + assert "daemon-reload kann die App unerwartet beenden" not in result.stdout + + +def test_install_completes_and_enables_autostart_when_not_yet_enabled(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_install_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script( + script_path, + home_dir, + runtime_dir, + stub_bin, + extra_env={"BLITZTEXT_NO_HOTKEY": "1", "BT_TEST_APP_ENABLED": "0"}, + ) + + assert result.returncode == 0, result.stderr + assert "Installation abgeschlossen" in result.stdout + assert "blitztext-linux.service für Autostart aktiviert" in result.stdout + service_dst = home_dir / ".config" / "systemd" / "user" / "blitztext-linux.service" + assert service_dst.exists() + assert "%BLITZTEXT_DIR%" not in service_dst.read_text(encoding="utf-8") diff --git a/tests/test_paste_service.py b/tests/test_paste_service.py index 518baaf..04a0966 100644 --- a/tests/test_paste_service.py +++ b/tests/test_paste_service.py @@ -100,7 +100,7 @@ def test_sends_ctrl_shift_v_when_terminal_active(self): service = PasteService(autopaste=True, key_delay_ms=135) result = MagicMock(returncode=0, stderr=b"") with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): - with patch("app.paste_service._is_terminal_active", return_value=True): + with patch("app.paste_service._detect_active_window_class", return_value="konsole"): with patch("app.paste_service.time.sleep"): with patch("app.paste_service.subprocess.run", return_value=result) as run_mock: assert service._ydotool_paste() is True @@ -116,7 +116,7 @@ def test_sends_ctrl_v_when_non_terminal_active(self): service = PasteService(autopaste=True, key_delay_ms=246) result = MagicMock(returncode=0, stderr=b"") with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): - with patch("app.paste_service._is_terminal_active", return_value=False): + with patch("app.paste_service._detect_active_window_class", return_value="firefox"): with patch("app.paste_service.time.sleep"): with patch("app.paste_service.subprocess.run", return_value=result) as run_mock: assert service._ydotool_paste() is True @@ -128,6 +128,22 @@ def test_sends_ctrl_v_when_non_terminal_active(self): timeout=5.0, ) + def test_wayland_unknown_sends_terminal_fallback_and_keeps_clipboard(self): + service = PasteService(autopaste=True, key_delay_ms=80) + result = MagicMock(returncode=0, stderr=b"") + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True): + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch("app.paste_service._detect_active_window_class", return_value=None): + with patch("app.paste_service.time.sleep"): + with patch("app.paste_service.subprocess.run", return_value=result) as run_mock: + assert service._ydotool_paste() is False + run_mock.assert_called_once_with( + ["ydotool", "key", "--key-delay", "80", *_CTRL_SHIFT_V_KEYCODES], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=5.0, + ) class TestReadClipboard: def test_reads_wayland_clipboard_with_wl_paste(self): diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 3a6ea34..36f9311 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -63,7 +63,7 @@ def test_paste_passes_timeout_to_subprocess(self): def test_paste_uses_configured_key_delay_for_ydotool(self): svc = PasteService(autopaste=True, key_delay_ms=135) with patch("app.paste_service.shutil.which", return_value="/usr/bin/tool"), \ - patch("app.paste_service._is_terminal_active", return_value=False), \ + patch("app.paste_service._detect_active_window_class", return_value="firefox"), \ patch.object(PasteService, "_cleanup_copyq"), \ patch("app.paste_service.time.sleep"), \ patch("app.paste_service.subprocess.run") as run_mock: @@ -102,7 +102,7 @@ def test_force_autopaste_override_enables_ydotool(self): """force_autopaste=True überschreibt autopaste=False: Clipboard + ydotool.""" svc = PasteService(autopaste=False) with patch("app.paste_service.shutil.which", return_value="/usr/bin/tool"), \ - patch("app.paste_service._is_terminal_active", return_value=False), \ + patch("app.paste_service._detect_active_window_class", return_value="firefox"), \ patch.object(PasteService, "_cleanup_copyq"), \ patch.object(PasteService, "_read_clipboard", return_value="alter text"), \ patch("app.paste_service.time.sleep"), \ diff --git a/tests/test_verify_script.py b/tests/test_verify_script.py new file mode 100644 index 0000000..ef865c6 --- /dev/null +++ b/tests/test_verify_script.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +# NOTE: tests/conftest.py's autouse `_block_real_notifications` fixture patches +# `subprocess.run` process-wide (it patches the shared `subprocess` module via +# `app.notify.subprocess.run`). Resolving real tool paths must therefore use +# `shutil.which`, not `subprocess.run(["which", ...])`. +INFRA_TOOLS = ("grep", "whoami", "stat", "sort", "dirname", "sed") +BASH = shutil.which("bash") + + +def _symlink_real_tool(stub_bin: Path, name: str) -> None: + resolved = shutil.which(name) + if not resolved: + raise RuntimeError(f"required real tool not found on PATH: {name}") + (stub_bin / name).symlink_to(resolved) + + +def _write_stub(stub_bin: Path, name: str, body: str) -> None: + path = stub_bin / name + path.write_text(f"#!{BASH}\n{body}\n", encoding="utf-8") + path.chmod(0o755) + + +def _prepare_verify_script( + tmp_path: Path, + repo_root: Path, + *, + present_checked_tools: tuple[str, ...] = (), + fake_groups: str = "", +) -> tuple[Path, Path]: + """Copies verify.sh into an isolated fake repo and builds a deterministic PATH. + + Returns (script_path, stub_bin_dir). The isolated PATH shadows every + checked binary (parec/wl-copy/xclip/ydotool/ffmpeg/socat) so results do + not depend on what happens to be installed on the machine running the + tests. + """ + scripts_dir = tmp_path / "repo" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + script_path = scripts_dir / "verify.sh" + script_path.write_text((repo_root / "scripts" / "verify.sh").read_text(encoding="utf-8"), encoding="utf-8") + script_path.chmod(0o755) + + stub_bin = tmp_path / "bin" + stub_bin.mkdir(parents=True, exist_ok=True) + for tool in INFRA_TOOLS: + _symlink_real_tool(stub_bin, tool) + + for tool in present_checked_tools: + _write_stub(stub_bin, tool, "exit 0") + + _write_stub( + stub_bin, + "groups", + f'printf "%s\\n" "{fake_groups}"', + ) + _write_stub(stub_bin, "id", '[[ "$1" == "-Gn" ]] && printf "%s\\n" "" || echo 0') + _write_stub(stub_bin, "systemctl", "exit 1") + _write_stub(stub_bin, "pgrep", "exit 1") + + return script_path, stub_bin + + +def _run_script(script_path: Path, home_dir: Path, runtime_dir: Path, stub_bin: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "HOME": str(home_dir), + "XDG_RUNTIME_DIR": str(runtime_dir), + "PATH": str(stub_bin), + "WAYLAND_DISPLAY": "", + "DISPLAY": "", + } + ) + proc = subprocess.Popen( + [BASH, str(script_path)], + cwd=str(script_path.parent), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr) + + +def test_verify_fails_and_exits_1_when_required_tools_and_venv_are_missing(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root, present_checked_tools=()) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script(script_path, home_dir, runtime_dir, stub_bin) + + assert result.returncode == 1 + assert "[FAIL]" in result.stdout + assert ".venv/bin/python nicht gefunden" in result.stdout + + +def test_verify_config_permissions_pass_when_file_is_0600(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + config_file = home_dir / ".config" / "blitztext-linux" / "config.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text("{}", encoding="utf-8") + config_file.chmod(0o600) + + result = _run_script(script_path, home_dir, runtime_dir, stub_bin) + + assert "config.json vorhanden und korrekt geschützt (0600)" in result.stdout + + +def test_verify_config_permissions_warn_when_file_is_more_permissive(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + config_file = home_dir / ".config" / "blitztext-linux" / "config.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text("{}", encoding="utf-8") + config_file.chmod(0o644) + + result = _run_script(script_path, home_dir, runtime_dir, stub_bin) + + assert "Berechtigungen sind 644" in result.stdout + assert "chmod 600" in result.stdout + + +def test_verify_config_missing_reports_info_not_fail(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script(script_path, home_dir, runtime_dir, stub_bin) + + assert "config.json nicht gefunden" in result.stdout + assert "wird beim ersten Start automatisch erstellt" in result.stdout + + +def test_verify_missing_xdg_runtime_dir_is_reported_as_fail(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + + env = os.environ.copy() + env.update({"HOME": str(home_dir), "PATH": str(stub_bin), "WAYLAND_DISPLAY": "", "DISPLAY": ""}) + env.pop("XDG_RUNTIME_DIR", None) + proc = subprocess.Popen( + [BASH, str(script_path)], + cwd=str(script_path.parent), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, _ = proc.communicate() + + assert "XDG_RUNTIME_DIR nicht gesetzt" in stdout + assert proc.returncode == 1 + + +def test_verify_all_checked_binaries_present_removes_their_fail_lines(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + checked_tools = ("parec", "wl-copy", "xclip", "ydotool", "ffmpeg", "socat") + script_path, stub_bin = _prepare_verify_script(tmp_path, repo_root, present_checked_tools=checked_tools) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + + result = _run_script(script_path, home_dir, runtime_dir, stub_bin) + + for tool in checked_tools: + assert f"{tool} nicht gefunden" not in result.stdout + assert f"{tool} gefunden" in result.stdout