screen-locker/install.sh
Krzysztof Rudnicki 3e77f4e232 Make every lock screen keyboard-operable, and add the missing installer
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.

Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.

install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.

The keyboard work, all of it Tk defaults rather than local mistakes:

- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
  #211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
  had no indication of where they were. Now set in the _ui_widgets factory, so
  a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
  a documented no-op arguing "buttons only, nothing to focus" -- exactly
  backwards: buttons are what needs focusing when there is no other window on
  screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
  Return and no Escape. It now focuses the *declining* option, so a blind
  Return keeps the workout rather than silently skipping it.

The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.

All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.

Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.

The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:25:45 +02:00

139 lines
6.3 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Screen Locker installer: workout-to-unlock gate.
#
# Usage: bash install.sh
#
# Why this file exists
# --------------------
# It didn't, and that is exactly how the locker silently stopped working.
#
# Commit 9b420a9 (2026-06-22) removed `WorkingDirectory=/opt/screen-locker` and
# `Environment=PYTHONPATH=/opt/screen-locker` from workout-locker.service, on
# the correct-in-principle grounds that "screen_locker is now pip-installed".
# But nothing *guaranteed* that install: there was no installer, `/opt/screen-
# locker` no longer exists, and the package is absent from every Python
# version's user site-packages. So the unit lost its fallback path and gained a
# hard dependency on a manual step nobody re-ran.
#
# Result: workout-locker.service crash-looped with ModuleNotFoundError
# ~1977 times in a single boot while remaining `enabled`, and never started
# successfully. A locker that cannot start is a locker that does not lock.
#
# The fix is this script, not a reminder: run it and the install is real,
# editable (so `git pull` here reaches the running service), and verified
# against the *production* interpreter rather than a dev venv.
#
# What it does:
# 1. Checks system deps (setxkbmap, needed for the VT-disable)
# 2. pip-installs this package editable into system Python's USER
# site-packages -- the service runs /usr/bin/python3 directly, not a venv
# 3. Installs + enables the systemd user units
# 4. Verifies the production interpreter can actually import the module,
# and FAILS if it cannot (a warning here would just recreate the bug)
# ============================================================================
set -euo pipefail
# Split declare/assign so the command-substitution exit code is not masked.
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly REPO_DIR="$SCRIPT_DIR"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
readonly PROD_PYTHON="/usr/bin/python3"
# The shared lock backend, developed in lockstep with this repo.
readonly GATELOCK_DIR="$HOME/utils/gatelock"
echo "=== Screen Locker Installer ==="
# 1. System dependencies ------------------------------------------------------
echo "[1/4] Checking system dependencies..."
if ! command -v setxkbmap &>/dev/null; then
echo " installing xorg-setxkbmap (required to disable VT switching)..."
sudo pacman -S --needed --noconfirm xorg-setxkbmap
else
echo " setxkbmap present."
fi
# 2. Install the package ------------------------------------------------------
# --break-system-packages is required on Arch (PEP 668). Editable so a later
# `git pull` in THIS clone updates the running service with no reinstall. That
# is also why the clone must live somewhere durable (~/screen-locker), never a
# scratch directory: a non-editable snapshot would freeze at this commit and
# silently stop tracking the repo.
echo "[2/4] Installing screen_locker into user site-packages (editable)..."
"$PROD_PYTHON" -m pip install --user --break-system-packages -e "$REPO_DIR"
# Re-assert an editable gatelock, deliberately AFTER the line above.
#
# pyproject.toml pins `gatelock @ git+https://.../@gatelock-v0.2.1`, so the
# install above resolves that pin and overwrites any local editable gatelock
# with a frozen wheel built from the tag. That silently reverts every
# unreleased change in ~/utils/gatelock and breaks the other lockers that
# depend on them -- observed: it removed `ScrollableSurface` and took
# diet_guard down with it.
#
# gatelock is a sibling in the utils monorepo and is developed in lockstep with
# the four lockers, so the local clone must win. Ordering is the fix: install
# it last.
if [[ -d "$GATELOCK_DIR" ]]; then
echo " re-asserting editable gatelock from $GATELOCK_DIR..."
"$PROD_PYTHON" -m pip install --user --break-system-packages \
--no-deps -e "$GATELOCK_DIR"
else
echo " WARNING: $GATELOCK_DIR not found; leaving the pinned gatelock" >&2
echo " wheel in place. Local gatelock changes will NOT be" >&2
echo " picked up by the running service." >&2
fi
# 3. systemd units ------------------------------------------------------------
echo "[3/4] Installing systemd user units..."
mkdir -p "$SYSTEMD_USER_DIR"
shopt -s nullglob
for unit in "$SCRIPT_DIR"/*.service "$SCRIPT_DIR"/*.timer; do
install -m 644 "$unit" "$SYSTEMD_USER_DIR/$(basename "$unit")"
echo " installed $(basename "$unit")"
done
shopt -u nullglob
systemctl --user daemon-reload
for timer in "$SCRIPT_DIR"/*.timer; do
[[ -e "$timer" ]] || continue
systemctl --user enable --now "$(basename "$timer")"
echo " enabled $(basename "$timer")"
done
# 4. Verify the PRODUCTION import path ---------------------------------------
# This gates rather than warns. An installer that reports success while the
# service still cannot import the module is precisely the failure mode above,
# and a warning would be read as noise.
echo "[4/4] Verifying the production interpreter can import screen_locker..."
if ! (cd /tmp && "$PROD_PYTHON" -c "import screen_locker" 2>/dev/null); then
echo "ERROR: $PROD_PYTHON cannot import screen_locker after install." >&2
echo " The systemd service uses this interpreter, so it would" >&2
echo " crash-loop with ModuleNotFoundError. Refusing to report" >&2
echo " success." >&2
exit 1
fi
if ! (cd /tmp && "$PROD_PYTHON" -c "import gatelock" 2>/dev/null); then
echo "ERROR: $PROD_PYTHON cannot import gatelock (the shared lock" >&2
echo " backend). Install it editable from ~/utils/gatelock." >&2
exit 1
fi
if [[ -d "$GATELOCK_DIR" ]]; then
gatelock_path="$(cd /tmp && "$PROD_PYTHON" -c \
"import gatelock; print(gatelock.__file__)")"
if [[ "$gatelock_path" != "$GATELOCK_DIR"* ]]; then
echo "ERROR: gatelock resolves to $gatelock_path, not the local" >&2
echo " clone at $GATELOCK_DIR. The pinned wheel won, so local" >&2
echo " gatelock changes would not reach the service." >&2
exit 1
fi
echo " ok: gatelock resolves to the local editable clone"
fi
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
echo
echo "=== Done ==="
echo "The locker is live. Check when it next fires with:"
echo " systemctl --user list-timers | grep -i workout"