From 3a2e63af9cefea4fa02a5f7746dd5f72c2d22df7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 22 Jun 2026 07:33:49 +0200 Subject: [PATCH] Migrate wake_alarm to the shared gatelock backend Third and final leg of the diet_guard -> screen-locker -> wake_alarm gatelock extraction. WakeAlarm now composes gatelock.GateRoot + LockWindow(mode="soft") instead of driving tk.Tk() directly, and moves hardware teardown into on_close() so it runs on every exit path (including SIGTERM), closing the prior gap where killing the process left fans maxed. _state.py's HMAC import moves to gatelock.log_integrity, the last call site of python_pkg.shared.log_integrity, which is deleted. Manually verified on the real display: fullscreen overlay renders, xdotool-typed input reaches the dismiss-code Entry (mode="soft" keeps the existing typing-focus behavior), and SIGTERM exits within ~0.4s while restoring hardware state. Full repo suite: 949 passed, 100% branch coverage. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky --- wake_alarm/_alarm.py | 75 +++++++------ wake_alarm/_state.py | 3 +- wake_alarm/tests/test_alarm.py | 16 ++- wake_alarm/tests/test_alarm_part2.py | 70 ++++-------- wake_alarm/tests/test_alarm_part3.py | 30 ++++-- wake_alarm/tests/test_alarm_part4.py | 153 +++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 97 deletions(-) create mode 100644 wake_alarm/tests/test_alarm_part4.py diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index 0fb21e7..c74df62 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -9,6 +9,7 @@ workout-free day via HMAC-signed wake state. from __future__ import annotations import argparse +import contextlib from dataclasses import dataclass from datetime import datetime, timezone import logging @@ -17,6 +18,8 @@ import threading import time import tkinter as tk +from gatelock import GateRoot, LockConfig, LockWindow + from python_pkg.shared.logging_setup import configure_logging from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display from python_pkg.wake_alarm._audio import ( @@ -108,29 +111,16 @@ class WakeAlarm: self.demo_mode = demo_mode self.dismissed = False self._stop_beep = threading.Event() - self._beep_thread: threading.Thread | None = None self._alarm_start: float = time.monotonic() self._active = True - self.root = tk.Tk() + self.root = GateRoot() + self.root.on_callback_error = self.on_callback_error self.root.title("Wake Alarm" + (" [DEMO]" if demo_mode else "")) - self.root.configure(bg="#1a1a1a") - - # Always hijack the full screen — demo_mode only controls timers. - # NOTE: we intentionally do NOT call overrideredirect(True): on X11 it - # removes WM management and the Entry widget can't receive keyboard - # focus, so the user can't type the dismiss code. -fullscreen + - # -topmost is enough to take over the screen while staying typeable. - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - fullscreen = True - self.root.geometry(f"{screen_w}x{screen_h}+0+0") - self.root.attributes("-fullscreen", fullscreen) - self.root.attributes("-topmost", fullscreen) - - self.root.lift() - self.root.focus_force() - self.root.update_idletasks() + # mode="soft": no watchdog exists yet for a clean, silent lock + # failure, so hard-lock (overrideredirect + grab) is deferred. + self._lock = LockWindow(self.root, LockConfig(mode="soft"), hooks=self) + self._lock.setup() self._progress = _AlarmProgress(current_challenge=_make_challenge()) self._view = self._build_ui() @@ -141,10 +131,31 @@ class WakeAlarm: self._schedule_skip_window_close() self._start_beep_thread() self._hardware = _AlarmHardware( - fan_state=_max_fans(), - audio_restore=_activate_alarm_audio(), + fan_state=_max_fans(), audio_restore=_activate_alarm_audio() ) self._start_screen_flash() + self._lock.grab_input() + + def on_focus_ready(self) -> None: + """Put keyboard focus on the dismiss-code entry once mapped.""" + with contextlib.suppress(tk.TclError): + self._view.entry.focus_force() + + def on_callback_error(self) -> None: + """Surface an unexpected callback error without dropping the alarm.""" + with contextlib.suppress(tk.TclError): + self._view.status_label.configure( + text="Something went wrong — try again.", + ) + self._view.entry.focus_force() + + def on_close(self) -> None: + """Restore fans/audio/display/plug; runs on every exit path, even SIGTERM.""" + self._stop_beep.set() + _restore_fans(active=self._hardware.fan_state) + _restore_alarm_audio(self._hardware.audio_restore) + _restore_display() + turn_off_plug() def _build_ui(self) -> _AlarmView: """Build the dismiss-challenge UI and return its widgets as a view.""" @@ -316,16 +327,7 @@ class WakeAlarm: bg="#1a1a1a", ).pack(pady=30) - self.root.after(3000, self._close) - - def _close(self) -> None: - """Close the alarm window.""" - self._stop_beep.set() - _restore_fans(active=self._hardware.fan_state) - _restore_alarm_audio(self._hardware.audio_restore) - _restore_display() - turn_off_plug() - self.root.destroy() + self.root.after(3000, self._lock.close) def _schedule_code_refresh(self) -> None: """Replace the current challenge periodically. @@ -389,11 +391,8 @@ class WakeAlarm: def _start_beep_thread(self) -> None: """Start the background beep escalation thread.""" - self._beep_thread = threading.Thread( - target=self._beep_loop, - daemon=True, - ) - self._beep_thread.start() + thread = threading.Thread(target=self._beep_loop, daemon=True) + thread.start() def _start_screen_flash(self) -> None: """Start flashing the screen background to attract attention.""" @@ -427,8 +426,8 @@ class WakeAlarm: self._stop_beep.wait(LOUD_TOGGLE_INTERVAL) def run(self) -> None: - """Start the alarm main loop.""" - self.root.mainloop() + """Start the alarm main loop, restoring hardware on every exit path.""" + self._lock.run() def _should_run_alarm() -> bool: diff --git a/wake_alarm/_state.py b/wake_alarm/_state.py index 1ad4f3c..0066e90 100644 --- a/wake_alarm/_state.py +++ b/wake_alarm/_state.py @@ -6,10 +6,11 @@ from datetime import datetime, timezone import json import logging -from python_pkg.shared.log_integrity import ( +from gatelock.log_integrity import ( compute_entry_hmac, verify_entry_hmac, ) + from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE _logger = logging.getLogger(__name__) diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py index cee6576..a63ecd3 100644 --- a/wake_alarm/tests/test_alarm.py +++ b/wake_alarm/tests/test_alarm.py @@ -59,7 +59,13 @@ def _make_mock_tk() -> MagicMock: def _block_real_tk() -> Generator[MagicMock]: """Prevent any real Tk windows in tests.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock @@ -67,7 +73,13 @@ def _block_real_tk() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]: """Provide explicit access to the mocked tk module.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock diff --git a/wake_alarm/tests/test_alarm_part2.py b/wake_alarm/tests/test_alarm_part2.py index bbfd70d..e7a7181 100644 --- a/wake_alarm/tests/test_alarm_part2.py +++ b/wake_alarm/tests/test_alarm_part2.py @@ -40,7 +40,13 @@ def _make_mock_tk() -> MagicMock: def _block_real_tk() -> Generator[MagicMock]: """Prevent any real Tk windows in tests.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock @@ -66,7 +72,13 @@ def _block_extra_devices() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]: """Provide explicit access to the mocked tk module.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock @@ -87,15 +99,15 @@ class TestWakeAlarmInit: assert alarm.demo_mode is True assert alarm.dismissed is False mock_root = mock_tk_module.Tk.return_value - # We deliberately drop overrideredirect (X11 focus bug); fullscreen+topmost - # are what take over the screen now. + # LockConfig(mode="soft") never sets overrideredirect (X11 focus bug); + # fullscreen+topmost are what take over the screen now. mock_root.overrideredirect.assert_not_called() fs_calls = [ c for c in mock_root.attributes.call_args_list - if c.args and c.args[0] == "-fullscreen" + if c.kwargs.get("fullscreen") is True ] - assert fs_calls, "-fullscreen attribute must be set" + assert fs_calls, "fullscreen attribute must be set" alarm._stop_beep.set() # Stop beep thread def test_production_mode_fullscreen( @@ -110,9 +122,9 @@ class TestWakeAlarmInit: fs_calls = [ c for c in mock_root.attributes.call_args_list - if c.args and c.args[0] == "-fullscreen" + if c.kwargs.get("fullscreen") is True ] - assert fs_calls, "-fullscreen attribute must be set" + assert fs_calls, "fullscreen attribute must be set" alarm._stop_beep.set() @@ -370,48 +382,6 @@ class TestBeepLoop: alarm._stop_beep.set() -class TestClose: - """Tests for the alarm close path.""" - - def test_close_stops_beep_and_destroys( - self, - mock_tk_module: MagicMock, - ) -> None: - """_close sets stop event and destroys root.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._close() - assert alarm._stop_beep.is_set() - alarm.root.destroy.assert_called() - - def test_close_restores_fans( - self, - mock_tk_module: MagicMock, - ) -> None: - """_close calls _restore_fans with the saved fan state.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._hardware.fan_state = True - with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore: - alarm._close() - mock_restore.assert_called_once_with(active=True) - - def test_close_restores_audio( - self, - mock_tk_module: MagicMock, - ) -> None: - """_close restores the default sink captured at activation.""" - del mock_tk_module - alarm = WakeAlarm(demo_mode=True) - alarm._hardware.audio_restore = "jbl_sink" - with patch( - "python_pkg.wake_alarm._alarm._restore_alarm_audio", - ) as mock_restore: - alarm._close() - mock_restore.assert_called_once_with("jbl_sink") - alarm._stop_beep.set() - - class TestScreenFlash: """Tests for _start_screen_flash and _flash_step.""" diff --git a/wake_alarm/tests/test_alarm_part3.py b/wake_alarm/tests/test_alarm_part3.py index 157698e..249c4d5 100644 --- a/wake_alarm/tests/test_alarm_part3.py +++ b/wake_alarm/tests/test_alarm_part3.py @@ -39,7 +39,13 @@ def _make_mock_tk() -> MagicMock: def _block_real_tk() -> Generator[MagicMock]: """Prevent any real Tk windows in tests.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock @@ -65,7 +71,13 @@ def _block_extra_devices() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]: """Provide explicit access to the mocked tk module.""" mock = _make_mock_tk() - with patch("python_pkg.wake_alarm._alarm.tk", mock): + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): yield mock @@ -135,14 +147,20 @@ class TestBeepLoopPhases: class TestRunMethod: """Tests for the run() method.""" - def test_run_calls_mainloop( + def test_run_delegates_to_lock( self, mock_tk_module: MagicMock, ) -> None: - """run() calls root.mainloop().""" + """run() hands off to the owned LockWindow. + + Asserts delegation rather than calling the real LockWindow.run(), + which installs real SIGTERM/SIGINT handlers in the test process. + """ + del mock_tk_module alarm = WakeAlarm(demo_mode=True) - alarm.run() - alarm.root.mainloop.assert_called_once() + with patch.object(alarm._lock, "run") as mock_run: + alarm.run() + mock_run.assert_called_once_with() alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_alarm_part4.py b/wake_alarm/tests/test_alarm_part4.py new file mode 100644 index 0000000..b091264 --- /dev/null +++ b/wake_alarm/tests/test_alarm_part4.py @@ -0,0 +1,153 @@ +"""Tests for WakeAlarm's gatelock hooks: on_focus_ready, on_callback_error, on_close.""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +if TYPE_CHECKING: + from collections.abc import Generator + +from python_pkg.wake_alarm._alarm import WakeAlarm + +# --------------------------------------------------------------------------- +# Helpers (duplicated from part 1 so this file is self-contained) +# --------------------------------------------------------------------------- + + +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.return_value = MagicMock() + mock.Label.return_value = MagicMock() + mock.Entry.return_value = MagicMock() + mock.TclError = tk.TclError + mock.END = tk.END + return mock + + +@pytest.fixture(autouse=True) +def _block_real_tk() -> Generator[MagicMock]: + """Prevent any real Tk windows in tests.""" + mock = _make_mock_tk() + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): + yield mock + + +@pytest.fixture(autouse=True) +def _block_extra_devices() -> Generator[MagicMock]: + """Prevent real subprocess.Popen calls for extra ALSA devices.""" + with ( + patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock, + patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False), + patch("python_pkg.wake_alarm._alarm._restore_fans"), + patch("python_pkg.wake_alarm._alarm._set_max_brightness"), + patch("python_pkg.wake_alarm._alarm._wake_display"), + patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"), + patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None), + patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"), + patch("python_pkg.wake_alarm._alarm.turn_on_plug"), + patch("python_pkg.wake_alarm._alarm.turn_off_plug"), + ): + yield mock + + +@pytest.fixture +def mock_tk_module() -> Generator[MagicMock]: + """Provide explicit access to the mocked tk module.""" + mock = _make_mock_tk() + with ( + patch("python_pkg.wake_alarm._alarm.tk", mock), + patch( + "python_pkg.wake_alarm._alarm.GateRoot", + return_value=mock.Tk.return_value, + ), + ): + yield mock + + +class TestGatelockHooks: + """Tests for the LockWindowHooks callbacks (on_focus_ready/on_callback_error).""" + + def test_on_focus_ready_focuses_entry( + self, + mock_tk_module: MagicMock, + ) -> None: + """on_focus_ready forces focus onto the dismiss-code entry.""" + del mock_tk_module + alarm = WakeAlarm(demo_mode=True) + alarm._view.entry.focus_force.reset_mock() + alarm.on_focus_ready() + alarm._view.entry.focus_force.assert_called_once() + alarm._stop_beep.set() + + def test_on_callback_error_surfaces_and_refocuses( + self, + mock_tk_module: MagicMock, + ) -> None: + """on_callback_error shows a message and refocuses the entry.""" + del mock_tk_module + alarm = WakeAlarm(demo_mode=True) + alarm._view.entry.focus_force.reset_mock() + alarm.on_callback_error() + alarm._view.status_label.configure.assert_called_with( + text="Something went wrong — try again.", + ) + alarm._view.entry.focus_force.assert_called_once() + alarm._stop_beep.set() + + +class TestClose: + """Tests for the alarm's gatelock close path (LockWindow.close/on_close).""" + + def test_lock_close_stops_beep_and_destroys( + self, + mock_tk_module: MagicMock, + ) -> None: + """LockWindow.close() runs on_close (stop event) and destroys root.""" + del mock_tk_module + alarm = WakeAlarm(demo_mode=True) + alarm._lock.close() + assert alarm._stop_beep.is_set() + alarm.root.destroy.assert_called() + + def test_on_close_restores_fans( + self, + mock_tk_module: MagicMock, + ) -> None: + """on_close calls _restore_fans with the saved fan state.""" + del mock_tk_module + alarm = WakeAlarm(demo_mode=True) + alarm._hardware.fan_state = True + with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore: + alarm.on_close() + mock_restore.assert_called_once_with(active=True) + alarm._stop_beep.set() + + def test_on_close_restores_audio( + self, + mock_tk_module: MagicMock, + ) -> None: + """on_close restores the default sink captured at activation.""" + del mock_tk_module + alarm = WakeAlarm(demo_mode=True) + alarm._hardware.audio_restore = "jbl_sink" + with patch( + "python_pkg.wake_alarm._alarm._restore_alarm_audio", + ) as mock_restore: + alarm.on_close() + mock_restore.assert_called_once_with("jbl_sink") + alarm._stop_beep.set()