mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:47:31 +01:00
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.
Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.
Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).
**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**
1. The inhibitor claimed a block it did not have. Popen succeeding only means
the binary launched; logind can still refuse -- the guest's polkit denies a
non-interactive session outright ("Failed to inhibit: Access denied", exit
1) while the alarm cheerfully logged the power key as blocked.
take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
the child survived, returning None and logging the refusal otherwise.
2. An unsigned session record was written and then rejected on read. With no
HMAC key on the machine (/etc/gatelock absent, as in the guest),
verify_entry_hmac returns False for a *missing* key exactly as it does for
a forgery -- so every session record was unreadable and item 2's power-off
defence silently did not exist. An entry with no "hmac" field is now
honoured with a loud warning; a present-but-wrong signature is still
rejected, since that one really is tampering.
Two harness traps worth recording, both hit while building it:
- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
harness greps for it; verified by breaking a case on purpose and watching
the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
into /home and fail with permissions errors that look like a guest fault.
Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.
Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.
Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.
455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
174 lines
7.8 KiB
Bash
Executable File
174 lines
7.8 KiB
Bash
Executable File
#!/bin/bash
|
|
# Install the weekend wake alarm systemd user service and sudoers entry.
|
|
#
|
|
# Usage: bash install.sh
|
|
#
|
|
# What it does:
|
|
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
|
# 2. Installs system dependencies (alsa-utils, ddcutil)
|
|
# 3. Installs the systemd user units and enables the per-minute trigger timer
|
|
# (which fires the alarm at the time the phone synced)
|
|
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
|
# 5. Adds a sudoers entry for passwordless rtcwake
|
|
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
|
# 7. Removes the retired fan-control script + its sudoers entry
|
|
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
|
|
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
|
|
|
|
set -euo pipefail
|
|
|
|
# Refuse to run under sudo. This script must run as the logged-in user: it
|
|
# installs *user* systemd units and calls `sudo` itself for the few root-owned
|
|
# pieces. Under sudo, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are stripped,
|
|
# so `systemctl --user` fails at step 3 and every later step -- including the
|
|
# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output
|
|
# still looks like success. It also runs pip as root, which leaves root-owned
|
|
# files in ~/.cache/pip. Failing loudly here beats a half-finished install.
|
|
if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then
|
|
echo "Error: do not run this script with sudo." >&2
|
|
echo "Run it as your normal user: bash $0" >&2
|
|
echo "It will prompt for sudo itself where root is actually needed." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
readonly SCRIPT_DIR
|
|
readonly REPO_DIR="$SCRIPT_DIR"
|
|
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
|
TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
|
|
TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
|
|
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
|
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
|
FANS_SCRIPT_DST="/usr/local/bin/wake-alarm-fans.sh"
|
|
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
|
SLEEP_HOOK_DST="/usr/lib/systemd/system-sleep/wake-alarm.sh"
|
|
SHUTDOWN_WRAPPER_DST="/usr/local/bin/shutdown"
|
|
SUDOERS_FILE="/etc/sudoers.d/wake-alarm"
|
|
RTCWAKE_BIN="/usr/sbin/rtcwake"
|
|
|
|
echo "=== Weekend Wake Alarm Installer ==="
|
|
|
|
# 1. Install this package + its dependencies into system Python -------------
|
|
echo "[1/9] Installing wake_alarm + dependencies for /usr/bin/python..."
|
|
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
|
|
echo " Installed. Verifying import..."
|
|
/usr/bin/python3 -c "import wake_alarm; import gatelock" \
|
|
&& echo " wake_alarm and gatelock import cleanly from the system interpreter."
|
|
|
|
# 2. Install system dependencies
|
|
echo "[2/9] Checking system dependencies..."
|
|
if ! command -v speaker-test &>/dev/null; then
|
|
echo " Installing alsa-utils (required for speaker-test)..."
|
|
sudo pacman -S --noconfirm alsa-utils
|
|
else
|
|
echo " alsa-utils already installed"
|
|
fi
|
|
|
|
# 3. Install systemd user units (alarm + the trigger timer that fires it)
|
|
echo "[3/9] Installing systemd user units..."
|
|
mkdir -p "$SYSTEMD_USER_DIR"
|
|
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
|
cp "$TRIGGER_SERVICE_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.service"
|
|
cp "$TRIGGER_TIMER_SRC" "$SYSTEMD_USER_DIR/wake-alarm-trigger.timer"
|
|
systemctl --user daemon-reload
|
|
echo " Installed wake-alarm.service, wake-alarm-trigger.{service,timer}"
|
|
|
|
# wake-alarm.service must NOT auto-start: the PC is always on, so
|
|
# graphical-session.target is reached on every login and the alarm has no time
|
|
# gate — it would fire at login time. The timer owns firing (via
|
|
# morning-routine.service). Both guards matter under `set -euo pipefail`:
|
|
# a unit with no [Install] can't be enabled, and disabling a never-enabled unit
|
|
# can also return non-zero.
|
|
systemctl --user disable wake-alarm.service 2>/dev/null || true
|
|
echo " wake-alarm.service left disabled (started only by the trigger)"
|
|
|
|
systemctl --user enable --now wake-alarm-trigger.timer
|
|
echo " Trigger timer enabled and started (ticks once a minute)"
|
|
|
|
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
|
echo "[4/9] Installing systemd-sleep hook..."
|
|
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
|
|
sudo chmod 0755 "$SLEEP_HOOK_DST"
|
|
echo " Installed to $SLEEP_HOOK_DST"
|
|
|
|
# 5. Add sudoers entry for rtcwake (requires root)
|
|
echo "[5/9] Setting up sudoers for rtcwake..."
|
|
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
|
|
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
|
|
echo " Sudoers entry already exists"
|
|
else
|
|
echo " Adding sudoers entry (requires sudo)..."
|
|
echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null
|
|
sudo chmod 0440 "$SUDOERS_FILE"
|
|
echo " Added: $SUDOERS_LINE"
|
|
fi
|
|
|
|
# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
|
echo "[6/9] Installing shutdown wrapper..."
|
|
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
|
|
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
|
|
echo " Installed to $SHUTDOWN_WRAPPER_DST"
|
|
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
|
|
|
|
# 7. Remove the retired fan-control script and its sudoers entry.
|
|
# The alarm no longer ramps fans: it added anxiety without helping anyone wake
|
|
# up. An upgrade must actively clean up, or a machine that installed the old
|
|
# version keeps a passwordless root entry for a script that no longer exists.
|
|
echo "[7/9] Removing retired fan-control script..."
|
|
if [[ -e "$FANS_SCRIPT_DST" ]]; then
|
|
sudo rm -f "$FANS_SCRIPT_DST"
|
|
echo " Removed $FANS_SCRIPT_DST"
|
|
fi
|
|
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$FANS_SCRIPT_DST" "$SUDOERS_FILE"; then
|
|
sudo sed -i "\\|$FANS_SCRIPT_DST|d" "$SUDOERS_FILE"
|
|
sudo chmod 0440 "$SUDOERS_FILE"
|
|
echo " Dropped the fan NOPASSWD sudoers entry"
|
|
fi
|
|
sudo rm -f /run/wake-alarm-fans.state
|
|
|
|
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
|
echo "[8/9] Installing python-kasa (AUR)..."
|
|
if python -c 'import kasa' 2>/dev/null; then
|
|
echo " python-kasa already installed"
|
|
elif command -v yay &>/dev/null; then
|
|
yay -S --noconfirm --needed python-kasa
|
|
else
|
|
echo " WARNING: yay not found; install python-kasa manually for smart-plug support" >&2
|
|
fi
|
|
if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then
|
|
echo " NOTE: ~/.config/wake_alarm/tapo.json not found — smart-plug control is disabled."
|
|
echo " Create it (mode 0600) with keys: host, email, password."
|
|
fi
|
|
|
|
# 9. Install ddcutil for DDC/CI monitor power control
|
|
# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor
|
|
# was physically powered off (power button), bypassing DPMS limitations.
|
|
echo "[9/9] Installing ddcutil (DDC/CI monitor power control)..."
|
|
if command -v ddcutil &>/dev/null; then
|
|
echo " ddcutil already installed"
|
|
else
|
|
sudo pacman -S --noconfirm ddcutil
|
|
echo " ddcutil installed"
|
|
fi
|
|
# ddcutil needs access to /dev/i2c-* — add user to i2c group if it exists.
|
|
if getent group i2c &>/dev/null; then
|
|
if ! id -nG "$USER" | grep -qw i2c; then
|
|
sudo usermod -aG i2c "$USER"
|
|
echo " Added $USER to i2c group (re-login required for group to take effect)"
|
|
else
|
|
echo " $USER already in i2c group"
|
|
fi
|
|
else
|
|
echo " i2c group not found — ddcutil will run via sudo"
|
|
fi
|
|
|
|
echo "=== Installation complete ==="
|
|
echo "The trigger timer ticks once a minute and fires the alarm at the time"
|
|
echo "the phone last synced (needs ~/.config/wake_alarm/sync.json with a"
|
|
echo "repo-scoped GitHub token — see MIGRATION.md)."
|
|
echo "Check what it resolves right now: python -m wake_alarm._sync --show"
|
|
echo "If the PC is powered down instead, the shutdown wrapper hibernates with"
|
|
echo "rtcwake set to the same alarm, and the sleep hook runs it on resume."
|
|
echo "To test now: python -m wake_alarm._alarm --demo"
|