mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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>
139 lines
6.3 KiB
Bash
Executable File
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"
|