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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 07:33:49 +02:00
parent db2be1f6a1
commit 3a2e63af9c
6 changed files with 250 additions and 97 deletions

View File

@ -9,6 +9,7 @@ workout-free day via HMAC-signed wake state.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
@ -17,6 +18,8 @@ import threading
import time import time
import tkinter as tk import tkinter as tk
from gatelock import GateRoot, LockConfig, LockWindow
from python_pkg.shared.logging_setup import configure_logging 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._alarm_display import _restore_display, _wake_display
from python_pkg.wake_alarm._audio import ( from python_pkg.wake_alarm._audio import (
@ -108,29 +111,16 @@ class WakeAlarm:
self.demo_mode = demo_mode self.demo_mode = demo_mode
self.dismissed = False self.dismissed = False
self._stop_beep = threading.Event() self._stop_beep = threading.Event()
self._beep_thread: threading.Thread | None = None
self._alarm_start: float = time.monotonic() self._alarm_start: float = time.monotonic()
self._active = True 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.title("Wake Alarm" + (" [DEMO]" if demo_mode else ""))
self.root.configure(bg="#1a1a1a") # mode="soft": no watchdog exists yet for a clean, silent lock
# failure, so hard-lock (overrideredirect + grab) is deferred.
# Always hijack the full screen — demo_mode only controls timers. self._lock = LockWindow(self.root, LockConfig(mode="soft"), hooks=self)
# NOTE: we intentionally do NOT call overrideredirect(True): on X11 it self._lock.setup()
# 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()
self._progress = _AlarmProgress(current_challenge=_make_challenge()) self._progress = _AlarmProgress(current_challenge=_make_challenge())
self._view = self._build_ui() self._view = self._build_ui()
@ -141,10 +131,31 @@ class WakeAlarm:
self._schedule_skip_window_close() self._schedule_skip_window_close()
self._start_beep_thread() self._start_beep_thread()
self._hardware = _AlarmHardware( self._hardware = _AlarmHardware(
fan_state=_max_fans(), fan_state=_max_fans(), audio_restore=_activate_alarm_audio()
audio_restore=_activate_alarm_audio(),
) )
self._start_screen_flash() 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: def _build_ui(self) -> _AlarmView:
"""Build the dismiss-challenge UI and return its widgets as a view.""" """Build the dismiss-challenge UI and return its widgets as a view."""
@ -316,16 +327,7 @@ class WakeAlarm:
bg="#1a1a1a", bg="#1a1a1a",
).pack(pady=30) ).pack(pady=30)
self.root.after(3000, self._close) self.root.after(3000, self._lock.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()
def _schedule_code_refresh(self) -> None: def _schedule_code_refresh(self) -> None:
"""Replace the current challenge periodically. """Replace the current challenge periodically.
@ -389,11 +391,8 @@ class WakeAlarm:
def _start_beep_thread(self) -> None: def _start_beep_thread(self) -> None:
"""Start the background beep escalation thread.""" """Start the background beep escalation thread."""
self._beep_thread = threading.Thread( thread = threading.Thread(target=self._beep_loop, daemon=True)
target=self._beep_loop, thread.start()
daemon=True,
)
self._beep_thread.start()
def _start_screen_flash(self) -> None: def _start_screen_flash(self) -> None:
"""Start flashing the screen background to attract attention.""" """Start flashing the screen background to attract attention."""
@ -427,8 +426,8 @@ class WakeAlarm:
self._stop_beep.wait(LOUD_TOGGLE_INTERVAL) self._stop_beep.wait(LOUD_TOGGLE_INTERVAL)
def run(self) -> None: def run(self) -> None:
"""Start the alarm main loop.""" """Start the alarm main loop, restoring hardware on every exit path."""
self.root.mainloop() self._lock.run()
def _should_run_alarm() -> bool: def _should_run_alarm() -> bool:

View File

@ -6,10 +6,11 @@ from datetime import datetime, timezone
import json import json
import logging import logging
from python_pkg.shared.log_integrity import ( from gatelock.log_integrity import (
compute_entry_hmac, compute_entry_hmac,
verify_entry_hmac, verify_entry_hmac,
) )
from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -59,7 +59,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]: def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() 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 yield mock
@ -67,7 +73,13 @@ def _block_real_tk() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() 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 yield mock

View File

@ -40,7 +40,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]: def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() 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 yield mock
@ -66,7 +72,13 @@ def _block_extra_devices() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() 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 yield mock
@ -87,15 +99,15 @@ class TestWakeAlarmInit:
assert alarm.demo_mode is True assert alarm.demo_mode is True
assert alarm.dismissed is False assert alarm.dismissed is False
mock_root = mock_tk_module.Tk.return_value mock_root = mock_tk_module.Tk.return_value
# We deliberately drop overrideredirect (X11 focus bug); fullscreen+topmost # LockConfig(mode="soft") never sets overrideredirect (X11 focus bug);
# are what take over the screen now. # fullscreen+topmost are what take over the screen now.
mock_root.overrideredirect.assert_not_called() mock_root.overrideredirect.assert_not_called()
fs_calls = [ fs_calls = [
c c
for c in mock_root.attributes.call_args_list 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 alarm._stop_beep.set() # Stop beep thread
def test_production_mode_fullscreen( def test_production_mode_fullscreen(
@ -110,9 +122,9 @@ class TestWakeAlarmInit:
fs_calls = [ fs_calls = [
c c
for c in mock_root.attributes.call_args_list 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() alarm._stop_beep.set()
@ -370,48 +382,6 @@ class TestBeepLoop:
alarm._stop_beep.set() 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: class TestScreenFlash:
"""Tests for _start_screen_flash and _flash_step.""" """Tests for _start_screen_flash and _flash_step."""

View File

@ -39,7 +39,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]: def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests.""" """Prevent any real Tk windows in tests."""
mock = _make_mock_tk() 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 yield mock
@ -65,7 +71,13 @@ def _block_extra_devices() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]: def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module.""" """Provide explicit access to the mocked tk module."""
mock = _make_mock_tk() 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 yield mock
@ -135,14 +147,20 @@ class TestBeepLoopPhases:
class TestRunMethod: class TestRunMethod:
"""Tests for the run() method.""" """Tests for the run() method."""
def test_run_calls_mainloop( def test_run_delegates_to_lock(
self, self,
mock_tk_module: MagicMock, mock_tk_module: MagicMock,
) -> None: ) -> 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 = WakeAlarm(demo_mode=True)
alarm.run() with patch.object(alarm._lock, "run") as mock_run:
alarm.root.mainloop.assert_called_once() alarm.run()
mock_run.assert_called_once_with()
alarm._stop_beep.set() alarm._stop_beep.set()

View File

@ -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()