Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions app/paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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],
Expand All @@ -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]:
Expand Down
191 changes: 191 additions & 0 deletions tests/test_install_script.py
Original file line number Diff line number Diff line change
@@ -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)],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run install script tests as a non-root user

When pytest is run as root, which is common in Docker-based CI/dev containers, this launches install.sh with EUID=0. The script exits at its root guard before resolving BLITZTEXT_NO_HOTKEY or reaching the systemd setup, so all four install tests fail and only exercise the root check instead of the intended paths. Please run this subprocess under an unprivileged user or skip/xfail these install-script cases in root environments.

Useful? React with 👍 / 👎.

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")
20 changes: 18 additions & 2 deletions tests/test_paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"), \
Expand Down
Loading