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
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:

View File

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

View File

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

View File

@ -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."""

View File

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

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