mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
"""One extra shutdown hour on a day with an accepted LeetCode submission.
|
|
|
|
leetcode-guard never writes the shutdown config itself; it only publishes the
|
|
fact through its ledger, the same way ``steam-backlog-enforcer`` reads it for
|
|
an hour of gaming budget. The write has to live here because
|
|
:func:`~screen_locker._shutdown_base.reset_to_base_if_new_day` recomputes the
|
|
config from scratch on every new day -- a bonus written by another program
|
|
would be wiped by that reset, exactly as a pre-tick workout credit was on
|
|
2026-09-13. So the hour is a *term* of the derived target, not an increment
|
|
that survives by luck.
|
|
|
|
Three things are pinned deliberately, mirroring leetcode-guard's own
|
|
``solved_today`` contract:
|
|
|
|
**Only ``credit`` entries count.** A ``seen`` entry is first-run seeding worth
|
|
zero, and a ``charge`` proves only that the day was *settled* -- which also
|
|
happens from banked credit, from the escape hatch and from a classified
|
|
outage, so it can never stand in for a solve.
|
|
|
|
**The solve time is ``detail["submitted_at"]``, not ``day``.** ``day`` is the
|
|
*harvesting* run's date, and a problem solved at 23:50 and harvested the next
|
|
morning carries the next day's key. It earns for the day it was solved on,
|
|
which by then is over -- never for the next one.
|
|
|
|
**"Cannot check" is not "not solved".** An unreadable ledger or key yields
|
|
``None``, and the caller fails closed to no hour. Failing open would let the
|
|
coupling be defeated by deleting a file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, time
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
from gatelock.log_integrity import verify_entry_hmac
|
|
|
|
from screen_locker._constants import HMAC_KEY_FILE
|
|
|
|
_logger: Final = logging.getLogger(__name__)
|
|
|
|
# Flat, once per day, regardless of how many problems were solved -- the
|
|
# workout is the harder thing and keeps the bigger, per-session reward.
|
|
LEETCODE_BONUS_HOURS: Final = 1
|
|
|
|
# leetcode-guard's real ledger (its DATA_DIR / "ledger.json"). Read-only from
|
|
# here; resolved at call time so the test suite's path redirect applies.
|
|
LEETCODE_LEDGER_FILE: Final = Path.home() / ".local/share/leetcode_guard/ledger.json"
|
|
|
|
_CREDIT: Final = "credit"
|
|
|
|
|
|
def _today_window(now: datetime | None = None) -> tuple[float, float]:
|
|
"""Local midnight and now, as unix seconds.
|
|
|
|
Args:
|
|
now: Stand-in for the current local time, for tests.
|
|
|
|
Returns:
|
|
The inclusive window a solve must fall in to count for today.
|
|
"""
|
|
moment = (now or datetime.now(tz=UTC)).astimezone()
|
|
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
|
|
return midnight.timestamp(), moment.timestamp()
|
|
|
|
|
|
def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool:
|
|
"""Whether this verified credit's accepted submission happened today.
|
|
|
|
Args:
|
|
entry: One ledger entry, already known to be a verified credit.
|
|
window: Local midnight and now, as unix seconds.
|
|
|
|
Returns:
|
|
Whether the solve falls inside today. Without a usable
|
|
``submitted_at`` the harvest ``day`` is the fallback: it runs late,
|
|
never early, so it can miss a late solve but never invent one.
|
|
"""
|
|
detail = entry.get("detail")
|
|
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
|
if raw is not None:
|
|
try:
|
|
stamp = float(str(raw))
|
|
except ValueError:
|
|
_logger.warning(
|
|
"LeetCode credit %r has an unparsable submitted_at (%r); "
|
|
"falling back to its day key",
|
|
entry.get("entry_id"),
|
|
raw,
|
|
)
|
|
else:
|
|
start, end = window
|
|
return start <= stamp <= end
|
|
start, _ = window
|
|
today = datetime.fromtimestamp(start).astimezone().date().isoformat()
|
|
return entry.get("day") == today
|
|
|
|
|
|
def _read_entries(ledger_path: Path, label: str = "LeetCode") -> list[object] | None:
|
|
"""Read the ledger's entry list, or ``None`` when it cannot be read.
|
|
|
|
Args:
|
|
ledger_path: leetcode-guard's (or book-guard's) ledger file.
|
|
label: Whose ledger, for the log line.
|
|
|
|
Returns:
|
|
The raw entries, or ``None`` -- which is never "no entries".
|
|
"""
|
|
try:
|
|
raw = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
except OSError as exc:
|
|
_logger.warning("Cannot read the %s ledger at %s (%s)", label, ledger_path, exc)
|
|
return None
|
|
except ValueError as exc:
|
|
_logger.warning(
|
|
"%s ledger at %s is not valid JSON (%s)", label, ledger_path, exc
|
|
)
|
|
return None
|
|
rows = raw.get("entries") if isinstance(raw, dict) else None
|
|
if not isinstance(rows, list):
|
|
_logger.warning("%s ledger at %s has no entries array", label, ledger_path)
|
|
return None
|
|
return rows
|
|
|
|
|
|
def leetcode_solved_today(
|
|
ledger_path: Path | None = None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> bool | None:
|
|
"""Whether a verified accepted submission landed today, locally.
|
|
|
|
Args:
|
|
ledger_path: The ledger to read. None resolves
|
|
:data:`LEETCODE_LEDGER_FILE` at call time.
|
|
now: Stand-in for the current local time, for tests.
|
|
|
|
Returns:
|
|
True or False when the ledger could be read and verified, ``None``
|
|
when it could not -- which is never "nothing was solved".
|
|
"""
|
|
target = LEETCODE_LEDGER_FILE if ledger_path is None else ledger_path
|
|
try:
|
|
key_usable = bool(HMAC_KEY_FILE.read_bytes().strip())
|
|
except OSError as exc:
|
|
_logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
|
|
return None
|
|
if not key_usable:
|
|
_logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
|
|
return None
|
|
|
|
rows = _read_entries(target)
|
|
if rows is None:
|
|
return None
|
|
|
|
window = _today_window(now)
|
|
return any(
|
|
isinstance(row, dict)
|
|
and row.get("kind") == _CREDIT
|
|
and verify_entry_hmac(row, key_file=HMAC_KEY_FILE)
|
|
and _landed_today(row, window=window)
|
|
for row in rows
|
|
)
|
|
|
|
|
|
def leetcode_bonus_hours(ledger_path: Path | None = None) -> int:
|
|
"""The shutdown hours today's LeetCode solve has earned, failing closed.
|
|
|
|
Args:
|
|
ledger_path: The ledger to read. None resolves the real one.
|
|
|
|
Returns:
|
|
:data:`LEETCODE_BONUS_HOURS` on a day with a verified solve, else 0.
|
|
A ledger that cannot be checked is 0 too, and logged -- it must never
|
|
be mistaken for a confident "not solved", but it earns nothing either.
|
|
"""
|
|
solved = leetcode_solved_today(ledger_path)
|
|
if solved is None:
|
|
_logger.warning("LeetCode solve state could not be checked; no shutdown hour")
|
|
return 0
|
|
return LEETCODE_BONUS_HOURS if solved else 0
|