mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:27:40 +01:00
a03afb8ae7
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 9b39196671 |
Take gatelock 0.8.1: nested LockConfig
The grab axes are grab=GrabPolicy(kind=..., disable_vt=..., preempt_weaker_holder=...) and every colour read goes through config.palette; nothing about the lock's behaviour changed. Also carries the pylint hook on 4.0.8 with the source-only scope config; the gate stays at 8.0 until the remaining findings are cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA |
|||
| f5c6c2fb9d |
Split _manual_workout_dialog.py and _manual_workout.py
_manual_workout_dialog.py (357) -> _manual_workout_sport_fields.py
ManualWorkoutSportFieldsMixin
(+ _mw_scrollable_form moved into the
existing widgets mixin, where the
generic scaffold belongs)
_manual_workout.py (355) -> _manual_workout_types.py: the sport
constants, ManualWorkoutDraft, and the
rolling budget accounting
A painting mixin cannot simply move: conftest's mock_tk fixture patches `tk`
per-module through the _TK_MODULES list, so the new module had to be
registered there or its tkinter calls reach a real display. That is what the
two dialog tests caught.
Test count 976 -> 977: _manual_workout_sport_fields is a new lock-surface
module, so test_no_popup_widgets now safety-checks it too. Coverage stays
100%, pylint holds at 8.70/10, `screen_lock --status` unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| a79c4fdd05 |
Stop the lock screen scrolling itself, and make every screen fit 768px
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.
The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:
* Every font size and pad now goes through the design-system tokens rather
than a literal. A positive size means *points* to Tk, so "36" rendered
~37% larger than the 36px intended; that alone was most of the overflow,
on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
the three reflection boxes share rows instead of taking one each.
Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.
Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.
Tests: 896 pass. The focus/scroll helpers shipped in
|
|||
| 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
|
|||
| c0aae7d05b |
Make the lock overlay's sport selector usable, and focus its form fields
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 600d0fd084 |
Migrate screen_locker to gatelock v0.2.0 per-output locking
The lock now paints one container per live output instead of one window on the primary, and claims the screen through an Arbiter at RANK_SCREEN_LOCKER so the wake alarm can outrank it. The fan-out happens at the widget factory, not the render loop. Painting a screen here is not a pure function -- the ~20 clear_container() flows record workouts, credit weeks and branch on state while they paint -- so re-running a flow per surface would double those side effects. Instead `_label`, `_button_row` and friends build N copies and return a group that mirrors later configure calls to all of them, and a flow body still runs once. Frame groups nest, so `_button(button_row, ...)` reads exactly as it did. The degenerate case carries as much weight as the general one: the status window, the post-sick-day verify window and the relaxed-day prompt have no lock and no surfaces, and share this same factory. They get a group of exactly one frame and behave as they always did. Text boxes are the one thing that cannot be shared. Entries fan out over a single StringVar, but tk.Text has no such variable, so the sick-day description genuinely diverges per monitor; TextGroup.get() returns whichever copy was actually typed into, preserving "answer on any monitor". Tests: the suite hung rather than failed on first run, which is the trap gatelock's own conftest documents -- screen_locker patches its own `tk`, but LockWindow builds Toplevels through gatelock's, and real tkinter over a MagicMock root recurses without bound. `_gatelock_fixtures` closes that and redirects the runtime dir so no test can stand a production locker down. Every other test runs single-output, where fan-out is unobservable, so test_surface_fanout runs on two outputs; each assertion was checked by cropping the group loops to the primary and confirming it goes red. 874 tests, 100% branch coverage, pre-commit clean, no lint suppressions. |
|||
| 0c6f6957a8 |
Wire gatelock.LockConfig into every window, not just the lock overlay
self._colors = LockConfig(...) is now built unconditionally in ScreenLocker.__init__ (previously only the fullscreen lock path built a local `config`), and every raw hex literal across status_view.py, _ui_flows.py, _ui_flows_relaxed.py, _sick_dialog.py, _manual_workout_dialog.py, _manual_workout_widgets.py, and _temperature_status_mixin.py now reads from it instead -- replacing ad hoc greens/oranges/reds (#00ff00, #0066cc, #ff4444, ...) with the shared success/accent/danger/warning/muted tokens. Also fixes the verify-window and status-window paths that still hardcoded #1a1a1a for the background. This was the prerequisite the on-fill fix (previous commit) actually needed: _window_setup.py/_ui_widgets.py/_heat_skip.py reference self._colors, which nothing in the codebase constructed outside the fullscreen lock path until this commit. Verified: 859/859 tests, 100% coverage. |
|||
| 79879263ba |
Support multiple workouts per day; fix the dead sync; ban silent failures
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
|
|||
| 233536d275 |
Manual-workout phone sync P3: PC pushes its manual entries to pc/log.json
Two-way sync: the PC now publishes its own manual workouts so the phone can see them for the shared budget. Reverses the old "the PC never pushes" invariant. - _manual_push.py (new): a small crdt-sync store of PC-originated manuals (manual_sync_log.json, next to workout_log.json). record_pc_manual() appends the form's entry (wrapped as the shared kind/date payload under a stable manual: id, monotonic HLC); push_pc_manuals() runs one sync_log tick to devices/pc/log.json. Best-effort: no token, empty store, or a 403 from a read-only token is swallowed so the local form still works. - _manual_workout_dialog.py: record the manual locally on form save (fast, both hosts) — published on the next lock startup. - screen_lock.py: push_pc_manuals() runs in the startup sync step (also retries any manual a previous push failed to publish). - Updated the now-false "PC only ever reads, never pushes" docstrings in _workout_sync.py and _constants.py; noted the contents:write PAT requirement. A PC-pushed record round-trips through the same ingestion decoder, so it is ingestable by any device. Full suite green at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH |
|||
| 60d8c3361b |
Redesign manual-workout form: two-column grid, drop phone-only fields
The manual-workout evidence form (shown on the lock screen and the status window via the shared ManualWorkoutDialogMixin) was a cramped, narrow centered column of tiny inputs with large empty margins, and included two fields that make no sense to fill from a locked PC. - Remove the vestigial "Google Maps link" (location_maps_link) and "Screenshot path" (proof_screenshot_path) fields from the form, the ManualWorkoutDraft dataclass, and build_entry(). Both were written to workout_log.json but never read by any code. The now-empty "Evidence" section is gone too. reservation_phone is kept. - Lay the form out as a two-column grid that fills ~90% of the screen (the fullscreen container was shrink-wrapping to its widest child) with larger labels/inputs; reflection boxes span full width. Activity-specific fields now sit directly under their section header. - Extract the low-level field/grid builders into a new ManualWorkoutFormWidgetsMixin (_manual_workout_widgets.py) so the dialog module stays under the 400-line cap; the dialog mixin inherits it, so the host classes need no change. Tests updated for the removed fields and the pack->grid sport swap; added a unit test for the grid cursor's odd-row bump. Full suite green at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH |
|||
| d9119a6582 |
Add manual (unverified) workout logging with evidence form
Adds a rate-limited manual-workout entry point (StatusWindow + locked retry screen) for activities like table tennis that ADB/RunnerUp can't verify, gated by a 2/7d + 5/30d budget and a detailed evidence form rather than free text. Full credit toward the weekly minimum, debt clearing, and commitment prompt, matching phone/RunnerUp verification. Also stops tracking personal runtime state (workout_log.json, sick_history.json, extra_benefits_state.json, scheduled_skips.json, shutdown_base.json, early_bird_pending.json) in git — these are regenerated locally and never belong in a public/private repo's history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbPcvAvt1JhiRzEyPDE5dA |