Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ env.sh
# (litclock-dev#721 bench pass).
env.sh.lock
env.sh.bak
# mktemp staging for the atomic env.sh writers (`mktemp "${dest}.XXXXXX"` in
# lib/state.sh and update.sh). Normally renamed over env.sh within milliseconds,
# but a SIGKILL or power loss inside that window leaves a full, unredacted copy
# of env.sh — API key, coordinates, city — sitting beside it. The siblings above
# are enumerated, so this shape was not covered and `git reset --hard` never
# removes it (litclock-dev#871 /review, adversarial). Exactly six X's, matching
# mktemp's template, so nothing else that shares the prefix is swept up.
env.sh.??????
# `sample` is also six characters, so the template pattern matches the TRACKED
# env.sh.sample. Harmless while it stays tracked (ignore rules do not apply to
# tracked files) and a trap the moment it is removed and re-added, so exclude it
# explicitly rather than relying on that.
!env.sh.sample
*.log
.vscode
*.pickle
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to LitClock are documented here. Format loosely follows [Kee

## [Unreleased]

### Changed

- Your clock now draws each quote from text instead of using a pre-made picture, once it has checked on itself that it can do so correctly; the pictures stay on the clock as a backup.

### Fixed

- If an update ever leaves your clock unable to start, it now reliably goes back to the version that last worked, and draws its quotes from the backup pictures until a newer version is installed.

## [v0.230.0] - 2026-09-22

### Fixed
Expand Down
211 changes: 206 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions env.sh.sample
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,25 @@ export GIFT_MODE_MESSAGE=
# pre-rendered PNG (falls back to the PNG, then a plain time display, on
# any render failure). The flag is only honored after this device passes
# `venv/bin/python3 tools/validate_measurement.py check --stamp` (writes
# the .runtime-render-validated marker the clock requires). Default off
# until the Stage-2 soak validates it.
# the .runtime-render-validated marker the clock requires).
#
# THIS FILE IS THE BACKFILL SOURCE FOR EXISTING DEVICES, which is why the
# value here is `false` while a fresh flash is seeded `true`. The divergence
# is deliberate and load-bearing — see `env_sh_defaults()` in
# scripts/lib/state.sh, which is the fresh-flash seeder.
#
# update.sh Phase 3 copies the line below VERBATIM onto any device missing
# this key. A `true` here would therefore switch on an old device with none
# of litclock-dev#871 Stage B's guards run — no on-device self-test, no
# images/ fallback check — before the smoke gate, and with no revert path
# that undoes it. litclock-dev#783 records devices born missing up to ten of
# these knobs, so the oldest and least reachable clocks are the exposed ones.
#
# An existing device is migrated by update.sh instead, only after its own
# self-test has rendered a quote from text on THIS release and only while
# images/ is still there to fall back to. Set it to false by hand and the
# next weekly tick sets it back; that is deliberate (owner call 2026-09-19),
# not a bug.
export LITCLOCK_RUNTIME_RENDER=false

# --- Advanced Settings (uncomment to override defaults) ---
Expand Down
7 changes: 5 additions & 2 deletions scripts/first-boot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,10 @@ main() {
local _defaults
# NO language argument — a fresh device keeps Accept-Language
# negotiation alive on this boot (the litclock-dev#743 empty-seed contract).
_defaults=$(env_sh_defaults)$'\n'
# `true`: a FRESH flash renders text from its first paint. The
# helper defaults to false for the reset and cloning callers,
# which run on existing devices (litclock-dev#871 Stage B).
_defaults=$(env_sh_defaults "" true)$'\n'
if ! atomic_write_env_sh "$ENV_FILE" "$_defaults"; then
local _rc=$?
if [[ "$_rc" == "75" ]]; then
Expand Down Expand Up @@ -646,7 +649,7 @@ export ALLOW_NSFW_QUOTES=false
export LITCLOCK_LANGUAGE=
export SHOW_DIAGNOSTICS_SHORTCUT=false
export GIFT_MODE_MESSAGE=
export LITCLOCK_RUNTIME_RENDER=false
export LITCLOCK_RUNTIME_RENDER=true
# export DISPLAY_CLEAR_HOUR=2
# export LITCLOCK_RENDER_LEAD_S=4
# export WEATHER_API_TIMEOUT=15
Expand Down
55 changes: 50 additions & 5 deletions scripts/lib/state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,22 @@ ENV_FILE_DEFAULT="${LITCLOCK_ENV_FILE:-/home/pi/litclock/env.sh}"
# substitution STRIPS trailing newlines, so every caller re-adds one:
# `DEFAULTS=$(env_sh_defaults)$'\n'`. Do not "simplify" that away.
#
# VALUES may differ from the sample and two deliberately do: the sample
# VALUES may differ from the sample and three deliberately do: the sample
# documents WEATHER_LATITUDE/LONGITUDE with real Austin coordinates as an
# example, while a seeded device must leave them EMPTY. With
# WEATHER_LOCATION_MODE=auto the IP-geo resolver fills them on a good boot;
# on the ip-api.com-blocked path a seeded coordinate would render Austin
# weather on a device that is not in Austin — worse than an honest empty.
# The third is LITCLOCK_RUNTIME_RENDER, which is a PARAMETER here rather than
# a constant: `true` when first-boot.sh asks for it (a fresh flash renders text
# from its first paint, against a marker stamped at image-build time), `false`
# for every other caller and for the sample. The sample is what update.sh
# Phase 3 BACKFILLS onto existing devices, so a `true` there would switch an
# old clock on with none of litclock-dev#871 Stage B's guards run. Fresh-flash
# default and existing-device backfill are different jobs and this is the only
# key where they disagree; do not "fix" them into agreement.
# tests/test_first_boot_flow.py compares the KEY SET and comment status, not
# values, and tests/test_runtime_render_autostamp.py pins the divergence.
#
# LANGUAGE ($1, optional) seeds LITCLOCK_LANGUAGE. Empty (the default, used by
# first-boot and prepare-for-cloning) keeps Accept-Language negotiation alive
Expand All @@ -254,6 +264,23 @@ ENV_FILE_DEFAULT="${LITCLOCK_ENV_FILE:-/home/pi/litclock/env.sh}"
# that moving the interpolation into this file did not leave the belt behind.
env_sh_defaults() {
local language="${1-}"
# RUNTIME_RENDER ($2, optional) — litclock-dev#871 Stage B. Defaults to
# `false`, and the default is the fail-safe direction ON PURPOSE: this
# helper is NOT a fresh-flash-only seeder. reset-setup.sh and
# prepare-for-cloning.sh both call it on an EXISTING device, and neither
# removes `.runtime-render-validated` (they clear the self-test record, not
# the marker). So a `true` default would hand a reset clock runtime render
# with none of Stage B's five guards run — including a clock whose
# self-test had FAILED, which is precisely the device the guards exist to
# hold back. Those callers take the default; a device that can render text
# is migrated properly by update.sh on its next tick, within a week.
# `first-boot.sh` passes `true` explicitly, because a fresh flash carries a
# marker stamped at image-build time against the shipped renderer.
local runtime_render="${2-false}"
if [[ "$runtime_render" != "true" && "$runtime_render" != "false" ]]; then
echo "[state] env_sh_defaults: runtime_render '$runtime_render' is not true/false; seeding false" >&2
runtime_render="false"
fi
if [[ -n "$language" && ! "$language" =~ ^[a-z][a-z0-9-]{0,16}$ ]]; then
echo "[state] env_sh_defaults: language '$language' failed the shape check; seeding empty" >&2
language=""
Expand All @@ -273,7 +300,7 @@ export ALLOW_NSFW_QUOTES=false
export LITCLOCK_LANGUAGE=$language
export SHOW_DIAGNOSTICS_SHORTCUT=false
export GIFT_MODE_MESSAGE=
export LITCLOCK_RUNTIME_RENDER=false
export LITCLOCK_RUNTIME_RENDER=$runtime_render
# export DISPLAY_CLEAR_HOUR=2
# export LITCLOCK_RENDER_LEAD_S=4
# export WEATHER_API_TIMEOUT=15
Expand Down Expand Up @@ -367,14 +394,32 @@ _atomic_write_env_sh_finalize() {
if [[ -e "$dest" ]]; then
owner=$(stat -c '%U:%G' "$dest" 2>/dev/null) || owner=""
mode=$(stat -c '%a' "$dest" 2>/dev/null) || mode=""
# MODE FIRST, then ownership. The other order installs an unreadable
# env.sh whenever the destination is root-owned and this runs as pi: the
# mktemp staging file is pi-owned 0600, `sudo chown root:root` succeeds,
# the subsequent unprivileged `chmod 644` then FAILS on a file pi no
# longer owns, and the rename replaces a world-readable config with a
# root-owned 0600 one that every pi-user service — the painter, the
# control server — can no longer read. Both failures are swallowed, so
# the caller reports success (review of litclock-dev#871 Stage B, which
# is the first path to exercise this helper from update.sh).
#
# Setting the mode while pi still owns the temp file is the case that
# matters and it succeeds there; it can still fail (a read-only mount,
# an exotic ACL), which is why the sudo fallback and the `|| true` stay.
# If the chown then fails the file lands pi:pi at whatever mode was set
# — readable, degraded, not broken. That is the safe direction, and it
# is why these stay best-effort rather than becoming a hard abort.
# `$mode` is the DESTINATION's mode, not a constant 0644: a device whose
# env.sh is 0600 keeps 0600.
if [[ -n "$mode" ]]; then
chmod "$mode" "$tmp" 2>/dev/null || sudo chmod "$mode" "$tmp" 2>/dev/null || true
fi
if [[ -n "$owner" ]]; then
chown "$owner" "$tmp" 2>/dev/null \
|| sudo chown "$owner" "$tmp" 2>/dev/null \
|| true
fi
if [[ -n "$mode" ]]; then
chmod "$mode" "$tmp" 2>/dev/null || true
fi
else
# First-boot path: mktemp staged the file at 0600. env.sh must be
# world-readable so the pi-user `source env.sh` in runtheclock.sh
Expand Down
30 changes: 30 additions & 0 deletions scripts/prepare-for-cloning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,36 @@ if [[ -n "$_ENV_LEAKS" ]]; then
_abort_env_credentials "$_ENV_LEAKS"
fi
unset _ENV_LEAKS

# litclock-dev#871 /review (adversarial) — the gate above reads ONE path, and
# the atomic env.sh writers stage through `mktemp "${dest}.XXXXXX"` beside it.
# Every in-script failure arm removes that file; a SIGKILL or power loss inside
# the printf -> chmod -> chown -> mv window does not, and `with_env_lock` runs
# the writer in a subshell where bash has reset this script's traps to default,
# so the signal handler does not cover it either. What is left is a full,
# UNREDACTED copy of the previous owner's env.sh — API key, coordinates, city —
# which Step 2's wipe never touches because it targets `env.sh`, which
# `git reset --hard` never removes because it is untracked, and which this gate
# printed `done` over because it never looked. It then ships on every clone.
#
# Refuse rather than delete: a staging file here means a writer died mid-write,
# so the card's state is not what the operator thinks, and silently removing the
# evidence is the wrong answer on a path whose whole job is to certify the card.
shopt -s nullglob
_ENV_STAGING=("$INSTALL_DIR"/env.sh.??????)
shopt -u nullglob
# env.sh.sample is six characters too; it is tracked and is not a staging file.
_ENV_STAGING_REAL=()
for _s in "${_ENV_STAGING[@]}"; do
[[ "$(basename "$_s")" == "env.sh.sample" ]] && continue
_ENV_STAGING_REAL+=("$_s")
done
if [[ ${#_ENV_STAGING_REAL[@]} -gt 0 ]]; then
_abort_env_credentials \
"abandoned atomic-write staging file(s) beside it: ${_ENV_STAGING_REAL[*]}" \
"each is a full copy of the previous owner's env.sh; remove them and re-run."
fi
unset _ENV_STAGING _ENV_STAGING_REAL _s
echo -e "${GREEN}done${NC}"

echo ""
Expand Down
17 changes: 17 additions & 0 deletions scripts/reset-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,23 @@ if [[ -f "$INSTALL_DIR/env.sh" ]]; then
# trailing newlines, and update.sh Phase 3 appends missing sample keys with
# `>>`, which would otherwise splice the first one onto the last line.
DEFAULTS=$(env_sh_defaults "$GIFT_LANGUAGE_CODE")$'\n'
# litclock-dev#871 /review (adversarial) — sweep abandoned atomic-write
# staging files FIRST. The writers stage through `mktemp "${dest}.XXXXXX"`
# beside env.sh and remove it on every in-script failure arm; a SIGKILL or
# power loss inside the write window does not, and `with_env_lock` runs the
# writer in a subshell where bash has reset this script's traps to default.
# What survives is a full copy of the gifter's env.sh — API key, home
# coordinates, city — which this wipe would leave untouched because it
# writes `env.sh` only, and which then travels to the recipient.
# env.sh.sample is six characters too and is a tracked repo file, so it is
# excluded by name rather than by glob.
shopt -s nullglob
for _stale in "$INSTALL_DIR"/env.sh.??????; do
[[ "$(basename "$_stale")" == "env.sh.sample" ]] && continue
rm -f "$_stale" 2>/dev/null || sudo rm -f "$_stale" 2>/dev/null || true
done
shopt -u nullglob
unset _stale
if atomic_write_env_sh "$INSTALL_DIR/env.sh" "$DEFAULTS"; then
echo -e "${GREEN}done${NC}"
else
Expand Down
Loading
Loading