diff --git a/screen_locker/install_systemd.sh b/screen_locker/install_systemd.sh index 3fd3f83..ff9a7db 100755 --- a/screen_locker/install_systemd.sh +++ b/screen_locker/install_systemd.sh @@ -4,10 +4,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py" SERVICE_FILE="$SCRIPT_DIR/workout-locker.service" -TIMER_FILE="$SCRIPT_DIR/workout-locker.timer" USER_SERVICE_DIR="$HOME/.config/systemd/user" SERVICE_NAME="workout-locker.service" -TIMER_NAME="workout-locker.timer" # Check if service is already installed if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then @@ -26,9 +24,14 @@ fi # Create user systemd directory if it doesn't exist mkdir -p "$USER_SERVICE_DIR" -# Copy service and timer files to user systemd directory +# Remove old timer if it was previously installed +if systemctl --user is-active "workout-locker.timer" &>/dev/null; then + systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true +fi +rm -f "$USER_SERVICE_DIR/workout-locker.timer" + +# Copy service file to user systemd directory cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME" -cp "$TIMER_FILE" "$USER_SERVICE_DIR/$TIMER_NAME" # Update paths in the service file to use absolute paths REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" @@ -39,18 +42,16 @@ sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg. # Reload systemd daemon systemctl --user daemon-reload -# Enable the service to start on login and the timer for periodic checks +# Enable the service to start on login (one-shot, no periodic timer) systemctl --user enable "$SERVICE_NAME" -systemctl --user enable --now "$TIMER_NAME" echo "✓ Workout locker service installed" echo "✓ Service will start automatically on next login" echo "" echo "To start now: systemctl --user start workout-locker" echo "To check status: systemctl --user status workout-locker" -echo "To check timer: systemctl --user list-timers workout-locker.timer" echo "To stop: systemctl --user stop workout-locker" -echo "To disable autostart: systemctl --user disable workout-locker workout-locker.timer" +echo "To disable autostart: systemctl --user disable workout-locker" # Check autostart installation status echo "" diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 43f5a95..462cc2c 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -50,6 +50,21 @@ __all__ = [ _logger = logging.getLogger(__name__) +def _assert_not_under_pytest() -> None: + """Raise if the screen locker is being created inside a pytest run. + + Defence-in-depth: prevents a real fullscreen Tk window from locking + the user's screen when tests forget to mock ``tk.Tk``. + The check is cheap (one dict lookup) and only fires during testing. + """ + if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter": + msg = ( + "SAFETY: ScreenLocker.__init__ called under pytest with " + "real tkinter — tk.Tk is not mocked" + ) + raise RuntimeError(msg) + + class ScreenLocker( ShutdownMixin, PhoneVerificationMixin, @@ -64,6 +79,7 @@ class ScreenLocker( verify_only: bool = False, ) -> None: """Initialize screen locker with optional demo mode.""" + _assert_not_under_pytest() script_dir = Path(__file__).resolve().parent self.log_file = script_dir / "workout_log.json" self.verify_only = verify_only diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index c93d70c..7c54947 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -1,4 +1,12 @@ -"""Shared fixtures and helpers for screen_locker tests.""" +"""Shared fixtures and helpers for screen_locker tests. + +Safety: + ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` + module reference inside ``screen_lock`` with a MagicMock and stubs + ``sys.exit``. This makes it physically impossible for any test to + create a real Tk root window, go fullscreen, or grab input — even if + the test forgets to request the explicit ``mock_tk`` fixture. +""" from __future__ import annotations @@ -12,7 +20,40 @@ import pytest from python_pkg.screen_locker.screen_lock import ScreenLocker if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterator + + +def _make_mock_tk() -> MagicMock: + """Build a MagicMock that stands in for the ``tkinter`` module.""" + mock = MagicMock() + mock_root = MagicMock() + mock_root.winfo_screenwidth.return_value = 1920 + mock_root.winfo_screenheight.return_value = 1080 + mock.Tk.return_value = mock_root + + mock_frame = MagicMock() + mock_frame.winfo_children.return_value = [] + mock.Frame.return_value = mock_frame + + # Keep real TclError so ``except tk.TclError`` still works. + mock.TclError = tk.TclError + return mock + + +@pytest.fixture(autouse=True) +def _block_real_tk_and_exit() -> Iterator[None]: + """Replace the whole ``tk`` module and ``sys.exit`` for every test. + + Patching the entire module (not just ``tk.Tk``) ensures that + **nothing** in tkinter can touch the real display server. + """ + mock = _make_mock_tk() + + with ( + patch("python_pkg.screen_locker.screen_lock.tk", mock), + patch("python_pkg.screen_locker.screen_lock.sys.exit"), + ): + yield @pytest.fixture diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index a781e69..c74e110 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -10,12 +10,29 @@ from unittest.mock import MagicMock, patch import pytest +from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path +class TestAssertNotUnderPytest: + """Tests for the _assert_not_under_pytest runtime guard.""" + + def test_raises_when_tk_is_real(self) -> None: + """Guard fires if tk.Tk is the real tkinter class under pytest.""" + with ( + patch("python_pkg.screen_locker.screen_lock.tk", tk), + pytest.raises(RuntimeError, match="SAFETY"), + ): + _assert_not_under_pytest() + + def test_silent_when_tk_is_mocked(self) -> None: + """Guard stays silent when tk is already mocked (normal test run).""" + _assert_not_under_pytest() + + class TestScreenLockerInit: """Tests for ScreenLocker initialization.""" diff --git a/screen_locker/workout-locker.timer b/screen_locker/workout-locker.timer deleted file mode 100644 index 4a01809..0000000 --- a/screen_locker/workout-locker.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Periodically check if workout was done today - -[Timer] -OnBootSec=5s -OnUnitActiveSec=15min -Persistent=true - -[Install] -WantedBy=timers.target