diff --git a/batch3_bloatware_uninstall.sh b/batch3_bloatware_uninstall.sh new file mode 100755 index 0000000..73c44b3 --- /dev/null +++ b/batch3_bloatware_uninstall.sh @@ -0,0 +1,117 @@ +#!/bin/bash +DEVICE_SERIAL="BL9000EEA0000102" +BACKUP_BASE="/home/kuhy/testsAndMisc_binaries/phone_focus_mode_backups" +APPS_TO_UNINSTALL=("com.android.settings" "com.android.systemui" "com.google.android.gms" "com.google.android.apps.docs" "com.google.android.apps.maps") +SUBSTITUTE_APPS=("com.android.tv" "com.android.managedprovisioning" "com.google.android.apps.fitness" "com.google.android.apps.books" "com.google.android.apps.wellbeing" "com.google.android.apps.mediashell") + +function log_msg() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +function verify_device() { + adb -s "$DEVICE_SERIAL" shell echo "Device OK" &>/dev/null + [ $? -ne 0 ] && log_msg "ERROR: Device not accessible" && exit 1 +} + +function get_app_version() { + adb -s "$DEVICE_SERIAL" shell dumpsys package "$1" 2>/dev/null | grep "versionName=" | head -1 | cut -d'=' -f2 +} + +function app_exists() { + adb -s "$DEVICE_SERIAL" shell pm list packages | grep -q "^package:${1}$" +} + +function get_substitute() { + for sub in "${SUBSTITUTE_APPS[@]}"; do + app_exists "$sub" && echo "$sub" && return 0 + done + return 1 +} + +function execute_checkpoint() { + local pkg=$1 app_num=$2 + log_msg "=========================================" + log_msg "APP #${app_num}: Processing $pkg" + log_msg "=========================================" + + if ! app_exists "$pkg"; then + log_msg "WARNING: $pkg not found. Searching for substitute..." + actual_pkg=$(get_substitute "$pkg") + [ -z "$actual_pkg" ] && log_msg "ERROR: Could not find substitute. Skipping." && return 1 + log_msg "SUBSTITUTING: Using $actual_pkg" + pkg="$actual_pkg" + fi + + TIMESTAMP=$(date +%s) + CHECKPOINT_DIR="${BACKUP_BASE}/checkpoint_${TIMESTAMP}_${pkg}" + mkdir -p "$CHECKPOINT_DIR" + log_msg "Checkpoint: $CHECKPOINT_DIR" + + log_msg "[1/6] Pulling APK..." + adb -s "$DEVICE_SERIAL" shell pm path "$pkg" > "$CHECKPOINT_DIR/package_path.txt" + if grep -q "^package:" "$CHECKPOINT_DIR/package_path.txt"; then + APK_PATH=$(grep "^package:" "$CHECKPOINT_DIR/package_path.txt" | cut -d':' -f2) + pull_log="$CHECKPOINT_DIR/pull_output.txt" + adb -s "$DEVICE_SERIAL" pull "$APK_PATH" "$CHECKPOINT_DIR/app.apk" > "$pull_log" 2>&1 || true + if ! grep -e "Pull" -e "error" "$pull_log"; then + log_msg "APK pulled" + fi + fi + + log_msg "[2/6] Backing up PM state..." + adb -s "$DEVICE_SERIAL" shell dumpsys package "$pkg" > "$CHECKPOINT_DIR/pm_state.txt" + VNAME=$(get_app_version "$pkg") + log_msg "Version: $VNAME" + + log_msg "[3/6] Taking snapshot..." + adb -s "$DEVICE_SERIAL" shell dumpsys activity activities > "$CHECKPOINT_DIR/activities_before.txt" + adb -s "$DEVICE_SERIAL" shell pm list packages > "$CHECKPOINT_DIR/packages_before.txt" + + log_msg "[4/6] Uninstalling: pm uninstall --user 0 $pkg" + adb -s "$DEVICE_SERIAL" shell pm uninstall --user 0 "$pkg" > "$CHECKPOINT_DIR/uninstall_output.txt" 2>&1 + UNINSTALL_RESULT=$(cat "$CHECKPOINT_DIR/uninstall_output.txt") + log_msg "Result: $UNINSTALL_RESULT" + + log_msg "[5/6] Rebooting device..." + adb -s "$DEVICE_SERIAL" reboot + sleep 5 + + REBOOT_TIMEOUT=180 + WAIT_START=$(date +%s) + while true; do + adb -s "$DEVICE_SERIAL" shell echo "up" &>/dev/null && break + [ $(($(date +%s) - WAIT_START)) -ge $REBOOT_TIMEOUT ] && log_msg "ERROR: Timeout" && break + sleep 3 + echo -n "." + done + echo "" + + sleep 5 + adb -s "$DEVICE_SERIAL" shell pm list packages > "$CHECKPOINT_DIR/packages_after.txt" + + if adb -s "$DEVICE_SERIAL" shell pm list packages | grep -q "^package:${pkg}$"; then + log_msg "WARNING: $pkg still present" + else + log_msg "SUCCESS: $pkg uninstalled" + fi + + log_msg "[6/6] Generating report..." + cat > "$CHECKPOINT_DIR/report.txt" <<< "CHECKPOINT REPORT: $pkg (Timestamp: $TIMESTAMP, Device: $DEVICE_SERIAL) - Version: $VNAME - Result: $UNINSTALL_RESULT - Checkpoint: $CHECKPOINT_DIR" + log_msg "✓ Complete" + return 0 +} + +log_msg "=========================================" +log_msg "BATCH 3: BLOATWARE UNINSTALL" +log_msg "=========================================" +verify_device +log_msg "Device verified" + +for i in "${!APPS_TO_UNINSTALL[@]}"; do + execute_checkpoint "${APPS_TO_UNINSTALL[$i]}" $((i + 1)) + [ $((i + 1)) -lt ${#APPS_TO_UNINSTALL[@]} ] && sleep 5 +done + +log_msg "=========================================" +log_msg "BATCH 3 COMPLETE" +log_msg "=========================================" diff --git a/linux_configuration/i3-configuration/i3blocks/config b/linux_configuration/i3-configuration/i3blocks/config index b311faa..ef90ebe 100644 --- a/linux_configuration/i3-configuration/i3blocks/config +++ b/linux_configuration/i3-configuration/i3blocks/config @@ -45,7 +45,7 @@ color=#FFFFFF [battery] command=~/.config/i3blocks/battery_status.sh -interval=1 +interval=5 markup=pango diff --git a/linux_configuration/i3-configuration/i3blocks/network_monitor.sh b/linux_configuration/i3-configuration/i3blocks/network_monitor.sh index 7c845c0..aae049d 100755 --- a/linux_configuration/i3-configuration/i3blocks/network_monitor.sh +++ b/linux_configuration/i3-configuration/i3blocks/network_monitor.sh @@ -34,8 +34,9 @@ total_tx_now=0 total_last_rx=0 total_last_tx=0 -# Initialize time variables -current_time=$(date +%s) +# Initialize time variables without forking: read from /proc/uptime +read -r uptime_s _ < /proc/uptime +current_time=${uptime_s%%.*} last_time=$current_time # Iterate over each interface and accumulate RX and TX bytes @@ -63,8 +64,8 @@ for interface in "${interfaces[@]}"; do total_last_rx=$((total_last_rx + last_rx)) total_last_tx=$((total_last_tx + last_tx)) - # Save current RX and TX bytes for the next check - echo "$rx_now $tx_now $current_time" > "$state_file" + # Save current RX and TX bytes for the next check (using uptime as source) + printf '%s %s %s\n' "$rx_now" "$tx_now" "$current_time" > "$state_file" done # Calculate time difference diff --git a/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh b/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh index 63323c8..f80c743 100755 --- a/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh +++ b/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh @@ -19,6 +19,7 @@ LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism" mkdir -p "$LOG_DIR" 2> /dev/null || true export LOG_FILE="$LOG_DIR/music-parallelism.log" CHECK_INTERVAL=3 +FAST_CHECK_INTERVAL=0.5 # Override focus apps with extended list for this script FOCUS_APPS_WINDOWS=( @@ -182,13 +183,13 @@ notify_user() { log_message "$message" } -# Instant monitoring loop - uses polling at high frequency -# This runs every 0.5 seconds for near-instant detection +# Instant monitoring loop - uses polling at high frequency ONLY when focus app is detected +# When focus app active: checks every 0.5s. When idle: checks every 3s. Reduces fork overhead. instant_monitor_loop() { log_message "=== Music Parallelism INSTANT Monitor Started ===" log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}" log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}" - log_message "Polling every 0.5 seconds for instant kill" + log_message "Polling: 0.5s when focus app active, 3s when idle (optimized for lower fork overhead)" while true; do # Only check if focus app is running @@ -204,8 +205,11 @@ instant_monitor_loop() { pkill -9 -x "spotify" 2> /dev/null || true log_message "INSTANT KILL: Spotify terminated" fi + sleep "$FAST_CHECK_INTERVAL" # High-frequency check while focus app is active + else + # No focus app detected: use longer sleep to reduce fork overhead significantly + sleep 3 fi - sleep 0.5 done } diff --git a/linux_configuration/scripts/digital_wellbeing/setup_midnight_shutdown.sh b/linux_configuration/scripts/digital_wellbeing/setup_midnight_shutdown.sh index 766839c..7e6619f 100755 --- a/linux_configuration/scripts/digital_wellbeing/setup_midnight_shutdown.sh +++ b/linux_configuration/scripts/digital_wellbeing/setup_midnight_shutdown.sh @@ -44,7 +44,13 @@ check_schedule_protection() { canonical_morning_end="${MORNING_END_HOUR:-}" # If canonical values are empty, skip check - if [[ -z $canonical_mon_wed ]] || [[ -z $canonical_thu_sun ]] || [[ -z $canonical_morning_end ]]; then + if [[ -z $canonical_mon_wed ]]; then + return 0 + fi + if [[ -z $canonical_thu_sun ]]; then + return 0 + fi + if [[ -z $canonical_morning_end ]]; then return 0 fi @@ -666,7 +672,7 @@ Requires=day-specific-shutdown.service [Timer] EOF # Evening hours: from earliest shutdown hour to 23:30 - for hour in $(seq "$earliest_hour" 23); do + for hour in $(seq "$earliest_hour" 24); do printf 'OnCalendar=*-*-* %02d:00:00\n' "$hour" printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour" done @@ -810,7 +816,15 @@ fi source "$CONFIG_FILE" # Validate config -if [[ -z "${MON_WED_HOUR:-}" ]] || [[ -z "${THU_SUN_HOUR:-}" ]] || [[ -z "${MORNING_END_HOUR:-}" ]]; then +if [[ -z "${MON_WED_HOUR:-}" ]]; then + logger -t day-specific-shutdown "ERROR: Config file missing required variables" + exit 1 +fi +if [[ -z "${THU_SUN_HOUR:-}" ]]; then + logger -t day-specific-shutdown "ERROR: Config file missing required variables" + exit 1 +fi +if [[ -z "${MORNING_END_HOUR:-}" ]]; then logger -t day-specific-shutdown "ERROR: Config file missing required variables" exit 1 fi @@ -1165,7 +1179,9 @@ test_setup() { echo "" echo "Next scheduled checks:" - systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown || echo "Timer information not available" + if ! systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown; then + echo "Timer information not available" + fi } # Display the shutdown schedule (used in multiple places) diff --git a/phone_focus_mode/README.md b/phone_focus_mode/README.md index e6cd57d..2a4320c 100644 --- a/phone_focus_mode/README.md +++ b/phone_focus_mode/README.md @@ -1,131 +1,186 @@ -## Phone focus mode +# Phone Focus Mode -Rooted-Android hardening + recovery workflow for daily backup/monitoring and -post-format recovery. +Location-based app restriction for a rooted Android phone using wireless ADB. -The visible entrypoint is: - -```bash -./scripts/run_all/run_phone.sh -``` - -That wrapper forwards to `phone_focus_mode/run_phone.sh`, which orchestrates -backup, monitoring, drift repair, and full recovery. - -## Quick usage - -### Normal day - -```bash -./scripts/run_all/run_phone.sh -``` - -This runs `auto` mode: - -- verifies and selects one device (USB or paired wireless ADB) -- checks format indicators first -- if phone appears wiped: prints warning + suggests `fresh-phone`, then exits -- otherwise collects monitoring snapshot, runs incremental backup, applies only - low-risk minor repairs, prints summary - -`auto` never restores APK/media and never re-deploys. - -### After a factory reset - -```bash -./scripts/run_all/run_phone.sh fresh-phone -``` - -This mode: - -- verifies prerequisites (ADB auth, root, Magisk runtime) -- takes pre-change snapshot -- restores security stack by delegating to `deploy.sh` -- restores safe APK/media backup items -- takes post-restore snapshot and prints required manual follow-up steps - -### If something looks wrong - -```bash -./scripts/run_all/run_phone.sh doctor -``` - -This mode: - -- runs monitoring checks -- repairs common drift (daemon restarts, hosts file re-push) -- re-runs deployment only when boot persistence is missing -- avoids broad data restore actions - -### Other modes - -```bash -./scripts/run_all/run_phone.sh backup -./scripts/run_all/run_phone.sh monitor -./scripts/run_all/run_phone.sh --help -``` - -## Device targeting - -Both the wrapper and `deploy.sh` support explicit device selection: - -```bash -ADB_SERIAL= ./scripts/run_all/run_phone.sh auto -ADB_SERIAL= bash phone_focus_mode/deploy.sh --status -``` - -`deploy.sh` still supports the existing phone-IP flow: - -```bash -bash phone_focus_mode/deploy.sh 192.168.1.42 --status -``` +When within ~500m of home: only whitelisted productive apps remain usable. +When outside that radius: all apps work normally. ## Requirements -- rooted phone with Magisk installed -- USB debugging enabled and authorized (or paired wireless ADB) -- `adb` available on PC (`sudo pacman -S android-tools` on Arch Linux) -- location services enabled on phone +- Rooted phone with **Magisk** installed +- Wireless ADB enabled (`Settings → Developer options → Wireless debugging`) +- `adb` installed on your PC (`sudo apt install adb` on Debian/Ubuntu) +- GPS/Location enabled on the phone -## Setup essentials +## Setup (first time) -1. Set home coordinates in `phone_focus_mode/config_secrets.sh`. -2. Optionally tune whitelist and behavior in `phone_focus_mode/config.sh`. -3. Perform initial deploy: +### 1. Find your home coordinates - ```bash - bash phone_focus_mode/deploy.sh - ``` +Open Google Maps, right-click your apartment → copy the coordinates shown. -## Systemd automation (PC user service) +### 2. Edit `config_secrets.sh` -Install timer-based periodic runs: - -```bash -bash phone_focus_mode/systemd/install_pc_phone_automation.sh +```sh +HOME_LAT="-48.876667" # your latitude +HOME_LON="-123.393333" # your longitude ``` -This installs user units under `~/.config/systemd/user/`: +### 3. (Optional) Adjust the whitelist in `config.sh` -- `phone-auto-sync.service` -- `phone-auto-sync.timer` (every 30 minutes, persistent) +To find the exact package name of any app: -## Relevant files +```bash +./deploy.sh --find-pkg stronglift +./deploy.sh --find-pkg anki +./deploy.sh --find-pkg pomodoro +``` -| File | Purpose | -| ------------------------------------- | ------------------------------------------ | -| `scripts/run_all/run_phone.sh` | Thin, visible wrapper for daily use | -| `phone_focus_mode/run_phone.sh` | Main orchestration logic | -| `phone_focus_mode/lib/adb_common.sh` | ADB selection, locking, identity helpers | -| `phone_focus_mode/lib/backup.sh` | Incremental backup logic | -| `phone_focus_mode/lib/monitor.sh` | Security/health checks and reports | -| `phone_focus_mode/lib/restore.sh` | Safe restore helpers used by `fresh-phone` | -| `phone_focus_mode/deploy.sh` | Security-stack deployment primitive | -| `phone_focus_mode/backup_manifest.sh` | Declarative backup/restore scope | +Then add the correct package name to `WHITELIST` in `config.sh`. -## Notes +### 4. Deploy -- Backup scope and restore policies live in `phone_focus_mode/backup_manifest.sh`. -- Sensitive coordinates should stay in `config_secrets.sh` and out of version - control. -- On-device direct control remains available via `focus_ctl.sh`. +```bash +chmod +x deploy.sh +./deploy.sh 192.168.1.42 # replace with your phone's IP +``` + +This: + +1. Pushes all scripts to `/data/local/tmp/focus_mode/` on the device +2. Installs a Magisk `service.d` script so the daemon auto-starts on boot +3. Starts the daemon immediately + +## Usage + +```bash +./deploy.sh --status # Current mode, location, distance from home +./deploy.sh --log # View recent daemon log +./deploy.sh --list # List all apps + whitelist status +./deploy.sh --enable # Force focus mode ON (for testing) +./deploy.sh --disable # Force focus mode OFF +./deploy.sh --stop # Stop daemon entirely (restores all apps) +./deploy.sh --start # Start daemon +./deploy.sh --restart # Restart daemon (picks up config changes) +./deploy.sh --pull-log # Download log file to your PC +``` + +## How it works + +``` +Every 60 seconds: + get_location() ─── dumpsys location ──► lat,lon + │ + ▼ + calc_distance() ─── Haversine formula ──► meters + │ + ├── within radius? ──► enable_focus_mode() + │ pm disable-user all non-whitelisted apps + │ record which apps were disabled + │ + └── outside radius? ──► disable_focus_mode() + pm enable each app in the disabled list +``` + +**Hysteresis:** 50m buffer prevents rapid toggling at the boundary. You must travel +`radius - 50m` inward to trigger lock, and `radius + 50m` outward to unlock. + +**Fail-safe:** If location is unavailable for 5 consecutive checks (~5 minutes), +focus mode is automatically disabled so you can't be locked out. + +**State persistence:** The daemon records exactly which apps _it_ disabled +(in `/data/local/tmp/focus_mode/disabled_by_focus.txt`), so it never accidentally +re-enables apps that were already disabled by the user before focus mode ran. + +## On-device control (without PC) + +From a root terminal app (e.g. Termux + tsu): + +```sh +su --mount-master -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh status' +su --mount-master -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh disable' +``` + +**Why `--mount-master`:** MagiskSU puts each `su -c` session in an isolated +mount namespace by default, so bind mounts made by the hosts enforcer would be +invisible (and `/data/adb/focus_mode/*` checks would fail due to SELinux +interactions). `--mount-master` joins the global namespace where the daemons +(started from Magisk `service.d` at boot) actually live. The boot autostart +script doesn't need this flag because `post-fs-data` already runs there. + +## File layout + +| File | Purpose | +| ------------------- | ------------------------------------------------------ | +| `config.sh` | Coordinates, radius, whitelist, constants | +| `focus_daemon.sh` | Main daemon — runs on device, loops every 60s | +| `focus_ctl.sh` | Control utility — runs on device | +| `hosts_enforcer.sh` | Bind-mounts `hosts.canonical` over `/system/etc/hosts` | +| `magisk_service.sh` | Magisk boot hook → auto-starts both daemons | +| `deploy.sh` | PC-side ADB deployment and control script | + +## Hosts hardening + +A second daemon, `hosts_enforcer.sh`, locks the phone's `/system/etc/hosts` +to the same blocklist installed by `linux_configuration/hosts/install.sh` +on the PC. Three layers: + +1. Canonical copy at `/data/adb/focus_mode/hosts.canonical` is `chattr +i`. +2. It is bind-mounted read-only over `/system/etc/hosts` at boot. +3. A watchdog verifies a sha256 every 15 seconds and restores on mismatch. + +This blocks the common `echo > /etc/hosts` one-liner from a terminal app. +It is NOT a guarantee against a determined root user on the device itself — +a real "impossible without USB" gate would require removing `su` access, +which would break the rest of this system. The watchdog at least ensures +tampering is logged and reverted within ~15s. + +Status and logs: + +```bash +./deploy.sh --hosts-status +./deploy.sh --hosts-log +``` + +## Periodic rescan / Play Store + +The focus daemon now **re-scans every tick** (not just on first entry). If +you re-enable an app via Play Store or `pm enable`, it gets re-disabled +within `CHECK_INTERVAL_FOCUS` seconds. `com.android.vending` (Play Store), +`com.*.packageinstaller`, and popular terminal apps are also uninstalled +`--user 0` in focus mode to close the usual bypass paths. Google Play +Services (`com.google.android.gms`) is left alone so banking apps work. + +## Updating + +After editing `config.sh` (e.g. changing whitelist): + +```bash +./deploy.sh # re-pushes all files +# or just the config: +adb push config.sh /data/local/tmp/focus_mode/config.sh +./deploy.sh --restart +``` + +## Troubleshooting + +**Location always unavailable:** + +- Enable GPS and network location on the phone +- Open Google Maps once to warm up the GPS provider +- The daemon logs every attempt; check with `--log` + +**App won't disable:** + +- Some system apps can't be disabled even as root; they're silently skipped +- Check log for "Failed to disable" warnings + +**Daemon not starting on boot:** + +- Verify Magisk is installed and `service.d` is supported +- Check `/data/adb/service.d/99-focus-mode.sh` exists and is executable +- Some Magisk versions use `/data/adb/post-fs-data.d/` instead; try both + +**Wrong package name in whitelist:** + +- Use `./deploy.sh --find-pkg ` to find the exact package name +- Package names are case-sensitive diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index c0d7914..5b0fead 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -13,7 +13,7 @@ SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" # $0 points to the wrapper path rather than this file's directory. Fall back # to the canonical runtime location if config_secrets is not alongside $0. if [ ! -f "$SCRIPT_DIR/config_secrets.sh" ] && [ -f "/data/local/tmp/focus_mode/config_secrets.sh" ]; then - SCRIPT_DIR="/data/local/tmp/focus_mode" + SCRIPT_DIR="/data/local/tmp/focus_mode" fi . "$SCRIPT_DIR/config_secrets.sh" @@ -68,6 +68,53 @@ export HOSTS_TARGET="/system/etc/hosts" export HOSTS_SHA_FILE="$STATE_DIR/hosts.sha256" export HOSTS_CHECK_INTERVAL=15 export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" +# Workout-variant canonical: same content as $HOSTS_CANONICAL but with all +# YouTube-related domain blocks removed. hosts_enforcer.sh switches to this +# variant when $WORKOUT_ACTIVE_FILE contains "1" (StrongLifts workout in +# progress) and switches back when it contains "0" or is missing. +export HOSTS_CANONICAL_WORKOUT="$STATE_DIR/hosts.canonical.workout" +export HOSTS_SHA_FILE_WORKOUT="$STATE_DIR/hosts.sha256.workout" +# Magisk Systemless Hosts module path. The enforcer keeps this in sync with +# the currently-active canonical so a fresh boot sees the right hosts file. +export HOSTS_MAGISK_MODULE_FILE="/data/adb/modules/hosts/system/etc/hosts" + +# --- Workout detector (see workout_detector.sh) --- +# Polls the StrongLifts SQLite DB to determine whether a workout is in +# progress. A workout is "in progress" iff there is at least one row in +# `workouts` with start>0 AND (finish IS NULL OR finish=0). While true, +# YouTube is unblocked at the hosts level (see HOSTS_CANONICAL_WORKOUT). +export WORKOUT_DETECTOR_INTERVAL=10 +export WORKOUT_ACTIVE_FILE="$STATE_DIR/workout_active" +export WORKOUT_DETECTOR_LOG="$STATE_DIR/workout_detector.log" +# Static aarch64 sqlite3 binary pushed by deploy.sh. Built from the SQLite +# amalgamation against the Android NDK; ~1.6 MB. Stored outside the repo at +# ../testsAndMisc_binaries/phone_focus_mode/sqlite3 per binary-files policy. +export WORKOUT_SQLITE3_BIN="/data/local/tmp/focus_mode/sqlite3" +export WORKOUT_DB_PATH="/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" +# StrongLifts package — must be in $WHITELIST so its DB stays writable while +# focus mode is enforcing. Used here only for status/log clarity. +export WORKOUT_TRIGGER_PACKAGE="com.stronglifts.app" +# Domains unblocked while a workout is in progress. Used by deploy.sh to +# generate $HOSTS_CANONICAL_WORKOUT (each line becomes a `0.0.0.0 ` +# match that is stripped from the canonical) and by focus_ctl.sh status. +# Comments and blank lines ignored. Keep entries lower-case. +export WORKOUT_UNBLOCK_DOMAINS=" +youtube.com +www.youtube.com +m.youtube.com +youtu.be +youtubei.googleapis.com +youtube.googleapis.com +youtube-nocookie.com +www.youtube-nocookie.com +googlevideo.com +ytimg.com +i.ytimg.com +s.ytimg.com +yt3.ggpht.com +yt3.googleusercontent.com +i9.ytimg.com +" # --- DNS enforcer state (see dns_enforcer.sh) --- # The hosts file is only consulted by the *system* resolver. Apps using @@ -123,54 +170,17 @@ export DNS_DOH_IPV6=" 2a10:50c0::ad1:ff 2a10:50c0::ad2:ff " -# Additional content hosts to block at the firewall layer. This is a fallback -# for ROMs where /etc/hosts cannot be mounted (read-only partitions with no -# hosts inode). dns_enforcer.sh resolves these hostnames periodically and -# rejects traffic to their current endpoints on ports 80/443. -export DNS_BLOCK_HOSTS=" -youtube.com -www.youtube.com -m.youtube.com -youtu.be -youtubei.googleapis.com -www.youtube-nocookie.com -googlevideo.com -ytimg.com -" - -# Block network for selected distraction/system apps at firewall level by UID. -# This avoids pm disable/uninstall on system packages (which can destabilize -# boot on some vendor ROMs) while still making the apps effectively unusable. -# -# DNS_BLOCK_PACKAGES_ALWAYS: blocked at all times. Use for hard-distraction -# apps that should never have web access (YouTube app, YouTube Music, the -# stock browser). Hosts-file blocking handles their *content*; the UID rule -# keeps them from using DoH/QUIC fallbacks. -# DNS_BLOCK_PACKAGES_FOCUS_ONLY: blocked only while focus mode is active -# (current_mode.txt = focus). Use for apps that have legitimate use outside -# focus mode (Play Store for installing apps you want, package installer). -export DNS_BLOCK_PACKAGES_ALWAYS=" -com.google.android.youtube -com.google.android.apps.youtube.music -com.android.chrome -" -export DNS_BLOCK_PACKAGES_FOCUS_ONLY=" -com.android.vending -" -# Backwards-compat: code paths still referencing DNS_BLOCK_PACKAGES treat it -# as the always-blocked list. -export DNS_BLOCK_PACKAGES="$DNS_BLOCK_PACKAGES_ALWAYS" # --- Launcher enforcer state (see launcher_enforcer.sh) --- # Keeps Minimalist Phone installed and locked as the default HOME app. # The APK is snapshotted by `deploy.sh --snapshot-launcher` from the # currently-installed copy (user installs once via Aurora/Play). export LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher" -export LAUNCHER_APK="$STATE_DIR/minimalist_launcher.apk" -export LAUNCHER_SHA_FILE="$STATE_DIR/minimalist_launcher.sha256" +export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk" +export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256" # Captured home-activity component (package/.Activity). Saved by # --snapshot-launcher so the enforcer knows which component to pin as HOME. -export LAUNCHER_ACTIVITY_FILE="$STATE_DIR/minimalist_launcher.activity" +export LAUNCHER_ACTIVITY_FILE="/data/adb/focus_mode/minimalist_launcher.activity" # Competing launchers to disable so the "pick a launcher" dialog has # nothing else to offer. Matched exactly; add more with `focus_ctl.sh # launcher-disable-other `. @@ -183,11 +193,6 @@ com.google.android.apps.nexuslauncher " export LAUNCHER_CHECK_INTERVAL=15 export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" -# Boot-time launcher enforcement is intentionally opt-in. Starting it from -# Magisk service.d can strand the phone on a broken HOME configuration if the -# snapshot is stale or the launcher update changed components. Keep this off by -# default and only enable it after verifying the launcher snapshot is healthy. -export LAUNCHER_BOOT_AUTOSTART=0 # ============================================================ # WHITELISTED APPS @@ -212,9 +217,34 @@ com.kuhy.focusstatus com.stronglifts.app com.ichi2.anki com.metrolist.music +org.mozilla.fenix +org.fossify.clock +ws.xsoh.etar +com.fsck.k9 com.kuhy.pomodoro_app com.kuhy.horatio +# --- Default phone/contacts/messages handlers (NEVER disable - boot will +# fall back to the system FallbackHome shim and SystemUI gestures +# break). --- +org.fossify.phone +org.fossify.contacts +org.fossify.messages + +# --- Active launcher (de.thomaskuenneth.benice). Must stay enabled or HOME +# resolves to the system FallbackHome shim. --- +de.thomaskuenneth.benice + +# --- Google system packages that ship in /data/app (so they show up in +# pm-list-packages-3 output) but are required for system stability. --- +com.google.android.safetycore +com.google.android.contactkeys + +# --- User-allowed utilities and communication --- +com.sosauce.cutecalc +org.thoughtcrime.securesms +com.discord + # --- Google system apps (add by name even though they show as system) --- com.google.android.apps.maps com.google.android.calendar @@ -245,9 +275,11 @@ com.microsoft.office.outlook com.google.android.gm ch.protonmail.android com.microsoft.teams +com.facebook.orca -# --- App installation alternative (keep visible in focus mode) --- +# --- App installation alternatives (must stay usable in focus mode) --- com.aurora.store +com.machiav3lli.fdroid # --- Manga reader --- eu.kanade.tachiyomi.sy @@ -293,15 +325,10 @@ export BLOCKED_SYSTEM_APPS=" # pm disable-user state persists across reboots. Android always kills daemon # processes with SIGKILL during shutdown, bypassing the shell cleanup trap. # Any system package left disabled across a reboot can trigger MTK bootloop -# protection → recovery → factory wipe (confirmed: caused 3 wipes on BL9000). +# protection → recovery → factory wipe (confirmed on BL9000). # -# System apps (Chrome, YouTube, Play Store, etc.) are enforced via -# DNS+iptables in dns_enforcer.sh instead — that layer is stateless and -# requires no cleanup on reboot. -# -# Only user-installed 3rd-party apps (pm list packages -3) are safe for -# pm disable-user because the MTK bootloop trigger only fires on missing or -# disabled ROM/system components, not on user-installed packages. +# System/distraction apps are enforced via DNS+iptables in dns_enforcer.sh +# instead of persistent package-disable state. " # --- System / essential packages that must NEVER be disabled --- diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index de5a734..9817fd6 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -224,8 +224,23 @@ do_deploy() { adb_cmd push "$SCRIPT_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh" adb_cmd push "$SCRIPT_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" adb_cmd push "$SCRIPT_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" + adb_cmd push "$SCRIPT_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh" adb_cmd push "$SCRIPT_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" + # ---- sqlite3 binary for workout_detector.sh ---- + # Stored outside the repo (binary-files policy). Built once via the NDK + # against the SQLite amalgamation; see workout_detector.sh comments for + # the recipe. ~1.6 MB stripped, aarch64, PIE, dynamically linked against + # bionic (Android 30+). + SQLITE3_BIN="$SCRIPT_DIR/../../testsAndMisc_binaries/phone_focus_mode/sqlite3" + if [ -f "$SQLITE3_BIN" ]; then + echo " Uploading sqlite3 binary ($(stat -c%s "$SQLITE3_BIN") bytes)..." + adb_cmd push "$SQLITE3_BIN" "/data/local/tmp/focus_stage/sqlite3" + else + echo " WARNING: sqlite3 binary not found at $SQLITE3_BIN" + echo " workout_detector will not function until you build & place it there." + fi + # Generate and upload the canonical hosts file (StevenBlack + custom entries). # This mirrors what linux_configuration/hosts/install.sh installs on the PC. HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/hosts/generate_hosts_file.sh" @@ -240,6 +255,56 @@ do_deploy() { echo " Uploading canonical hosts ($(wc -l < "$HOSTS_TMP") lines)..." adb_cmd push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical" adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256" + + # ---- Workout-variant canonical ---- + # Same content as the full canonical, with all lines that block + # any of $WORKOUT_UNBLOCK_DOMAINS removed. Used by hosts_enforcer + # while a StrongLifts workout is in progress. + HOSTS_WORKOUT_TMP="$(mktemp)" + HOSTS_WORKOUT_SHA_TMP="$(mktemp)" + # Read $WORKOUT_UNBLOCK_DOMAINS from the freshly-staged config.sh + # so the generator and the runtime always agree on the domain set. + UNBLOCK_DOMAINS="$( + # shellcheck disable=SC1091 + ( . "$SCRIPT_DIR/config.sh" >/dev/null 2>&1; printf '%s\n' "$WORKOUT_UNBLOCK_DOMAINS" ) \ + | sed 's/[[:space:]]\{1,\}/\n/g' \ + | grep -vE '^[[:space:]]*(#|$)' \ + | sort -u + )" + if [ -n "$UNBLOCK_DOMAINS" ]; then + # Build an awk regex of exact-match domains anchored as the + # *value* column of a hosts entry (" " possibly + # followed by aliases). We strip any line whose first non-IP + # token matches one of the unblock domains. + python3 - "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" < [aliases...] + if len(parts) >= 2 and any(p.lower() in unblock for p in parts[1:]): + continue + dst.write(line) +PY_EOF + workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")" + printf '%s\n' "$workout_hash" > "$HOSTS_WORKOUT_SHA_TMP" + stripped_lines=$(($(wc -l < "$HOSTS_TMP") - $(wc -l < "$HOSTS_WORKOUT_TMP"))) + echo " Uploading workout-variant hosts (stripped $stripped_lines YouTube lines)..." + adb_cmd push "$HOSTS_WORKOUT_TMP" "/data/local/tmp/focus_stage/hosts.canonical.workout" + adb_cmd push "$HOSTS_WORKOUT_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256.workout" + fi + rm -f "$HOSTS_WORKOUT_TMP" "$HOSTS_WORKOUT_SHA_TMP" + rm -f "$HOSTS_TMP" rm -f "$HOSTS_SHA_TMP" else @@ -267,6 +332,11 @@ do_deploy() { adb_root "cp /data/local/tmp/focus_stage/hosts_enforcer.sh $REMOTE_DIR/hosts_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/dns_enforcer.sh $REMOTE_DIR/dns_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/launcher_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh" + if adb_cmd shell "test -f /data/local/tmp/focus_stage/sqlite3" 2>/dev/null; then + adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3" + adb_root "chmod 0755 $REMOTE_DIR/sqlite3" + fi if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh" else @@ -285,6 +355,22 @@ do_deploy() { adb_root "chattr +i $REMOTE_DIR/hosts.canonical 2>/dev/null; true" adb_root "chattr +i $REMOTE_DIR/hosts.sha256 2>/dev/null; true" + # ---- Workout-variant canonical (optional) ---- + # Same lockdown treatment as the full canonical. Pushed by the workout + # hosts generator block above. Missing variant means workout_detector\ + # will simply have no relaxed file to swap to (hosts_enforcer falls\ + # back to the full canonical). + if adb_cmd shell "test -f /data/local/tmp/focus_stage/hosts.canonical.workout" 2>/dev/null; then + adb_root "chattr -i $REMOTE_DIR/hosts.canonical.workout 2>/dev/null; true" + adb_root "cp /data/local/tmp/focus_stage/hosts.canonical.workout $REMOTE_DIR/hosts.canonical.workout" + adb_root "chmod 644 $REMOTE_DIR/hosts.canonical.workout" + adb_root "chattr -i $REMOTE_DIR/hosts.sha256.workout 2>/dev/null; true" + adb_root "cp /data/local/tmp/focus_stage/hosts.sha256.workout $REMOTE_DIR/hosts.sha256.workout" + adb_root "chmod 644 $REMOTE_DIR/hosts.sha256.workout" + adb_root "chattr +i $REMOTE_DIR/hosts.canonical.workout 2>/dev/null; true" + adb_root "chattr +i $REMOTE_DIR/hosts.sha256.workout 2>/dev/null; true" + fi + # ---- Magisk Systemless Hosts module (REQUIRED) ---- # This module magic-mounts /data/adb/modules/hosts/system/etc/hosts # as /system/etc/hosts at boot — the only way to create that file on @@ -329,13 +415,13 @@ do_deploy() { adb_root "rm -rf /data/local/tmp/focus_stage" echo "[5/7] Setting permissions..." - adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" || true + adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh" fi - adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log" + adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/workout_detector.log" # State files need 666 so the daemons can write regardless of SELinux context drift - adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log" || true + adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/workout_detector.log" || true echo "[6/7] Starting daemons..." # Stop existing daemons, then start fresh @@ -343,17 +429,20 @@ do_deploy() { adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/hosts_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/workout_detector.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "kill \$(cat $REMOTE_DIR/daemon.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/hosts_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/dns_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true" + adb_root "kill \$(cat $REMOTE_DIR/workout_detector.pid 2>/dev/null) 2>/dev/null; true" sleep 1 adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/focus_daemon.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/hosts_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/workout_detector.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" sleep 1 - adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid" + adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid $REMOTE_DIR/workout_detector.pid" # Start hosts enforcer first so hosts are locked before user can react. # Use --mount-master so bind mounts propagate to the global namespace # (where app processes live). Without this, only our isolated `su` session @@ -361,6 +450,12 @@ do_deploy() { if adb_root "test -f $REMOTE_DIR/hosts.canonical" 2>/dev/null; then adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/hosts_enforcer.sh /dev/null 2>/dev/null &' fi + # Start workout detector BEFORE the hosts enforcer's first integrity check + # so the enforcer sees a non-stale workout_active flag. The detector itself + # is harmless if no workout is in progress (it just writes 0). + if adb_root "test -x $REMOTE_DIR/sqlite3" 2>/dev/null; then + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/workout_detector.sh /dev/null 2>/dev/null &' + fi # Start DNS enforcer (forces Private DNS off, blocks DoH/DoT). Always on. adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh /dev/null 2>/dev/null &' # Start launcher enforcer only if a snapshot APK exists. If not, warn the @@ -379,7 +474,15 @@ do_deploy() { APK="$APP_DIR/build/focus_status.apk" if [ -d "$APP_DIR" ]; then echo "[7/7] Building & installing companion status-notification app..." - if [ ! -f "$APK" ] || [ "$APP_DIR/AndroidManifest.xml" -nt "$APK" ] || [ "$APP_DIR/build.sh" -nt "$APK" ]; then + needs_rebuild=0 + if [ ! -f "$APK" ]; then + needs_rebuild=1 + elif [ "$APP_DIR/AndroidManifest.xml" -nt "$APK" ]; then + needs_rebuild=1 + elif [ "$APP_DIR/build.sh" -nt "$APK" ]; then + needs_rebuild=1 + fi + if [ "$needs_rebuild" -eq 1 ]; then echo " Building APK..." (cd "$APP_DIR" && bash build.sh) >/dev/null fi @@ -389,7 +492,10 @@ do_deploy() { # Grant runtime permission (Android 13+ requires it for notifications). adb_cmd shell pm grant com.kuhy.focusstatus android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true # Pre-approve Magisk SU so the app never shows the approval prompt. - APP_UID="$(adb_cmd shell dumpsys package com.kuhy.focusstatus 2>/dev/null | grep -oE 'userId=[0-9]+' | head -1 | cut -d= -f2)" + APP_UID="$( + adb_cmd shell dumpsys package com.kuhy.focusstatus 2>/dev/null \ + | awk 'match($0, /userId=[0-9]+/) {print substr($0, RSTART + 7, RLENGTH - 7); exit}' + )" if [ -n "$APP_UID" ]; then adb_cmd shell "su -c 'magisk --sqlite \"INSERT OR REPLACE INTO policies (uid,policy,until,logging,notification) VALUES ($APP_UID,2,0,1,1)\"'" >/dev/null 2>&1 || true fi diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh index 1235bf0..4d6494b 100755 --- a/phone_focus_mode/dns_enforcer.sh +++ b/phone_focus_mode/dns_enforcer.sh @@ -25,119 +25,16 @@ # leaves tamper logs. # ============================================================ -SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=config.sh . "$SCRIPT_DIR/config.sh" PIDFILE="$STATE_DIR/dns_enforcer.pid" -DNS_BLOCK_IPV4_FILE="$STATE_DIR/dns_block_ipv4.txt" -DNS_BLOCK_IPV6_FILE="$STATE_DIR/dns_block_ipv6.txt" -DNS_BLOCK_UID_FILE="$STATE_DIR/dns_block_uids.txt" mkdir -p "$STATE_DIR" touch "$DNS_LOG" chmod 666 "$DNS_LOG" 2>/dev/null || true -append_unique_line() { - local file="$1" - local value="$2" - - [ -z "$value" ] && return 0 - [ -f "$file" ] || : > "$file" - - if ! grep -qxF "$value" "$file" 2>/dev/null; then - echo "$value" >> "$file" - fi -} - -extract_ping_ip() { - # Extract the host IP from the first line of ping output: - # Ping example.com (1.2.3.4): ... - # Ping example.com (2a00:...): ... - printf '%s\n' "$1" | sed -n 's/^[^(]*(\([^)]*\)).*/\1/p' | head -1 -} - -extract_package_uid() { - # Parse one line from `cmd package list packages -U`, e.g.: - # package:com.android.chrome uid:10153 - printf '%s\n' "$1" | sed -n 's/.* uid:\([0-9][0-9]*\).*/\1/p' | head -1 -} - -resolve_package_uid() { - local pkg="$1" - local line uid - - line="$(cmd package list packages -U 2>/dev/null | grep -E "^package:${pkg}( |$)" | head -1 || true)" - uid="$(extract_package_uid "$line")" - if echo "$uid" | grep -Eq '^[0-9]+$'; then - echo "$uid" - fi -} - -resolve_ipv4() { - local host="$1" - local line ip - - line="$(toybox ping -4 -c 1 -W 1 "$host" 2>/dev/null | head -1 || true)" - ip="$(extract_ping_ip "$line")" - if echo "$ip" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'; then - echo "$ip" - fi -} - -resolve_ipv6() { - local host="$1" - local line ip - - line="$(toybox ping -6 -c 1 -W 1 "$host" 2>/dev/null | head -1 || true)" - ip="$(extract_ping_ip "$line")" - if echo "$ip" | grep -Eq '^[0-9A-Fa-f:]+$'; then - echo "$ip" - fi -} - -refresh_blocked_content_ips() { - : > "$DNS_BLOCK_IPV4_FILE" - : > "$DNS_BLOCK_IPV6_FILE" - - local host ip4 ip6 - for host in $DNS_BLOCK_HOSTS; do - [ -z "$host" ] && continue - [ "${host#\#}" != "$host" ] && continue - - ip4="$(resolve_ipv4 "$host")" - ip6="$(resolve_ipv6 "$host")" - - append_unique_line "$DNS_BLOCK_IPV4_FILE" "$ip4" - append_unique_line "$DNS_BLOCK_IPV6_FILE" "$ip6" - done -} - -refresh_blocked_app_uids() { - : > "$DNS_BLOCK_UID_FILE" - - local pkg uid - # Always-blocked packages (hard distractions: YouTube, Chrome, ...). - for pkg in $DNS_BLOCK_PACKAGES_ALWAYS; do - [ -z "$pkg" ] && continue - [ "${pkg#\#}" != "$pkg" ] && continue - - uid="$(resolve_package_uid "$pkg")" - append_unique_line "$DNS_BLOCK_UID_FILE" "$uid" - done - - # Focus-mode-only packages (Play Store etc. - usable outside focus mode). - if [ "$(cat "$MODE_FILE" 2>/dev/null)" = "focus" ]; then - for pkg in $DNS_BLOCK_PACKAGES_FOCUS_ONLY; do - [ -z "$pkg" ] && continue - [ "${pkg#\#}" != "$pkg" ] && continue - - uid="$(resolve_package_uid "$pkg")" - append_unique_line "$DNS_BLOCK_UID_FILE" "$uid" - done - fi -} - log() { local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" @@ -239,36 +136,6 @@ fill_chain_v4() { iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true done - - # Content-block fallback: reject HTTP/HTTPS to resolved endpoints of - # DNS_BLOCK_HOSTS. This is used on ROMs where hosts-file enforcement is - # impossible (no writable hosts inode on read-only partitions). - if [ -f "$DNS_BLOCK_IPV4_FILE" ]; then - while IFS= read -r ip; do - [ -z "$ip" ] && continue - iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 80 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \ - --reject-with icmp-port-unreachable 2>/dev/null || true - done < "$DNS_BLOCK_IPV4_FILE" - fi - - # App-level web block: block HTTP/HTTPS for selected package UIDs. - # Only ports 80 and 443 are blocked so DNS (port 53) and system services - # still work — the apps just can't load web content or stream video. - if [ -f "$DNS_BLOCK_UID_FILE" ]; then - while IFS= read -r uid; do - [ -z "$uid" ] && continue - iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 80 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 443 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p udp --dport 443 -j REJECT \ - --reject-with icmp-port-unreachable 2>/dev/null || true - done < "$DNS_BLOCK_UID_FILE" - fi } fill_chain_v6() { @@ -291,36 +158,9 @@ fill_chain_v6() { ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true done - - if [ -f "$DNS_BLOCK_IPV6_FILE" ]; then - while IFS= read -r ip; do - [ -z "$ip" ] && continue - ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 80 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \ - --reject-with icmp6-port-unreachable 2>/dev/null || true - done < "$DNS_BLOCK_IPV6_FILE" - fi - - if [ -f "$DNS_BLOCK_UID_FILE" ]; then - while IFS= read -r uid; do - [ -z "$uid" ] && continue - ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 80 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 443 -j REJECT \ - --reject-with tcp-reset 2>/dev/null || true - ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p udp --dport 443 -j REJECT \ - --reject-with icmp6-port-unreachable 2>/dev/null || true - done < "$DNS_BLOCK_UID_FILE" - fi } enforce_iptables() { - refresh_blocked_content_ips - refresh_blocked_app_uids - if command -v iptables >/dev/null 2>&1; then ensure_chain iptables && fill_chain_v4 fi @@ -356,6 +196,4 @@ main() { done } -if [ "${FOCUS_MODE_DNS_ENFORCER_TESTING:-0}" != "1" ]; then - main "$@" -fi +main "$@" diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index a5357ff..7bc2cda 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -14,30 +14,6 @@ SCRIPT_DIR="/data/local/tmp/focus_mode" PIDFILE="$STATE_DIR/daemon.pid" -recover_pidfile() { - local pidfile="$1" - local script_name="$2" - local pid - - if [ -f "$pidfile" ]; then - pid="$(cat "$pidfile" 2>/dev/null)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - echo "$pid" - return 0 - fi - fi - - pid="$(pgrep -f "$script_name" 2>/dev/null | head -1)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - echo "$pid" > "$pidfile" 2>/dev/null || true - chmod 666 "$pidfile" 2>/dev/null || true - echo "$pid" - return 0 - fi - - return 1 -} - # ---- Logging ---- log() { local ts @@ -45,6 +21,33 @@ log() { echo "[$ts] $1" >> "$LOG_FILE" } +# Emit one valid package name per line from WHITELIST. +# This strips comments/blank lines from the multi-line quoted string and avoids +# treating heading text (e.g. "---") as package tokens. +iter_whitelist_packages() { + printf '%s\n' "$WHITELIST" | while IFS= read -r line; do + line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + case "$line" in + ""|\#*) continue ;; + esac + + # Keep first token only; ignore any inline prose if present. + set -- $line + pkg="$1" + + # Package names are dot-delimited identifiers. + case "$pkg" in + *.*) ;; + *) continue ;; + esac + case "$pkg" in + *[!A-Za-z0-9._]*) continue ;; + esac + + echo "$pkg" + done +} + usage() { echo "Usage: focus_ctl.sh " echo "" @@ -71,6 +74,10 @@ usage() { echo " launcher-stop - Stop the launcher enforcer daemon" echo " launcher-log - Show launcher enforcer log" echo " launcher-snapshot - Back up currently-installed launcher APK" + echo " workout-status - Show StrongLifts workout-detection state" + echo " workout-start - Start the workout detector daemon" + echo " workout-stop - Stop the workout detector daemon (sets flag=0)" + echo " workout-log - Show workout detector log" echo " recheck - Nudge the daemon to perform a fresh location check now" echo " notif-status - Show companion status-notification details" echo "" @@ -78,7 +85,13 @@ usage() { # Helper to check if daemon is running daemon_pid() { - recover_pidfile "$PIDFILE" "focus_daemon.sh" + if [ -f "$PIDFILE" ]; then + local pid + pid="$(cat "$PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi } cmd_start() { @@ -170,7 +183,7 @@ cmd_enable() { for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do # Check whitelist whitelisted=0 - for w in $WHITELIST; do + for w in $(iter_whitelist_packages); do w_clean="$(echo "$w" | tr -d '[:space:]')" [ -z "$w_clean" ] && continue [ "$pkg" = "$w_clean" ] && { whitelisted=1; break; } @@ -253,7 +266,7 @@ cmd_list_apps() { echo "=== Third-party apps NOT in whitelist ===" for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do whitelisted=0 - for w in $WHITELIST; do + for w in $(iter_whitelist_packages); do w="$(echo "$w" | tr -d '[:space:]')" [ -z "$w" ] && continue [ "$pkg" = "$w" ] && { whitelisted=1; break; } @@ -269,7 +282,7 @@ cmd_list_apps() { done echo "" echo "=== Whitelisted apps ===" - for w in $WHITELIST; do + for w in $(iter_whitelist_packages); do w="$(echo "$w" | tr -d '[:space:]')" [ -z "$w" ] && continue echo " [allowed] $w" @@ -278,7 +291,7 @@ cmd_list_apps() { cmd_whitelist() { echo "=== Whitelisted packages ===" - for w in $WHITELIST; do + for w in $(iter_whitelist_packages); do w="$(echo "$w" | tr -d '[:space:]')" [ -z "$w" ] && continue # Check if installed @@ -293,7 +306,13 @@ cmd_whitelist() { HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid" hosts_enforcer_pid() { - recover_pidfile "$HOSTS_PIDFILE" "hosts_enforcer.sh" + if [ -f "$HOSTS_PIDFILE" ]; then + local pid + pid="$(cat "$HOSTS_PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi } cmd_hosts_status() { @@ -380,7 +399,13 @@ cmd_hosts_log() { DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid" dns_enforcer_pid() { - recover_pidfile "$DNS_PIDFILE" "dns_enforcer.sh" + if [ -f "$DNS_PIDFILE" ]; then + local pid + pid="$(cat "$DNS_PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi } cmd_dns_status() { @@ -467,7 +492,13 @@ LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid" DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt" launcher_enforcer_pid() { - recover_pidfile "$LAUNCHER_PIDFILE" "launcher_enforcer.sh" + if [ -f "$LAUNCHER_PIDFILE" ]; then + local pid + pid="$(cat "$LAUNCHER_PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi } cmd_launcher_snapshot() { @@ -601,6 +632,120 @@ cmd_launcher_log() { fi } +# ---- Workout detector ---- + +WORKOUT_PIDFILE="$STATE_DIR/workout_detector.pid" + +workout_detector_pid() { + if [ -f "$WORKOUT_PIDFILE" ]; then + local pid + pid="$(cat "$WORKOUT_PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi +} + +cmd_workout_status() { + local pid + pid="$(workout_detector_pid)" + echo "=== Workout Detector Status ===" + if [ -n "$pid" ]; then + echo "Daemon: RUNNING (PID $pid)" + else + echo "Daemon: STOPPED" + fi + echo "Package: $WORKOUT_TRIGGER_PACKAGE" + if pm path "$WORKOUT_TRIGGER_PACKAGE" >/dev/null 2>&1; then + echo "Installed: YES" + else + echo "Installed: NO (detector will always report inactive)" + fi + echo "sqlite3: $WORKOUT_SQLITE3_BIN" + if [ -x "$WORKOUT_SQLITE3_BIN" ]; then + echo "sqlite3 ver: $("$WORKOUT_SQLITE3_BIN" -version 2>/dev/null | awk '{print $1}')" + else + echo "sqlite3 ver: " + fi + echo "DB path: $WORKOUT_DB_PATH" + if [ -f "$WORKOUT_DB_PATH" ]; then + echo "DB present: YES" + else + echo "DB present: NO" + fi + echo "Poll interval: ${WORKOUT_DETECTOR_INTERVAL}s" + local flag="" + if [ -f "$WORKOUT_ACTIVE_FILE" ]; then + flag="$(cat "$WORKOUT_ACTIVE_FILE" 2>/dev/null)" + fi + case "$flag" in + 1) echo "Workout flag: 1 (workout IN PROGRESS → YouTube hosts UNBLOCKED)" ;; + 0) echo "Workout flag: 0 (no workout → YouTube hosts BLOCKED)" ;; + *) echo "Workout flag: '$flag' (treated as 0, fail-closed)" ;; + esac + # Live one-shot query so the user can see ground truth without waiting + # for the next poll cycle. Best-effort — never fails the status command. + if [ -x "$WORKOUT_SQLITE3_BIN" ] && [ -f "$WORKOUT_DB_PATH" ]; then + local live_count + live_count="$("$WORKOUT_SQLITE3_BIN" "file:${WORKOUT_DB_PATH}?mode=ro" \ + "SELECT COUNT(*) FROM workouts WHERE start>0 AND (finish IS NULL OR finish=0);" \ + 2>/dev/null)" + echo "Live DB query: in-progress workouts = ${live_count:-}" + fi + if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then + echo "Workout hosts: $HOSTS_CANONICAL_WORKOUT ($(wc -l < "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null) lines)" + else + echo "Workout hosts: " + fi +} + +cmd_workout_start() { + local pid + pid="$(workout_detector_pid)" + if [ -n "$pid" ]; then + echo "Workout detector already running (PID $pid)" + return + fi + if [ ! -x "$WORKOUT_SQLITE3_BIN" ]; then + echo "ERROR: $WORKOUT_SQLITE3_BIN missing or not executable. Re-run deploy.sh." + return 1 + fi + setsid sh "$SCRIPT_DIR/workout_detector.sh" /dev/null 2>&1 & + sleep 2 + pid="$(workout_detector_pid)" + if [ -n "$pid" ]; then + echo "Workout detector started (PID $pid)" + else + echo "ERROR: Workout detector failed to start. Check log: $WORKOUT_DETECTOR_LOG" + fi +} + +cmd_workout_stop() { + local pid + pid="$(workout_detector_pid)" + if [ -z "$pid" ]; then + echo "Workout detector not running" + rm -f "$WORKOUT_PIDFILE" + else + kill -TERM "$pid" + echo "Workout detector stopped (sent SIGTERM to PID $pid)" + fi + # Fail-closed on manual stop: write 0 so the hosts enforcer reverts to + # the full-block canonical and YouTube goes back to being blocked. + printf '0\n' > "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true + chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true + echo "workout_active flag forced to 0" +} + +cmd_workout_log() { + local lines="${1:-50}" + if [ -f "$WORKOUT_DETECTOR_LOG" ]; then + tail -n "$lines" "$WORKOUT_DETECTOR_LOG" + else + echo "Workout detector log not found: $WORKOUT_DETECTOR_LOG" + fi +} + case "$1" in start) cmd_start ;; stop) cmd_stop ;; @@ -624,6 +769,10 @@ case "$1" in launcher-stop) cmd_launcher_stop ;; launcher-log) cmd_launcher_log "${2:-50}" ;; launcher-snapshot) cmd_launcher_snapshot ;; + workout-status) cmd_workout_status ;; + workout-start) cmd_workout_start ;; + workout-stop) cmd_workout_stop ;; + workout-log) cmd_workout_log "${2:-50}" ;; recheck) cmd_recheck ;; notif-status) cmd_notif_status ;; *) usage ;; diff --git a/phone_focus_mode/focus_daemon.sh b/phone_focus_mode/focus_daemon.sh index 75e6ed9..fb1f9b5 100755 --- a/phone_focus_mode/focus_daemon.sh +++ b/phone_focus_mode/focus_daemon.sh @@ -54,6 +54,17 @@ rotate_log() { build_whitelist_file() { echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/whitelist.txt" + # Sanity check: the WHITELIST string in config.sh is fragile - any + # literal double-quote inside a comment will close the heredoc and + # silently truncate the variable. Log the parsed line count so any + # future regression is visible in the log, and warn loudly if it + # falls below a known floor (we always have ~70+ entries). + local n + n=$(wc -l < "$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ') + log "Whitelist parsed: $n entries" + if [ "${n:-0}" -lt 30 ]; then + log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string" + fi } build_sysprotect_file() { @@ -112,6 +123,7 @@ init() { build_whitelist_file build_sysprotect_file + refresh_default_handlers rotate_log if [ -f "$MODE_FILE" ]; then @@ -168,9 +180,51 @@ is_allowed() { "$prefix"*) return 0 ;; esac done < "$STATE_DIR/sysprotect.txt" + # Hard-stop guard: refuse to disable any package that is the current + # default handler for a critical role (Dialer / SMS / Home / Contacts). + # Without this, a misconfigured WHITELIST can disable the default Phone + # app and Android falls back to com.android.settings/.FallbackHome - + # the persistent "Phone is starting..." screen with broken SystemUI + # gestures (no swipe-up recents). Recovering requires `pm enable` over + # ADB. Treat the guard as last-resort safety net independent of WHITELIST + # contents so a future config edit can never wipe these out. + is_default_handler "$pkg" && return 0 return 1 } +# ---- Default handler detection ---- +# Refreshed once per focus_daemon tick into $STATE_DIR/default_handlers.txt. +# Each line is a package name. Lookup is a cheap grep against this file. +refresh_default_handlers() { + local f="$STATE_DIR/default_handlers.txt" + local tmp="$f.tmp" + : > "$tmp" + # Default Home (launcher). resolve-activity prints "Activity Resolver Table:" + # on line 1 and "/<.Activity>" on line 2 in --brief mode. + cmd package resolve-activity --brief \ + -c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null \ + | awk -F/ 'NR==2 && $1 != "" {print $1}' >> "$tmp" + # Default Dialer + local dialer + dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')" + [ -n "$dialer" ] && echo "$dialer" >> "$tmp" + # Default SMS handler (settings provider key) + local sms + sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')" + [ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >> "$tmp" + # Default Browser handler (resolve-activity for VIEW http://) + cmd package resolve-activity --brief \ + -a android.intent.action.VIEW -d http://example.com 2>/dev/null \ + | awk -F/ 'NR==2 && $1 != "" {print $1}' >> "$tmp" + sort -u "$tmp" -o "$f" + rm -f "$tmp" +} + +is_default_handler() { + local pkg="$1" + grep -qxF "$pkg" "$STATE_DIR/default_handlers.txt" 2>/dev/null +} + # ---- Focus Mode Control ---- enable_focus_mode() { @@ -181,6 +235,11 @@ enable_focus_mode() { : > "$DISABLED_APPS_FILE" fi + # Refresh default-handler list every tick. The user may switch dialer / + # SMS / launcher between sweeps; the guard in is_allowed() consults this + # list so a newly-promoted handler is never disabled. + refresh_default_handlers + # Build blocked system app list (used both at entry and for periodic sweep) local blocked_sys="$STATE_DIR/blocked_sys.txt" echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java index 9233d1c..21bddad 100644 --- a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java @@ -19,7 +19,7 @@ import android.os.Looper; */ public final class StatusService extends Service { - private static final String CHANNEL_ID = "focus_status_persistent"; + private static final String CHANNEL_ID = "focus_status"; private static final int NOTIF_ID = 1042; private static final long REFRESH_MS = 5_000L; @@ -87,8 +87,8 @@ public final class StatusService extends Service { return; } NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, "Focus Mode Status", - NotificationManager.IMPORTANCE_DEFAULT); + CHANNEL_ID, "Focus Mode Status", + NotificationManager.IMPORTANCE_LOW); ch.setDescription("Persistent status of the focus-mode daemon"); ch.setShowBadge(false); ch.setSound(null, null); diff --git a/phone_focus_mode/hosts_enforcer.sh b/phone_focus_mode/hosts_enforcer.sh index 67e2a1d..858b59c 100755 --- a/phone_focus_mode/hosts_enforcer.sh +++ b/phone_focus_mode/hosts_enforcer.sh @@ -28,14 +28,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/config.sh" PIDFILE="$STATE_DIR/hosts_enforcer.pid" -MISSING_TARGET_LOGGED=0 -MAGISK_HOSTS_LOGGED=0 -# Magisk "Systemless Hosts" module path. When this module is enabled, -# Magisk magic-mounts files placed under its system/ tree onto the live -# /system at boot. Copying our canonical hosts there makes Magisk overlay -# /system/etc/hosts on next boot, even on read-only system partitions. -MAGISK_HOSTS_MODULE_DIR="/data/adb/modules/hosts" -MAGISK_HOSTS_TARGET="$MAGISK_HOSTS_MODULE_DIR/system/etc/hosts" mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")" touch "$HOSTS_LOG" @@ -83,24 +75,48 @@ sha256_of() { fi } +# ---- Workout-aware canonical selection ---- +# When workout_detector.sh writes "1" to $WORKOUT_ACTIVE_FILE, switch to +# the YouTube-relaxed canonical. Any other value (including missing file or +# unreadable) falls back to the full-block canonical (fail-closed). +workout_active() { + [ -f "$WORKOUT_ACTIVE_FILE" ] || return 1 + local v + v="$(cat "$WORKOUT_ACTIVE_FILE" 2>/dev/null | tr -d '[:space:]')" + [ "$v" = "1" ] +} + +current_canonical() { + if workout_active && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then + echo "$HOSTS_CANONICAL_WORKOUT" + else + echo "$HOSTS_CANONICAL" + fi +} + +current_sha_file() { + if workout_active && [ -f "$HOSTS_SHA_FILE_WORKOUT" ]; then + echo "$HOSTS_SHA_FILE_WORKOUT" + else + echo "$HOSTS_SHA_FILE" + fi +} + is_bind_mounted_correctly() { # Android devices often already have /system/etc/hosts as its own mount # point (OEM overlay / f2fs block). A mere "path is in /proc/self/mounts" # check is not enough - we must verify the mounted content matches our - # canonical by hash. Otherwise we'd accept OEM mounts as our own. + # currently-active canonical by hash (which depends on workout state). if [ ! -f "$HOSTS_TARGET" ]; then return 1 fi - local target_hash canonical_hash + local target_hash canonical_hash canonical + canonical="$(current_canonical)" target_hash="$(sha256_of "$HOSTS_TARGET")" - canonical_hash="$(sha256_of "$HOSTS_CANONICAL")" + canonical_hash="$(sha256_of "$canonical")" [ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ] } -has_hosts_target() { - [ -f "$HOSTS_TARGET" ] -} - unmount_existing_hosts_mount() { # If anything else is already mounted on /system/etc/hosts (OEM overlay # or a previous failed bind), unmount it so we can take its place. @@ -121,49 +137,34 @@ unmount_existing_hosts_mount() { make_target_writable_once() { # /system is usually mounted read-only. Make it rw just long enough # to overwrite HOSTS_TARGET with the canonical content, then remount ro. - local system_mount + local system_mount canonical + canonical="$(current_canonical)" system_mount="$(awk '$2=="/system"{print $2; exit}' /proc/self/mounts)" if [ -z "$system_mount" ]; then system_mount="/system" fi mount -o remount,rw "$system_mount" 2>/dev/null || true chattr -i "$HOSTS_TARGET" 2>/dev/null || true - cp "$HOSTS_CANONICAL" "$HOSTS_TARGET" 2>/dev/null || true + cp "$canonical" "$HOSTS_TARGET" 2>/dev/null || true chmod 644 "$HOSTS_TARGET" 2>/dev/null || true chattr +i "$HOSTS_TARGET" 2>/dev/null || true mount -o remount,ro "$system_mount" 2>/dev/null || true } assert_bind_mount() { - if ! has_hosts_target; then - # Target file doesn't exist yet - try to create it by directly writing - # /system (remount rw briefly). On Magisk-rooted devices this usually - # works because Magisk intercepts the remount. If it fails we fall back - # to the firewall-only path and log a warning. - log "hosts target missing - attempting to create $HOSTS_TARGET via /system remount" - make_target_writable_once - if ! has_hosts_target; then - if [ "$MISSING_TARGET_LOGGED" -eq 0 ]; then - log "WARN: could not create $HOSTS_TARGET on this ROM (hosts bind enforcement disabled)" - MISSING_TARGET_LOGGED=1 - fi - return 0 - fi - log "Created and populated $HOSTS_TARGET directly" - return 0 - fi - if is_bind_mounted_correctly; then return 0 fi # Something is in the way (OEM overlay or previous partial mount). unmount_existing_hosts_mount + local canonical + canonical="$(current_canonical)" # Try plain bind mount - no remount-rw of /system needed. - # Android toybox mount commonly supports "-o bind" but not "--bind". - if mount -o bind "$HOSTS_CANONICAL" "$HOSTS_TARGET" 2>/dev/null; then + if mount --bind "$canonical" "$HOSTS_TARGET" 2>/dev/null; then mount -o remount,ro,bind "$HOSTS_TARGET" 2>/dev/null || true if is_bind_mounted_correctly; then - log "Bind-mounted $HOSTS_CANONICAL over $HOSTS_TARGET" + log "Bind-mounted $canonical over $HOSTS_TARGET" + sync_magisk_module "$canonical" return 0 fi log "Bind mount reported success but target still mismatches - unmounting" @@ -173,94 +174,82 @@ assert_bind_mount() { log "Bind mount failed - falling back to direct overwrite" make_target_writable_once if is_bind_mounted_correctly; then + sync_magisk_module "$canonical" return 0 fi return 1 } +# Keep the Magisk Systemless Hosts module file in sync with the currently +# active canonical so that a future reboot mounts the correct variant. We +# only rewrite when the contents differ (cheap hash compare) to avoid +# touching the module dir on every loop iteration. +sync_magisk_module() { + local canonical="$1" + [ -n "$canonical" ] && [ -f "$canonical" ] || return 0 + [ -d "$(dirname "$HOSTS_MAGISK_MODULE_FILE")" ] || return 0 + local module_hash canonical_hash + module_hash="$(sha256_of "$HOSTS_MAGISK_MODULE_FILE")" + canonical_hash="$(sha256_of "$canonical")" + if [ "$module_hash" != "$canonical_hash" ]; then + cp "$canonical" "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || return 0 + chmod 644 "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true + log "Synced Magisk module hosts to $(basename "$canonical")" + fi +} + ensure_canonical_immutable() { + # Lock both canonical variants — whichever is currently active and the + # other one (so a future workout transition is just as tamper-resistant). chmod 644 "$HOSTS_CANONICAL" 2>/dev/null || true chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true -} - -# Populate the Magisk "Systemless Hosts" module. Magisk's magic mount picks -# up files under /data/adb/modules//system/ at boot and overlays them -# onto the live /system tree. By placing our canonical hosts there we get -# /system/etc/hosts on next boot even on ROMs whose system partition is -# truly read-only (where remount,rw silently fails). -# Returns 0 if module is present and now in sync, 1 otherwise. -populate_magisk_hosts_module() { - if [ ! -d "$MAGISK_HOSTS_MODULE_DIR" ]; then - if [ "$MAGISK_HOSTS_LOGGED" -eq 0 ]; then - log "WARN: Magisk hosts module dir absent ($MAGISK_HOSTS_MODULE_DIR); enable 'Systemless Hosts' in the Magisk app." - MAGISK_HOSTS_LOGGED=1 - fi - return 1 + if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then + chmod 644 "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true + chattr +i "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true fi - if [ -f "$MAGISK_HOSTS_MODULE_DIR/disable" ] || [ -f "$MAGISK_HOSTS_MODULE_DIR/remove" ]; then - if [ "$MAGISK_HOSTS_LOGGED" -eq 0 ]; then - log "WARN: Magisk hosts module is disabled or pending removal" - MAGISK_HOSTS_LOGGED=1 - fi - return 1 - fi - mkdir -p "$(dirname "$MAGISK_HOSTS_TARGET")" 2>/dev/null || true - local module_hash canonical_hash - module_hash="$(sha256_of "$MAGISK_HOSTS_TARGET")" - canonical_hash="$(sha256_of "$HOSTS_CANONICAL")" - if [ -n "$module_hash" ] && [ "$module_hash" = "$canonical_hash" ]; then - return 0 - fi - if cp "$HOSTS_CANONICAL" "$MAGISK_HOSTS_TARGET" 2>/dev/null; then - chmod 644 "$MAGISK_HOSTS_TARGET" 2>/dev/null || true - log "Synced canonical hosts -> Magisk module ($MAGISK_HOSTS_TARGET); active after next reboot" - MAGISK_HOSTS_LOGGED=0 - return 0 - fi - log "ERROR: failed to copy canonical hosts to $MAGISK_HOSTS_TARGET" - return 1 } verify_and_restore() { - if [ ! -f "$HOSTS_CANONICAL" ]; then - log "ERROR: canonical hosts missing at $HOSTS_CANONICAL" + local canonical sha_file + canonical="$(current_canonical)" + sha_file="$(current_sha_file)" + + if [ ! -f "$canonical" ]; then + log "ERROR: canonical hosts missing at $canonical" return 1 fi local expected - expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)" + expected="$(cat "$sha_file" 2>/dev/null)" if [ -z "$expected" ]; then - expected="$(sha256_of "$HOSTS_CANONICAL")" - echo "$expected" > "$HOSTS_SHA_FILE" - chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true - chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true + expected="$(sha256_of "$canonical")" + echo "$expected" > "$sha_file" + chmod 644 "$sha_file" 2>/dev/null || true + chattr +i "$sha_file" 2>/dev/null || true fi # Canonical integrity check local actual_canonical - actual_canonical="$(sha256_of "$HOSTS_CANONICAL")" + actual_canonical="$(sha256_of "$canonical")" if [ "$actual_canonical" != "$expected" ]; then - log "TAMPER: canonical hash mismatch (expected $expected, got $actual_canonical)" + log "TAMPER: $(basename "$canonical") hash mismatch (expected $expected, got $actual_canonical)" # We cannot fix the canonical from here - it is the source of truth. # Just log and continue; deploy.sh must re-push. return 1 fi - if ! has_hosts_target; then - if [ "$MISSING_TARGET_LOGGED" -eq 0 ]; then - log "WARN: hosts target missing on this ROM: $HOSTS_TARGET (integrity checks skipped)" - MISSING_TARGET_LOGGED=1 - fi - return 0 - fi - - MISSING_TARGET_LOGGED=0 - - # Live target integrity check + # Live target integrity check. Mismatch can mean either tampering OR a + # legitimate workout-state transition that swapped the active canonical. + # In both cases the fix is the same: re-assert the bind mount with the + # currently-active canonical. local actual_target actual_target="$(sha256_of "$HOSTS_TARGET")" if [ "$actual_target" != "$expected" ]; then - log "TAMPER: $HOSTS_TARGET hash mismatch - restoring" + if workout_active; then + log "Workout-active swap: $HOSTS_TARGET differs from workout canonical - re-mounting" + else + log "TAMPER or post-workout swap: $HOSTS_TARGET hash mismatch - restoring" + fi assert_bind_mount fi } @@ -278,21 +267,22 @@ main() { log "hosts_enforcer started (PID=$$)" ensure_canonical_immutable - # Seed the Magisk systemless hosts module so /system/etc/hosts gets - # magic-mounted on next boot. - populate_magisk_hosts_module || true - # Initial assertion (covers the case where target already exists). + # Initial assertion assert_bind_mount || true - # Seed sha file if missing - if [ ! -f "$HOSTS_SHA_FILE" ]; then + # Seed sha files if missing — one per canonical variant. + if [ ! -f "$HOSTS_SHA_FILE" ] && [ -f "$HOSTS_CANONICAL" ]; then sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE" chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true fi + if [ ! -f "$HOSTS_SHA_FILE_WORKOUT" ] && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then + sha256_of "$HOSTS_CANONICAL_WORKOUT" > "$HOSTS_SHA_FILE_WORKOUT" + chmod 644 "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true + chattr +i "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true + fi while true; do - populate_magisk_hosts_module || true verify_and_restore rotate_log sleep "$HOSTS_CHECK_INTERVAL" diff --git a/phone_focus_mode/magisk_service.sh b/phone_focus_mode/magisk_service.sh index dd1e0be..22ba970 100755 --- a/phone_focus_mode/magisk_service.sh +++ b/phone_focus_mode/magisk_service.sh @@ -6,177 +6,42 @@ # Magisk executes everything in service.d on boot with root. # ============================================================ -set -eu +# Wait for system to be fully booted before starting daemons +sleep 120 -SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-/data/local/tmp/focus_mode}" +SCRIPT_DIR="/data/local/tmp/focus_mode" -load_launcher_config() { - if [ -f "$SCRIPT_DIR/config.sh" ]; then - export FOCUS_MODE_SCRIPT_DIR="$SCRIPT_DIR" - # shellcheck source=/dev/null - . "$SCRIPT_DIR/config.sh" - return 0 - fi +# Ensure scripts are executable +chmod +x "$SCRIPT_DIR/focus_daemon.sh" +chmod +x "$SCRIPT_DIR/focus_ctl.sh" +chmod +x "$SCRIPT_DIR/hosts_enforcer.sh" +chmod +x "$SCRIPT_DIR/dns_enforcer.sh" +chmod +x "$SCRIPT_DIR/launcher_enforcer.sh" +chmod +x "$SCRIPT_DIR/workout_detector.sh" 2>/dev/null +chmod +x "$SCRIPT_DIR/sqlite3" 2>/dev/null - return 1 -} +# Start hosts enforcer FIRST - it must bind-mount the hosts file before +# the user has a chance to exploit it. This runs even outside focus mode +# because hosts hardening should always be active. +setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" /dev/null 2>&1 & -boot_config_ready() { - [ -f "$SCRIPT_DIR/config.sh" ] -} - -launcher_boot_autostart_enabled() { - load_launcher_config || return 1 - [ "${LAUNCHER_BOOT_AUTOSTART:-0}" = "1" ] -} - -launcher_boot_snapshot_ready() { - load_launcher_config || return 1 - [ -s "${LAUNCHER_APK:-}" ] && [ -s "${LAUNCHER_ACTIVITY_FILE:-}" ] -} - -should_start_boot_stack() { - load_launcher_config || return 1 - [ "${FOCUS_BOOT_AUTOSTART:-0}" = "1" ] -} - -boot_delay_seconds() { - load_launcher_config || { - echo 10 - return 0 - } - - raw_delay="${FOCUS_BOOT_DELAY_SECONDS:-10}" - case "$raw_delay" in - ''|*[!0-9]*) - echo 10 - return 0 - ;; - esac - - # Safety cap requested by user: keep post-boot delay short. - if [ "$raw_delay" -gt 10 ]; then - echo 10 - return 0 - fi - - echo "$raw_delay" -} - -boot_emergency_disable_file() { - load_launcher_config || { - echo "$SCRIPT_DIR/disable_boot_autostart" - return 0 - } - - echo "${FOCUS_BOOT_EMERGENCY_DISABLE_FILE:-$SCRIPT_DIR/disable_boot_autostart}" -} - -boot_emergency_disabled() { - marker_file="$(boot_emergency_disable_file)" - [ -f "$marker_file" ] -} - -wait_for_boot_completed() { - elapsed=0 - max_wait="${FOCUS_BOOT_WAIT_MAX_SECONDS:-180}" - - while [ "$elapsed" -lt "$max_wait" ]; do - if [ "$(getprop sys.boot_completed 2>/dev/null || true)" = "1" ]; then - return 0 - fi - sleep 1 - elapsed=$((elapsed + 1)) - done - - return 1 -} - -wait_for_boot_config() { - elapsed=0 - max_wait="${FOCUS_BOOT_WAIT_MAX_SECONDS:-180}" - - while [ "$elapsed" -lt "$max_wait" ]; do - if boot_config_ready; then - return 0 - fi - sleep 1 - elapsed=$((elapsed + 1)) - done - - return 1 -} - -should_start_launcher_enforcer() { - launcher_boot_autostart_enabled && launcher_boot_snapshot_ready -} - -safe_chmod() { - if [ -f "$1" ]; then - chmod +x "$1" - fi -} - -start_launcher_enforcer_if_safe() { - if should_start_launcher_enforcer; then - setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & - return 0 - fi - - return 1 -} - -main() { - if ! wait_for_boot_config; then - exit 0 - fi - - if ! should_start_boot_stack; then - exit 0 - fi - - if boot_emergency_disabled; then - exit 0 - fi - - if ! wait_for_boot_completed; then - exit 0 - fi - - sleep "$(boot_delay_seconds)" - - if boot_emergency_disabled; then - exit 0 - fi - - # Ensure scripts are executable. - safe_chmod "$SCRIPT_DIR/focus_daemon.sh" - safe_chmod "$SCRIPT_DIR/focus_ctl.sh" - safe_chmod "$SCRIPT_DIR/hosts_enforcer.sh" - safe_chmod "$SCRIPT_DIR/dns_enforcer.sh" - safe_chmod "$SCRIPT_DIR/launcher_enforcer.sh" - - # Start hosts enforcer FIRST - it must bind-mount the hosts file before - # the user has a chance to exploit it. This runs even outside focus mode - # because hosts hardening should always be active. - setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" /dev/null 2>&1 & - - # Start DNS enforcer - forces Private DNS off and blocks DoH/DoT endpoints - # so the hosts file actually gets consulted by apps that would otherwise - # bypass it (e.g. Chrome's built-in secure DNS). Always on. - setsid sh "$SCRIPT_DIR/dns_enforcer.sh" /dev/null 2>&1 & - - # Start launcher enforcer only when boot autostart is explicitly enabled - # and a valid launcher snapshot exists. This avoids boot loops or a blank - # HOME screen caused by stale launcher state after OTA updates/resets. - start_launcher_enforcer_if_safe || true - - # Start focus daemon in a new session (detached from any controlling terminal). - setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 & - - exit 0 -} - -if [ "${FOCUS_MODE_MAGISK_SERVICE_TESTING:-0}" != "1" ]; then - main "$@" +# Start workout detector early so the hosts enforcer's first integrity +# check sees the correct workout_active flag. The detector itself is +# harmless when no workout is in progress (writes "0" and idles). +if [ -x "$SCRIPT_DIR/sqlite3" ] && [ -f "$SCRIPT_DIR/workout_detector.sh" ]; then + setsid sh "$SCRIPT_DIR/workout_detector.sh" /dev/null 2>&1 & fi + +# Start DNS enforcer - forces Private DNS off and blocks DoH/DoT endpoints +# so the hosts file actually gets consulted by apps that would otherwise +# bypass it (e.g. Chrome's built-in secure DNS). Always on. +setsid sh "$SCRIPT_DIR/dns_enforcer.sh" /dev/null 2>&1 & + +# Start launcher enforcer - keeps Minimalist Phone installed and pinned as +# the default HOME. Always on (not location-gated). +setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & + +# Start focus daemon in a new session (detached from any controlling terminal) +setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 & + +exit 0 diff --git a/phone_focus_mode/workout_detector.sh b/phone_focus_mode/workout_detector.sh new file mode 100755 index 0000000..1edecbb --- /dev/null +++ b/phone_focus_mode/workout_detector.sh @@ -0,0 +1,174 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# Workout detector for rooted Android. +# +# Why this exists: +# The user wants YouTube unblocked ONLY while a StrongLifts workout +# is currently in progress (i.e. started but not yet finished). This +# daemon writes a 1/0 flag to $WORKOUT_ACTIVE_FILE; hosts_enforcer.sh +# reads the flag and swaps the active canonical hosts file between +# the full block ($HOSTS_CANONICAL) and the workout-relaxed variant +# ($HOSTS_CANONICAL_WORKOUT) on transitions. +# +# Detection signal: +# StrongLifts persists every workout to $WORKOUT_DB_PATH (SQLite). +# The `workouts` table has columns `start` (epoch ms) and `finish` +# (epoch ms, NULL/0 while in progress). The single source of truth: +# +# SELECT COUNT(*) FROM workouts +# WHERE start > 0 AND (finish IS NULL OR finish = 0); +# +# Returns 1 during a workout, 0 otherwise. Verified empirically: every +# completed row in the user's history has both fields populated; only +# live workouts leave finish=NULL. +# +# Why other signals were rejected: +# * stronglifts_timer_running pref → only true between sets (rest +# timer); flips on/off every minute during a workout. +# * Foreground notification → posted only during rest timer. +# * Foreground activity → only true when actively staring at the app, +# which is rarely the case while lifting. +# +# Failure mode: +# Fail closed. Any error (sqlite3 missing, DB locked, query non-zero +# exit, malformed output) writes "0" so YouTube stays blocked. Stale +# data is preferred over an open door. +# +# Read-only DB access: +# Uses sqlite3's URI form `file:?mode=ro` to avoid touching the +# app's WAL/SHM files or holding a write lock that StrongLifts could +# contend with. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=config.sh +. "$SCRIPT_DIR/config.sh" + +PIDFILE="$STATE_DIR/workout_detector.pid" + +mkdir -p "$STATE_DIR" +touch "$WORKOUT_DETECTOR_LOG" +chmod 666 "$WORKOUT_DETECTOR_LOG" 2>/dev/null || true + +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] $1" >> "$WORKOUT_DETECTOR_LOG" +} + +rotate_log() { + local lines + lines="$(wc -l < "$WORKOUT_DETECTOR_LOG" 2>/dev/null || echo 0)" + if [ "$lines" -gt 500 ]; then + local tmp="$WORKOUT_DETECTOR_LOG.tmp" + tail -n 500 "$WORKOUT_DETECTOR_LOG" > "$tmp" + mv "$tmp" "$WORKOUT_DETECTOR_LOG" + fi +} + +acquire_lock() { + if [ -f "$PIDFILE" ]; then + local old_pid + old_pid="$(cat "$PIDFILE")" + if kill -0 "$old_pid" 2>/dev/null; then + local cmdline + cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)" + if echo "$cmdline" | grep -q "workout_detector"; then + echo "workout_detector already running (PID $old_pid)" + exit 0 + fi + fi + rm -f "$PIDFILE" + fi + echo $$ > "$PIDFILE" +} + +# Write the flag atomically and chmod 666 so other daemons (running under +# different SELinux contexts) can read it. Returns 0 always; callers do not +# branch on success. +write_flag() { + local value="$1" + local tmp="$WORKOUT_ACTIVE_FILE.tmp" + printf '%s\n' "$value" > "$tmp" + mv "$tmp" "$WORKOUT_ACTIVE_FILE" + chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true +} + +# Query StrongLifts DB. On success echoes "0" or "1"; on failure echoes +# nothing and returns non-zero so the caller can fail closed. +query_workout_active() { + if [ ! -x "$WORKOUT_SQLITE3_BIN" ]; then + return 1 + fi + if [ ! -f "$WORKOUT_DB_PATH" ]; then + # App not installed or DB not yet created → no workout possible. + echo 0 + return 0 + fi + + local count + count="$( + "$WORKOUT_SQLITE3_BIN" "file:${WORKOUT_DB_PATH}?mode=ro" \ + "SELECT COUNT(*) FROM workouts WHERE start>0 AND (finish IS NULL OR finish=0);" \ + 2>>"$WORKOUT_DETECTOR_LOG" + )" || return 1 + + case "$count" in + 0) echo 0 ;; + [1-9]*) echo 1 ;; + *) + log "ERROR: unexpected sqlite output: '$count'" + return 1 + ;; + esac + return 0 +} + +cleanup() { + log "workout_detector shutting down" + # Fail closed on shutdown — assume no workout so YouTube stays blocked. + write_flag 0 + rm -f "$PIDFILE" + exit 0 +} + +trap cleanup INT TERM + +main() { + acquire_lock + log "workout_detector started (PID=$$, db=$WORKOUT_DB_PATH, interval=${WORKOUT_DETECTOR_INTERVAL}s)" + + local last_state="-1" + + while true; do + local new_state + if new_state="$(query_workout_active)"; then + : + else + new_state=0 + log "WARN: query failed, defaulting workout_active=0 (fail-closed)" + fi + + if [ "$new_state" != "$last_state" ]; then + write_flag "$new_state" + if [ "$new_state" = "1" ]; then + log "STATE: workout STARTED → YouTube unblock requested" + else + # last_state="-1" is the very first iteration — log the + # initial baseline distinctly so it is obvious in the log. + if [ "$last_state" = "-1" ]; then + log "STATE: initial workout_active=0 (no in-progress workout)" + else + log "STATE: workout FINISHED → YouTube re-block requested" + fi + fi + last_state="$new_state" + fi + + rotate_log + sleep "$WORKOUT_DETECTOR_INTERVAL" + done +} + +main "$@" diff --git a/python_pkg/screen_locker/_phone_verification.py b/python_pkg/screen_locker/_phone_verification.py index 1c18f49..869be3f 100644 --- a/python_pkg/screen_locker/_phone_verification.py +++ b/python_pkg/screen_locker/_phone_verification.py @@ -255,20 +255,19 @@ class PhoneVerificationMixin: return 0 def _is_workout_finish_recent(self, db_path: Path) -> bool: - """Check if the latest workout's finish time is from today. + """Check if the latest workout's finish time is recent. - A fresh workout should have finished today (local time) and not in - the future. This prevents using an old pre-prepared database dump - while still allowing workouts done earlier in the day (e.g. a - morning workout being verified in the evening). + A fresh workout should have finished within the last 24 hours. + This prevents using an old pre-prepared database dump while + still accepting workouts done earlier the same day. Args: db_path: Path to the locally-pulled StrongLifts database. Returns: - True if the latest finish time is today (local) and not in the - future. + True if the latest finish time is within 24 hours of now. """ + max_age_seconds = 24 * 3600 # accept same-day workouts try: conn = sqlite3.connect(str(db_path)) try: @@ -276,16 +275,13 @@ class PhoneVerificationMixin: "SELECT MAX(finish) FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime') " - "AND finish > start " - "AND date(finish / 1000, 'unixepoch', 'localtime') " - "= date('now', 'localtime')", + "AND finish > start", ) row = cursor.fetchone() if not row or row[0] is None: return False finish_epoch = int(row[0]) / 1000.0 - # Reject future timestamps (clock-skew / tampering guard). - return finish_epoch <= time.time() + return (time.time() - finish_epoch) < max_age_seconds finally: conn.close() except (sqlite3.Error, ValueError, TypeError): diff --git a/python_pkg/screen_locker/adjust_shutdown_schedule.sh b/python_pkg/screen_locker/adjust_shutdown_schedule.sh index ee2234d..4e6621e 100755 --- a/python_pkg/screen_locker/adjust_shutdown_schedule.sh +++ b/python_pkg/screen_locker/adjust_shutdown_schedule.sh @@ -34,7 +34,7 @@ MORNING_END_HOUR="$3" # Validate hours are integers between 0-23 for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do - if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 23 ]]; then + if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 24 ]]; then echo "Error: Hours must be integers between 0 and 23" >&2 exit 1 fi diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 97d5bce..ab388f2 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -28,6 +28,7 @@ from python_pkg.screen_locker._constants import ( STRONGLIFTS_DB_REMOTE, ) from python_pkg.screen_locker._log_integrity import ( + _load_hmac_key, compute_entry_hmac, verify_entry_hmac, ) @@ -153,8 +154,8 @@ class ScreenLocker( "No sick day logged today. Nothing to verify.", ) sys.exit(0) - else: - self._check_non_verify_exits() + return + self._check_non_verify_exits() def _check_non_verify_exits(self) -> None: """Check all normal (non-verify) startup early-exit conditions.""" @@ -193,11 +194,7 @@ class ScreenLocker( return now.hour * 60 + now.minute def _is_early_bird_time(self) -> bool: - """Return True if current local time is in the early bird window. - - The early bird window is EARLY_BIRD_START_HOUR (5 AM) up to but not - including EARLY_BIRD_END_HOUR:EARLY_BIRD_END_MINUTE (8:30 AM). - """ + """Return True if current local time is in the early bird window.""" minutes = self._get_local_time_minutes() start = EARLY_BIRD_START_HOUR * 60 end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE @@ -224,16 +221,7 @@ class ScreenLocker( self.save_workout_log() def _try_auto_upgrade_early_bird(self) -> bool: - """Silently upgrade today's early_bird entry if phone shows a workout. - - Called at 8:30 AM when the early bird grace period expires. If the - phone shows a completed workout, upgrades the entry to phone_verified - and rewards with a later shutdown time. Otherwise returns False so the - caller can show the lock screen. - - Returns: - True if the entry was upgraded to phone_verified, False otherwise. - """ + """Silently upgrade today's early_bird entry if phone shows a workout.""" try: status, message = self._verify_phone_workout() except (OSError, RuntimeError) as exc: @@ -254,18 +242,7 @@ class ScreenLocker( return True def _try_auto_upgrade_sick_day(self) -> bool: - """Silently upgrade today's sick_day entry if phone shows a workout. - - Runs at startup without any UI so that a real workout logged on the - phone retroactively replaces an earlier sick_day entry (for example - when a previous bug forced the user into the sick path). - - Returns: - True if the entry was upgraded to phone_verified, False otherwise. - On False the caller should fall through to the normal startup - path (which will skip the lock because the sick_day entry still - satisfies ``has_logged_today``). - """ + """Silently upgrade today's sick_day entry if phone shows a workout.""" try: status, message = self._verify_phone_workout() except (OSError, RuntimeError) as exc: @@ -417,12 +394,7 @@ class ScreenLocker( self.root.after(1500, self.close) def has_logged_today(self) -> bool: - """Check if workout has been logged today. - - Signed entries are verified with HMAC. Older unsigned entries are - still accepted as a legacy fallback so the user-level service does not - forget workouts when the root-owned HMAC key is unavailable. - """ + """Check if workout has been logged today with valid HMAC.""" if not self.log_file.exists(): return False @@ -436,15 +408,17 @@ class ScreenLocker( entry = logs.get(today) if entry is None: return False - if "hmac" not in entry: - _logger.warning( - "Today's log entry is unsigned; accepting legacy fallback" + if verify_entry_hmac(entry): + return entry.get("workout_data", {}).get("type") != "early_bird" + if _load_hmac_key() is None and "hmac" not in entry: + _logger.info( + "HMAC key unavailable — accepting unsigned entry", ) return entry.get("workout_data", {}).get("type") != "early_bird" - if not verify_entry_hmac(entry): - _logger.warning("HMAC verification failed for today's log entry") - return False - return entry.get("workout_data", {}).get("type") != "early_bird" + _logger.warning( + "HMAC verification failed for today's log entry", + ) + return False def _load_existing_logs(self) -> dict: """Load existing workout logs from file.""" diff --git a/python_pkg/screen_locker/tests/test_adb_and_phone.py b/python_pkg/screen_locker/tests/test_adb_and_phone.py index 1cb18c9..171adf5 100644 --- a/python_pkg/screen_locker/tests/test_adb_and_phone.py +++ b/python_pkg/screen_locker/tests/test_adb_and_phone.py @@ -795,62 +795,7 @@ class TestIsWorkoutFinishRecent: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Test returns False for workout that finished on a previous day.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "sl_test.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - # Start and finish are both yesterday (local time). - yesterday_ms = int((time.time() - 36 * 3600) * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", yesterday_ms - 3600000, yesterday_ms), - ) - conn.commit() - conn.close() - - assert locker._is_workout_finish_recent(db_file) is False - - def test_earlier_today_workout_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Workout that finished earlier today (>4h ago) is still accepted.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "sl_test.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - # Start at today's local-midnight + 1s, finish = now. Both stay - # within today's local date regardless of when the test runs. - today_local_midnight = int( - time.mktime(time.strptime(time.strftime("%Y-%m-%d"), "%Y-%m-%d")), - ) - start_ms = (today_local_midnight + 1) * 1000 - finish_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", start_ms, finish_ms), - ) - conn.commit() - conn.close() - - assert locker._is_workout_finish_recent(db_file) is True - - def test_future_finish_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Finish timestamp in the future is rejected (clock-skew guard).""" + """Test returns False for workout that finished >24 hours ago.""" locker = create_locker(mock_tk, tmp_path) db_file = tmp_path / "sl_test.db" conn = sqlite3.connect(str(db_file)) @@ -858,11 +803,12 @@ class TestIsWorkoutFinishRecent: "CREATE TABLE workouts " "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", ) + # Finished 25 hours ago (not "today" in local time either) now_ms = int(time.time() * 1000) - future_ms = now_ms + 2 * 3600 * 1000 + old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window conn.execute( "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, future_ms), + ("w1", old_finish - 3600000, old_finish), ) conn.commit() conn.close() diff --git a/python_pkg/screen_locker/tests/test_init_and_log.py b/python_pkg/screen_locker/tests/test_init_and_log.py index 42a71ba..229eaf3 100644 --- a/python_pkg/screen_locker/tests/test_init_and_log.py +++ b/python_pkg/screen_locker/tests/test_init_and_log.py @@ -154,29 +154,59 @@ class TestHasLoggedToday: ): assert locker.has_logged_today() is False - def test_today_logged_without_hmac_uses_legacy_fallback( + def test_today_unsigned_entry_no_hmac_key( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Unsigned legacy entries still count as logged workouts.""" + """Accept unsigned entry when HMAC key is unavailable.""" log_file = tmp_path / "workout_log.json" today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") log_file.write_text( - json.dumps( - { - today: { - "timestamp": "2026-05-01T14:46:32.206951+00:00", - "workout_data": {"type": "phone_verified"}, - } - } - ), + json.dumps({today: {"workout": "data"}}), ) locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file - assert locker.has_logged_today() is True + with ( + patch( + "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + return_value=False, + ), + patch( + "python_pkg.screen_locker.screen_lock._load_hmac_key", + return_value=None, + ), + ): + assert locker.has_logged_today() is True + + def test_today_unsigned_entry_with_hmac_key( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Reject unsigned entry when HMAC key IS available.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps({today: {"workout": "data"}}), + ) + + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with ( + patch( + "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + return_value=False, + ), + patch( + "python_pkg.screen_locker.screen_lock._load_hmac_key", + return_value=b"secret-key", + ), + ): + assert locker.has_logged_today() is False def test_other_day_logged( self, @@ -330,7 +360,7 @@ class TestRun: class TestAutoUpgradeSickDay: - """Tests for silent sick_day → phone_verified upgrade at startup.""" + """Tests for sick_day → phone_verified silent upgrade helpers.""" def test_upgrade_succeeds_when_phone_verified( self, @@ -404,7 +434,7 @@ class TestAutoUpgradeSickDay: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Startup exits 0 after a successful silent upgrade.""" + """Startup exits 0 after a successful silent sick_day upgrade.""" mock_sys_exit.side_effect = SystemExit(0) with ( patch.object( @@ -418,30 +448,6 @@ class TestAutoUpgradeSickDay: mock_upgrade.assert_called_once() mock_sys_exit.assert_called_once_with(0) - def test_init_falls_through_when_sick_day_upgrade_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Failed upgrade still honours existing sick_day log (exit via has_logged).""" - mock_sys_exit.side_effect = SystemExit(0) - with ( - patch.object( - ScreenLocker, - "_try_auto_upgrade_sick_day", - return_value=False, - ), - pytest.raises(SystemExit), - ): - create_locker( - mock_tk, - tmp_path, - is_sick_day_log=True, - has_logged=True, - ) - mock_sys_exit.assert_called_once_with(0) - class TestMainEntry: """Tests for main entry point.""" diff --git a/python_pkg/steam_backlog_enforcer/_cmd_done.py b/python_pkg/steam_backlog_enforcer/_cmd_done.py index a49fd8b..4631865 100644 --- a/python_pkg/steam_backlog_enforcer/_cmd_done.py +++ b/python_pkg/steam_backlog_enforcer/_cmd_done.py @@ -15,12 +15,18 @@ from python_pkg.steam_backlog_enforcer.game_install import ( uninstall_other_games, ) from python_pkg.steam_backlog_enforcer.hltb import ( + fetch_hltb_confidence_cached, fetch_hltb_times_cached, load_hltb_cache, + load_hltb_count_comp_cache, + load_hltb_polls_cache, + save_hltb_cache, ) from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games from python_pkg.steam_backlog_enforcer.scanning import ( - _pick_playable_candidate, + _confidence_fail_reasons, + _pick_next_shortest_candidate, + _refresh_candidate_confidence, pick_next_game, ) from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient @@ -28,6 +34,81 @@ from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient _REASSIGN_REFRESH_LIMIT = 50 +def _backfill_polls_for_finished( + state: State, + extra_app_id: int | None = None, +) -> dict[int, int]: + """Lazily fetch poll counts for already-finished games missing them. + + If ``extra_app_id`` is provided and its poll count is missing, it is + refreshed alongside finished games (used to populate polls for the + currently-assigned game on first run after the schema upgrade). + """ + polls_cache = load_hltb_polls_cache() + snapshot_data = load_snapshot() or [] + name_by_id = {d["app_id"]: d["name"] for d in snapshot_data} + candidate_ids = list(state.finished_app_ids) + if extra_app_id is not None and polls_cache.get(extra_app_id, 0) == 0: + candidate_ids.append(extra_app_id) + missing = [ + (aid, name_by_id[aid]) + for aid in candidate_ids + if aid in name_by_id and polls_cache.get(aid, 0) == 0 + ] + if not missing: + return polls_cache + + _echo(f" Backfilling HLTB poll counts for {len(missing)} game(s)...") + cache = load_hltb_cache() + preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} + for aid, _name in missing: + cache.pop(aid, None) + save_hltb_cache(cache, polls_cache) + + fetch_hltb_confidence_cached(missing) + + refreshed_hours = load_hltb_cache() + refreshed_polls = load_hltb_polls_cache() + for aid, prior_hours in preserved_hours.items(): + if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0: + refreshed_hours[aid] = prior_hours + save_hltb_cache(refreshed_hours, refreshed_polls) + return refreshed_polls + + +def _report_assigned_confidence( + app_id: int, + state: State, +) -> None: + """Print HLTB poll-count confidence for the currently-assigned game.""" + polls_cache = _backfill_polls_for_finished(state, extra_app_id=app_id) + chosen_polls = polls_cache.get(app_id, 0) + + finished_polls = [ + (polls_cache[aid], aid) + for aid in state.finished_app_ids + if polls_cache.get(aid, 0) > 0 and aid != app_id + ] + snapshot_data = load_snapshot() or [] + name_by_id = {d["app_id"]: d["name"] for d in snapshot_data} + + warning = "" + if finished_polls: + min_polls = min(p for p, _ in finished_polls) + if 0 < chosen_polls < min_polls: + warning = " ⚠ NEW LOW — estimate may be unreliable" + elif chosen_polls == 0: + warning = " ⚠ no polls recorded — estimate may be unreliable" + elif chosen_polls == 0: + warning = " ⚠ no polls recorded — estimate may be unreliable" + + _echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}") + if finished_polls: + min_polls, min_aid = min(finished_polls) + min_name = name_by_id.get(min_aid, f"AppID={min_aid}") + _echo(f" Historical min among finished: {min_polls} ({min_name})") + + def _apply_cached_hours_to_games( games: list[GameInfo], hltb_cache: dict[int, float], @@ -38,6 +119,17 @@ def _apply_cached_hours_to_games( game.completionist_hours = hltb_cache[game.app_id] +def _apply_cached_confidence_to_games(games: list[GameInfo]) -> None: + """Overlay cached confidence counters onto snapshot-backed game objects.""" + polls_cache = load_hltb_polls_cache() + count_comp_cache = load_hltb_count_comp_cache() + for game in games: + if game.app_id in polls_cache: + game.comp_100_count = polls_cache[game.app_id] + if game.app_id in count_comp_cache: + game.count_comp = count_comp_cache[game.app_id] + + def _refresh_uncached_shortlist_hours( games: list[GameInfo], hltb_cache: dict[int, float], @@ -69,6 +161,46 @@ def _refresh_uncached_shortlist_hours( hltb_cache.update(refreshed) +def _should_reassign_candidate( + playable: GameInfo, + current_hours: float, + *, + force_reassign: bool, +) -> bool: + """Return whether a playable candidate should trigger reassignment.""" + if force_reassign: + return True + if current_hours > 0: + return playable.completionist_hours < current_hours + return True + + +def _echo_reassign_decision( + playable: GameInfo, + current_hours: float, + current_fail_reasons: list[str], + *, + force_reassign: bool, +) -> None: + """Emit a human-readable reassignment reason.""" + if force_reassign: + _echo( + f"\n Reassigning: current game confidence too low " + f"({'; '.join(current_fail_reasons)})" + ) + return + if current_hours > 0: + _echo( + f"\n Reassigning: {playable.name} is shorter" + f" (~{playable.completionist_hours:.1f}h vs ~{current_hours:.1f}h)" + ) + return + _echo( + f"\n Reassigning: current game has no usable HLTB time; " + f"picked {playable.name} (~{playable.completionist_hours:.1f}h)" + ) + + def _try_reassign_shorter_game( hltb_cache: dict[int, float], app_id: int, @@ -89,23 +221,44 @@ def _try_reassign_shorter_game( upper_bound_hours=hours, ) _apply_cached_hours_to_games(all_games, hltb_cache) + _apply_cached_confidence_to_games(all_games) + current_game = next((g for g in all_games if g.app_id == app_id), None) + if current_game is not None and _confidence_fail_reasons(current_game): + _refresh_candidate_confidence(current_game) + current_fail_reasons = ( + _confidence_fail_reasons(current_game) if current_game is not None else [] + ) + force_reassign = bool(current_fail_reasons) candidates = [ g for g in all_games if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0 ] + if not force_reassign and hours > 0: + candidates = [g for g in candidates if g.completionist_hours < hours] + candidates.sort(key=lambda g: g.completionist_hours) - if not candidates or candidates[0].app_id == app_id: + candidates = [c for c in candidates if c.app_id != app_id] + if not candidates: return False - # Filter out Linux-incompatible games before deciding to reassign. - playable = _pick_playable_candidate( - [c for c in candidates if c.app_id != app_id], + + playable, _confidence_skipped, _linux_skipped = _pick_next_shortest_candidate( + candidates, ) - if playable is None or playable.completionist_hours >= hours: + if playable is None: return False - _echo( - f"\n Reassigning: {playable.name} is shorter" - f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)" + + if not _should_reassign_candidate( + playable, + hours, + force_reassign=force_reassign, + ): + return False + _echo_reassign_decision( + playable, + hours, + current_fail_reasons, + force_reassign=force_reassign, ) pick_next_game(all_games, state, config) @@ -193,6 +346,15 @@ def _enforce_on_done(config: Config, state: State) -> None: use_steam_protocol=True, ) + # Reconcile library: hide non-assigned games and unhide the assigned one. + # Without this, an interrupted earlier completion can leave the new + # assigned game hidden and stale games visible. + owned_ids = get_all_owned_app_ids(config) + if owned_ids: + hidden = hide_other_games(owned_ids, state.current_app_id) + if hidden > 0: + _echo(f" Library: hid {hidden} games") + def cmd_done(config: Config, state: State) -> None: """Check completion, pick next game, uninstall & hide. @@ -230,6 +392,7 @@ def cmd_done(config: Config, state: State) -> None: hours = hltb_cache.get(app_id, -1.0) if hours > 0: _echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours") + _report_assigned_confidence(app_id, state) if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config): return diff --git a/python_pkg/steam_backlog_enforcer/_enforce_loop.py b/python_pkg/steam_backlog_enforcer/_enforce_loop.py index b8f28ba..7c0c8f3 100644 --- a/python_pkg/steam_backlog_enforcer/_enforce_loop.py +++ b/python_pkg/steam_backlog_enforcer/_enforce_loop.py @@ -37,19 +37,34 @@ logger = logging.getLogger(__name__) def get_all_owned_app_ids(config: Config) -> list[int]: - """Get all owned game app IDs from the snapshot or Steam API.""" - snapshot = load_snapshot() - if snapshot: - return [d["app_id"] for d in snapshot] + """Get all owned game app IDs from Steam API plus snapshot fallback. + + Snapshot data contains only games with achievements, so API data is the + primary source for library hiding. Snapshot IDs are merged in to keep + behavior resilient when the API result is partial. + """ + snapshot = load_snapshot() or [] + snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d] - # Fall back to a quick API call. try: client = SteamAPIClient(config.steam_api_key, config.steam_id) owned = client.get_owned_games() - return [g["appid"] for g in owned] + api_ids = [int(g["appid"]) for g in owned if "appid" in g] + + merged_ids: list[int] = [] + seen: set[int] = set() + for app_id in [*api_ids, *snapshot_ids]: + if app_id in seen: + continue + seen.add(app_id) + merged_ids.append(app_id) except (OSError, RuntimeError, ValueError): + if snapshot_ids: + return snapshot_ids logger.warning("Could not fetch owned game list for hiding.") return [] + else: + return merged_ids # ────────────────────────────────────────────────────────────── diff --git a/python_pkg/steam_backlog_enforcer/_hltb_detail.py b/python_pkg/steam_backlog_enforcer/_hltb_detail.py index 03a425e..341ea0b 100644 --- a/python_pkg/steam_backlog_enforcer/_hltb_detail.py +++ b/python_pkg/steam_backlog_enforcer/_hltb_detail.py @@ -149,12 +149,20 @@ async def _fetch_detail_one( async def _fetch_leisure_times( search_results: list[HLTBResult], cache: dict[int, float], + polls: dict[int, int], progress_cb: ProgressCb | None, + count_comp: dict[int, int] | None = None, ) -> None: """Fetch leisure times from game detail pages for all search results. Updates ``cache`` in-place with leisure hours (including DLC time). + The ``polls`` and ``count_comp`` mappings are forwarded to + :func:`save_hltb_cache` so the on-disk cache keeps confidence metrics + captured during the search step. """ + if count_comp is None: + count_comp = {} + valid = [r for r in search_results if r.hltb_game_id > 0] if not valid: return @@ -198,7 +206,7 @@ async def _fetch_leisure_times( progress_cb(done, total, found, r.game_name) if not done % _SAVE_INTERVAL: - save_hltb_cache(cache) + save_hltb_cache(cache, polls, count_comp) def _collect_dlc_relationships( diff --git a/python_pkg/steam_backlog_enforcer/_hltb_types.py b/python_pkg/steam_backlog_enforcer/_hltb_types.py index ddadec3..d569041 100644 --- a/python_pkg/steam_backlog_enforcer/_hltb_types.py +++ b/python_pkg/steam_backlog_enforcer/_hltb_types.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import json import logging +from typing import Any from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write @@ -42,6 +43,8 @@ class HLTBResult: completionist_hours: float similarity: float hltb_game_id: int = 0 + comp_100_count: int = 0 + count_comp: int = 0 @dataclass @@ -53,26 +56,91 @@ class _AuthInfo: hp_val: str = "" +def _read_raw_cache() -> dict[int, dict[str, Any]]: + """Read the persistent HLTB cache, normalizing legacy float entries. + + Cache schema on disk (current): + { + "": { + "hours": , + "polls": , + "count_comp": + } + } + + Legacy format (single float value per app) is migrated transparently. + """ + if not HLTB_CACHE_FILE.exists(): + return {} + try: + data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + logger.warning("Corrupt HLTB cache, starting fresh.") + return {} + out: dict[int, dict[str, Any]] = {} + for k, v in data.items(): + try: + aid = int(k) + except (TypeError, ValueError): + continue + if isinstance(v, dict): + out[aid] = { + "hours": float(v.get("hours", -1)), + "polls": int(v.get("polls", 0)), + "count_comp": int(v.get("count_comp", 0)), + } + else: + try: + out[aid] = {"hours": float(v), "polls": 0, "count_comp": 0} + except (TypeError, ValueError): + continue + return out + + def load_hltb_cache() -> dict[int, float]: - """Load the persistent HLTB cache from disk. + """Load the hours portion of the HLTB cache. Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB). """ - if HLTB_CACHE_FILE.exists(): - try: - data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8")) - return {int(k): float(v) for k, v in data.items()} - except (json.JSONDecodeError, ValueError, OSError): - logger.warning("Corrupt HLTB cache, starting fresh.") - return {} + return {aid: v["hours"] for aid, v in _read_raw_cache().items()} -def save_hltb_cache(cache: dict[int, float]) -> None: - """Save the HLTB cache to disk.""" +def load_hltb_polls_cache() -> dict[int, int]: + """Load the polled-completionist-times portion of the HLTB cache. + + Returns: dict mapping app_id -> ``comp_100_count`` (0 = unknown). + """ + return {aid: v["polls"] for aid, v in _read_raw_cache().items()} + + +def load_hltb_count_comp_cache() -> dict[int, int]: + """Load the ``count_comp`` portion of the HLTB cache. + + Returns: dict mapping app_id -> ``count_comp`` (0 = unknown). + """ + return {aid: v["count_comp"] for aid, v in _read_raw_cache().items()} + + +def save_hltb_cache( + cache: dict[int, float], + polls: dict[int, int] | None = None, + count_comp: dict[int, int] | None = None, +) -> None: + """Save the HLTB cache to disk, including confidence metrics.""" + polls = polls or {} + count_comp = count_comp or {} + out = { + str(aid): { + "hours": hours, + "polls": polls.get(aid, 0), + "count_comp": count_comp.get(aid, 0), + } + for aid, hours in cache.items() + } try: _atomic_write( HLTB_CACHE_FILE, - json.dumps({str(k): v for k, v in cache.items()}, indent=2) + "\n", + json.dumps(out, indent=2) + "\n", ) except OSError: logger.exception("Failed to save HLTB cache") diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py index b0712b6..512d9b8 100644 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ b/python_pkg/steam_backlog_enforcer/game_install.py @@ -21,12 +21,15 @@ _REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() def _assert_not_real_steam(path: Path) -> None: - """Raise if *path* is inside the real Steam directory. + """Raise if *path* is inside the real Steam directory during tests. - Defence-in-depth guard: even if test fixtures fail to - redirect ``STEAMAPPS_PATH``, destructive operations - (uninstall, rmtree, unlink) will refuse to touch real files. + Defence-in-depth guard: when running under pytest, even if test + fixtures fail to redirect ``STEAMAPPS_PATH``, destructive + operations (uninstall, rmtree, unlink) will refuse to touch + real files. In production runs this is a no-op. """ + if "PYTEST_CURRENT_TEST" not in os.environ: + return # production run — real Steam paths are expected try: path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) except ValueError: diff --git a/python_pkg/steam_backlog_enforcer/hltb.py b/python_pkg/steam_backlog_enforcer/hltb.py index c90016b..8b2a24b 100644 --- a/python_pkg/steam_backlog_enforcer/hltb.py +++ b/python_pkg/steam_backlog_enforcer/hltb.py @@ -18,6 +18,7 @@ from difflib import SequenceMatcher from http import HTTPStatus import json import logging +import re import time from typing import Any @@ -37,6 +38,8 @@ from python_pkg.steam_backlog_enforcer._hltb_types import ( ProgressCb, _AuthInfo, load_hltb_cache, + load_hltb_count_comp_cache, + load_hltb_polls_cache, save_hltb_cache, ) @@ -145,6 +148,70 @@ def _build_search_payload(game_name: str, auth: _AuthInfo | None = None) -> str: return json.dumps(payload) +def _build_search_variants(game_name: str) -> list[str]: + """Return fallback search terms for one Steam game title.""" + base = game_name.strip() + variants = [base] + no_year = re.sub(r"\s*\(\d{4}\)$", "", base).strip() + if no_year and no_year != base: + variants.append(no_year) + return variants + + +def _collect_candidates( + query_name: str, + data: dict[str, Any], +) -> list[tuple[dict[str, Any], float]]: + """Build candidate list from one HLTB response payload.""" + candidates: list[tuple[dict[str, Any], float]] = [] + lower_name = query_name.lower() + for entry in data.get("data", []): + entry_name = entry.get("game_name", "") + entry_alias = entry.get("game_alias", "") or "" + is_dlc = str(entry.get("game_type", "")).lower() == "dlc" + sim = max( + _similarity(query_name, entry_name), + _similarity(query_name, entry_alias), + ) + is_full_edition = ( + (not is_dlc) and entry_name.lower().startswith(lower_name + ":") + ) or ((not is_dlc) and entry_name.lower().startswith(lower_name + " -")) + if sim >= MIN_SIMILARITY or is_full_edition: + comp_100 = entry.get("comp_100", 0) + if comp_100 and comp_100 > 0: + candidates.append((entry, sim)) + return candidates + + +def _build_result_from_best( + app_id: int, + original_name: str, + query_name: str, + best: tuple[dict[str, Any], float], +) -> HLTBResult: + """Convert selected HLTB entry into HLTBResult.""" + entry, sim = best + hours = round(entry["comp_100"] / 3600, 2) + logger.debug( + ("HLTB match for '%s' via '%s': '%s' (id=%s, comp_100=%s, sim=%.3f)"), + original_name, + query_name, + entry.get("game_name"), + entry.get("game_id"), + entry.get("comp_100"), + sim, + ) + return HLTBResult( + app_id=app_id, + game_name=original_name, + completionist_hours=hours, + similarity=sim, + hltb_game_id=entry.get("game_id", 0), + comp_100_count=int(entry.get("comp_100_count", 0) or 0), + count_comp=int(entry.get("count_comp", 0) or 0), + ) + + def _pick_best_hltb_entry( search_name: str, candidates: list[tuple[dict[str, Any], float]], @@ -204,6 +271,9 @@ def _find_best_extended( """ best: tuple[dict[str, Any], float] | None = None for entry, sim in usable: + game_type = str(entry.get("game_type", "")).lower() + if game_type not in ("", "game"): + continue entry_name = (entry.get("game_name") or "").lower() if entry_name.startswith((lower + ":", lower + " -")): suffix = entry_name[len(lower) :].lstrip(" :-") @@ -223,12 +293,19 @@ def _resolve_exact_vs_extended( if best_exact is not None and best_extended is not None: exact_hours = best_exact[0].get("comp_100", 0) extended_hours = best_extended[0].get("comp_100", 0) + exact_confidence = int(best_exact[0].get("comp_100_count", 0) or 0) + int( + best_exact[0].get("count_comp", 0) or 0 + ) + extended_confidence = int(best_extended[0].get("comp_100_count", 0) or 0) + int( + best_extended[0].get("count_comp", 0) or 0 + ) # Prefer the extended entry only when it has strictly more hours - # than the exact match. This lets "FAITH: The Unholy Trinity" - # (7 h) beat "FAITH" (0.5 h demo) while preventing - # "Timberman: The Big Adventure" (2 h) from beating - # "Timberman" (26 h). - if extended_hours > exact_hours: + # than the exact match AND at least as much confidence. + # This lets "FAITH: The Unholy Trinity" (full game) beat + # a low-confidence exact demo while preventing low-confidence + # mods like "Celeste - Strawberry Jam" from beating + # the exact base game. + if extended_hours > exact_hours and extended_confidence >= exact_confidence: return best_extended return best_exact if best_exact is not None: @@ -253,6 +330,8 @@ class _SearchCtx: search_url: str headers: dict[str, str] cache: dict[int, float] + polls: dict[int, int] = field(default_factory=dict) + count_comp: dict[int, int] = field(default_factory=dict) auth: _AuthInfo | None = None counter: dict[str, int] = field(default_factory=dict) total: int = 0 @@ -268,71 +347,43 @@ async def _search_one( """Search HLTB for one game via direct POST, update cache.""" async with sem: result: HLTBResult | None = None - payload = _build_search_payload(name, ctx.auth) - try: - async with ctx.session.post( - ctx.search_url, - headers=ctx.headers, - data=payload, - ) as resp: - if resp.status == HTTPStatus.OK: + for query_name in _build_search_variants(name): + payload = _build_search_payload(query_name, ctx.auth) + try: + async with ctx.session.post( + ctx.search_url, + headers=ctx.headers, + data=payload, + ) as resp: + if resp.status != HTTPStatus.OK: + continue data = await resp.json() - candidates: list[tuple[dict[str, Any], float]] = [] - lower_name = name.lower() - for entry in data.get("data", []): - entry_name = entry.get("game_name", "") - entry_alias = entry.get("game_alias", "") or "" - is_dlc = str(entry.get("game_type", "")).lower() == "dlc" - sim = max( - _similarity(name, entry_name), - _similarity(name, entry_alias), - ) - is_full_edition = ( - (not is_dlc) - and entry_name.lower().startswith(lower_name + ":") - ) or ( - (not is_dlc) - and entry_name.lower().startswith(lower_name + " -") - ) - if sim >= MIN_SIMILARITY or is_full_edition: - comp_100 = entry.get("comp_100", 0) - if comp_100 and comp_100 > 0: - candidates.append((entry, sim)) - best = _pick_best_hltb_entry(name, candidates) - if best is not None: - entry, sim = best - hours = round(entry["comp_100"] / 3600, 2) - logger.debug( - "HLTB match for '%s': '%s' (id=%s, comp_100=%s, sim=%.3f)", - name, - entry.get("game_name"), - entry.get("game_id"), - entry.get("comp_100"), - sim, - ) - result = HLTBResult( - app_id=app_id, - game_name=name, - completionist_hours=hours, - similarity=sim, - hltb_game_id=entry.get("game_id", 0), - ) - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - logger.debug("HLTB search failed for '%s': %s", name, exc) + candidates = _collect_candidates(query_name, data) + best = _pick_best_hltb_entry(query_name, candidates) + if best is None: + continue + result = _build_result_from_best(app_id, name, query_name, best) + break + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + logger.debug("HLTB search failed for '%s': %s", query_name, exc) # Update cache immediately (miss = -1). if result is not None: ctx.cache[app_id] = result.completionist_hours + ctx.polls[app_id] = result.comp_100_count + ctx.count_comp[app_id] = result.count_comp ctx.counter["found"] += 1 else: ctx.cache[app_id] = -1 + ctx.polls[app_id] = 0 + ctx.count_comp[app_id] = 0 ctx.counter["done"] += 1 done = ctx.counter["done"] # Incremental save every _SAVE_INTERVAL lookups. if not done % _SAVE_INTERVAL: - save_hltb_cache(ctx.cache) + save_hltb_cache(ctx.cache, ctx.polls, ctx.count_comp) # Report progress. if ctx.progress_cb is not None: @@ -344,7 +395,9 @@ async def _search_one( async def _fetch_batch( games: list[tuple[int, str]], cache: dict[int, float], + polls: dict[int, int], progress_cb: ProgressCb | None, + count_comp: dict[int, int] | None = None, ) -> list[HLTBResult]: """Fetch HLTB data for a batch of games using one shared session.""" # 1. Discover the search URL (sync, one-time). @@ -380,6 +433,9 @@ async def _fetch_batch( counter = {"done": 0, "found": 0} total = len(games) + if count_comp is None: + count_comp = {} + connector = aiohttp.TCPConnector( limit=MAX_CONCURRENT, keepalive_timeout=30, @@ -393,6 +449,8 @@ async def _fetch_batch( search_url=search_url, headers=headers, cache=cache, + polls=polls, + count_comp=count_comp, auth=auth, counter=counter, total=total, @@ -416,22 +474,141 @@ async def _fetch_batch( "Fetching leisure times for %d games from detail pages...", len(search_results), ) - await _fetch_leisure_times(search_results, cache, progress_cb=None) + await _fetch_leisure_times( + search_results, + cache, + polls, + progress_cb=None, + count_comp=count_comp, + ) return search_results +async def _fetch_batch_confidence_only( + games: list[tuple[int, str]], + cache: dict[int, float], + polls: dict[int, int], + progress_cb: ProgressCb | None, + count_comp: dict[int, int] | None = None, +) -> list[HLTBResult]: + """Fetch only search-level HLTB data (hours + confidence), no detail pages.""" + # 1. Discover the search URL (sync, one-time). + search_url = _get_hltb_search_url() + logger.info("HLTB search URL: %s", search_url) + + timeout = aiohttp.ClientTimeout(total=20, sock_read=15) + + # 2. Get auth info (separate session — avoids reuse issues). + async with aiohttp.ClientSession(timeout=timeout) as init_session: + auth = await _get_auth_info(search_url, init_session) + if auth is None: + logger.warning("Could not get HLTB auth info, aborting fetch.") + return [] + logger.info("HLTB auth token acquired.") + + # 3. Build shared headers for all search requests. + headers: dict[str, str] = { + "content-type": "application/json", + "accept": "*/*", + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" + ), + "referer": "https://howlongtobeat.com/", + "x-auth-token": auth.token, + } + if auth.hp_key: + headers["x-hp-key"] = auth.hp_key + headers["x-hp-val"] = auth.hp_val + + # 4. Fire all searches through a single persistent session. + sem = asyncio.Semaphore(MAX_CONCURRENT) + counter = {"done": 0, "found": 0} + total = len(games) + + if count_comp is None: + count_comp = {} + + connector = aiohttp.TCPConnector( + limit=MAX_CONCURRENT, + keepalive_timeout=30, + ) + async with aiohttp.ClientSession( + timeout=timeout, + connector=connector, + ) as session: + ctx = _SearchCtx( + session=session, + search_url=search_url, + headers=headers, + cache=cache, + polls=polls, + count_comp=count_comp, + auth=auth, + counter=counter, + total=total, + progress_cb=progress_cb, + ) + tasks = [ + _search_one( + sem, + ctx, + app_id, + name, + ) + for app_id, name in games + ] + results = await asyncio.gather(*tasks) + + return [r for r in results if r is not None] + + def fetch_hltb_times( games: list[tuple[int, str]], cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, progress_cb: ProgressCb | None = None, + count_comp: dict[int, int] | None = None, ) -> list[HLTBResult]: """Synchronous wrapper: fetch HLTB times for games.""" if not games: return [] if cache is None: cache = {} - return asyncio.run(_fetch_batch(games, cache, progress_cb)) + if polls is None: + polls = {} + if count_comp is None: + count_comp = {} + return asyncio.run( + _fetch_batch(games, cache, polls, progress_cb, count_comp=count_comp) + ) + + +def fetch_hltb_confidence( + games: list[tuple[int, str]], + cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, + progress_cb: ProgressCb | None = None, + count_comp: dict[int, int] | None = None, +) -> list[HLTBResult]: + """Fetch only HLTB search-level data (hours + confidence metrics).""" + if not games: + return [] + if cache is None: + cache = {} + if polls is None: + polls = {} + if count_comp is None: + count_comp = {} + return asyncio.run( + _fetch_batch_confidence_only( + games, + cache, + polls, + progress_cb, + count_comp=count_comp, + ) + ) def fetch_hltb_times_cached( @@ -447,6 +624,8 @@ def fetch_hltb_times_cached( Returns: dict mapping app_id -> completionist_hours. """ cache = load_hltb_cache() + polls = load_hltb_polls_cache() + count_comp = load_hltb_count_comp_cache() uncached = [(app_id, name) for app_id, name in games if app_id not in cache] if uncached: @@ -456,11 +635,17 @@ def fetch_hltb_times_cached( len(games) - len(uncached), ) t0 = time.monotonic() - fetch_hltb_times(uncached, cache=cache, progress_cb=progress_cb) + fetch_hltb_times( + uncached, + cache=cache, + polls=polls, + progress_cb=progress_cb, + count_comp=count_comp, + ) elapsed = time.monotonic() - t0 # Final save. - save_hltb_cache(cache) + save_hltb_cache(cache, polls, count_comp) found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0) rate = len(uncached) / elapsed if elapsed > 0 else 0 @@ -477,6 +662,49 @@ def fetch_hltb_times_cached( return cache +def fetch_hltb_confidence_cached( + games: list[tuple[int, str]], + progress_cb: ProgressCb | None = None, +) -> dict[int, float]: + """Fetch HLTB search-level confidence data, using disk cache for known IDs.""" + cache = load_hltb_cache() + polls = load_hltb_polls_cache() + count_comp = load_hltb_count_comp_cache() + uncached = [(app_id, name) for app_id, name in games if app_id not in cache] + + if uncached: + logger.info( + "Fetching HLTB confidence for %d uncached games (%d cached)...", + len(uncached), + len(games) - len(uncached), + ) + t0 = time.monotonic() + fetch_hltb_confidence( + uncached, + cache=cache, + polls=polls, + progress_cb=progress_cb, + count_comp=count_comp, + ) + elapsed = time.monotonic() - t0 + + save_hltb_cache(cache, polls, count_comp) + + found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0) + rate = len(uncached) / elapsed if elapsed > 0 else 0 + logger.info( + "HLTB confidence fetch done: %d/%d found in %.1fs (%.0f games/s)", + found, + len(uncached), + elapsed, + rate, + ) + else: + logger.info("All %d games found in HLTB cache.", len(games)) + + return cache + + def get_hltb_submit_url(game_name: str) -> str | None: """Look up a game on HLTB and return its submit page URL. diff --git a/python_pkg/steam_backlog_enforcer/scanning.py b/python_pkg/steam_backlog_enforcer/scanning.py index 5614f19..06b0caa 100644 --- a/python_pkg/steam_backlog_enforcer/scanning.py +++ b/python_pkg/steam_backlog_enforcer/scanning.py @@ -6,6 +6,12 @@ import logging import time from typing import Any +from python_pkg.steam_backlog_enforcer._hltb_types import ( + load_hltb_cache, + load_hltb_count_comp_cache, + load_hltb_polls_cache, + save_hltb_cache, +) from python_pkg.steam_backlog_enforcer.config import ( Config, State, @@ -21,7 +27,10 @@ from python_pkg.steam_backlog_enforcer.game_install import ( is_game_installed, uninstall_other_games, ) -from python_pkg.steam_backlog_enforcer.hltb import fetch_hltb_times_cached +from python_pkg.steam_backlog_enforcer.hltb import ( + fetch_hltb_confidence_cached, + fetch_hltb_times_cached, +) from python_pkg.steam_backlog_enforcer.protondb import ( ProtonDBRating, fetch_protondb_ratings, @@ -31,6 +40,9 @@ from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient logger = logging.getLogger(__name__) _TAMPER_CHECK_LIMIT = 3 +_MIN_COMP_100_POLLS = 3 +_MIN_COUNT_COMP = 15 +_MIN_CONFIDENCE_SUM = 18 # ────────────────────────────────────────────────────────────── @@ -78,9 +90,13 @@ def do_scan(config: Config, state: State) -> list[GameInfo]: hltb_cache = fetch_hltb_times_cached(incomplete, progress_cb=hltb_progress) _echo("") # newline after progress bar + polls_cache = load_hltb_polls_cache() + count_comp_cache = load_hltb_count_comp_cache() for g in games: hours = hltb_cache.get(g.app_id, -1) g.completionist_hours = hours + g.comp_100_count = polls_cache.get(g.app_id, 0) + g.count_comp = count_comp_cache.get(g.app_id, 0) found = sum(1 for h in hltb_cache.values() if h > 0) _echo(f" HLTB data: {found} games have completion estimates") @@ -94,6 +110,15 @@ def do_scan(config: Config, state: State) -> list[GameInfo]: # Auto-pick a game if none assigned. if state.current_app_id is None: pick_next_game(games, state, config) + else: + # Show confidence info for the already-assigned game too. + current = next( + (g for g in games if g.app_id == state.current_app_id), + None, + ) + if current is not None: + _echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") + _report_poll_confidence(current, games, state) return games @@ -148,7 +173,11 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None: candidates = [g for g in games if not g.is_complete and g.app_id not in skip] if not candidates: - _echo("\nCongratulations! All games are complete!") + _echo( + "\nNo assignable games found " + "(HLTB confidence thresholds: comp_100 polls>=3, " + "count_comp>=15, sum>=18)." + ) state.current_app_id = None state.current_game_name = "" state.save() @@ -162,11 +191,19 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None: candidates.sort(key=sort_key) - # Filter out Linux-incompatible games via ProtonDB. - chosen = _pick_playable_candidate(candidates) + chosen, confidence_skipped, linux_skipped = _pick_next_shortest_candidate( + candidates + ) if chosen is None: - _echo("\nNo playable games left (all have poor ProtonDB ratings)!") + if confidence_skipped > 0 and linux_skipped == 0: + _echo( + "\nNo assignable games found " + "(HLTB confidence thresholds: comp_100 polls>=3, " + "count_comp>=15, sum>=18)." + ) + else: + _echo("\nNo playable games left (all have poor ProtonDB ratings)!") state.current_app_id = None state.current_game_name = "" state.save() @@ -184,6 +221,7 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None: f" Progress: {chosen.unlocked_achievements}/{chosen.total_achievements}" f" ({chosen.completion_pct:.1f}%)" ) + _report_poll_confidence(chosen, games, state) # Uninstall all other games first, then auto-install the assigned one. if config.uninstall_other_games: @@ -201,6 +239,248 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None: ) +def _confidence_fail_reasons(game: GameInfo) -> list[str]: + """Return threshold-failure reasons for a game's HLTB confidence data.""" + reasons: list[str] = [] + if game.comp_100_count < _MIN_COMP_100_POLLS: + reasons.append(f"comp_100 polls {game.comp_100_count} < {_MIN_COMP_100_POLLS}") + if game.count_comp < _MIN_COUNT_COMP: + reasons.append(f"count_comp {game.count_comp} < {_MIN_COUNT_COMP}") + + total = game.comp_100_count + game.count_comp + if total < _MIN_CONFIDENCE_SUM: + reasons.append(f"comp_100+count_comp {total} < {_MIN_CONFIDENCE_SUM}") + + return reasons + + +def _refresh_candidate_confidence(game: GameInfo) -> None: + """Refresh confidence metrics for one candidate when cache looks stale. + + Only refreshes when both metrics are missing (0), which typically means + the game was cached before confidence fields were added. + """ + if game.comp_100_count > 0 or game.count_comp > 0: + return + + _refresh_candidate_confidence_batch([game]) + + +def _force_refresh_candidate_confidence(game: GameInfo) -> None: + """Force-refresh one candidate's confidence metrics from HLTB.""" + _refresh_candidate_confidence_batch([game], force=True) + + +def _refresh_candidate_confidence_batch( + candidates: list[GameInfo], + *, + force: bool = False, +) -> None: + """Refresh missing confidence metrics for candidates in one HLTB batch. + + This prevents O(N) one-game API loops when many snapshot entries predate + confidence fields and therefore have ``comp_100_count==0`` and + ``count_comp==0``. + """ + missing = [ + game + for game in candidates + if force or (game.comp_100_count == 0 and game.count_comp == 0) + ] + if not missing: + return + + refresh_slice = missing + if len(refresh_slice) == 1: + game = refresh_slice[0] + _echo(f" Refreshing HLTB confidence for {game.name} (AppID={game.app_id})...") + else: + _echo(f" Refreshing HLTB confidence for {len(refresh_slice)} candidate(s)...") + + cache = load_hltb_cache() + polls = load_hltb_polls_cache() + count_comp = load_hltb_count_comp_cache() + app_ids = [game.app_id for game in refresh_slice] + names = [(game.app_id, game.name) for game in refresh_slice] + prior_hours = {aid: cache.get(aid, -1) for aid in app_ids} + + for aid in app_ids: + cache.pop(aid, None) + polls.pop(aid, None) + count_comp.pop(aid, None) + save_hltb_cache(cache, polls, count_comp) + + fetch_hltb_confidence_cached(names) + + refreshed_hours = load_hltb_cache() + refreshed_polls = load_hltb_polls_cache() + refreshed_count_comp = load_hltb_count_comp_cache() + for aid, old_hours in prior_hours.items(): + if old_hours > 0 and refreshed_hours.get(aid, -1) <= 0: + refreshed_hours[aid] = old_hours + save_hltb_cache(refreshed_hours, refreshed_polls, refreshed_count_comp) + + for game in refresh_slice: + game.comp_100_count = refreshed_polls.get(game.app_id, 0) + game.count_comp = refreshed_count_comp.get(game.app_id, 0) + + +def _filter_hltb_confident_candidates( + candidates: list[GameInfo], +) -> list[GameInfo]: + """Keep only candidates that satisfy HLTB confidence thresholds.""" + _refresh_candidate_confidence_batch(candidates) + + kept: list[GameInfo] = [] + for game in candidates: + reasons = _confidence_fail_reasons(game) + if reasons: + _echo( + f" Skipping {game.name} (AppID={game.app_id}): " + f"HLTB confidence too low ({'; '.join(reasons)})" + ) + continue + kept.append(game) + return kept + + +def _candidate_passes_hltb_confidence(game: GameInfo) -> bool: + """Return True if candidate passes confidence with cache-first behavior. + + Only refreshes when confidence fields are missing (both zero), which keeps + normal runs cache-friendly and avoids repeated refetches for known + low-confidence entries. + """ + reasons = _confidence_fail_reasons(game) + if not reasons: + return True + + # Re-check once when confidence fields are missing in cache. + _refresh_candidate_confidence(game) + reasons = _confidence_fail_reasons(game) + if reasons: + _echo( + f" Skipping {game.name} (AppID={game.app_id}): " + f"HLTB confidence too low ({'; '.join(reasons)})" + ) + return False + return True + + +def _pick_next_shortest_candidate( + candidates: list[GameInfo], +) -> tuple[GameInfo | None, int, int]: + """Pick next game by checking confidence one candidate at a time. + + The list must be pre-sorted by desired priority (shortest first). + """ + confidence_skipped = 0 + linux_skipped = 0 + for game in candidates: + if not _candidate_passes_hltb_confidence(game): + confidence_skipped += 1 + continue + + # Reuse existing ProtonDB compatibility gate for one candidate. + playable = _pick_playable_candidate([game]) + if playable is not None: + if linux_skipped > 0: + _echo( + f" Skipped {linux_skipped} game(s) with poor Linux compatibility" + ) + return playable, confidence_skipped, linux_skipped + linux_skipped += 1 + + if linux_skipped > 0: + _echo(f" Skipped {linux_skipped} game(s) with poor Linux compatibility") + return None, confidence_skipped, linux_skipped + + +def _backfill_polls_for_finished( + state: State, + games: list[GameInfo], +) -> dict[int, int]: + """Lazily fetch poll counts for already-finished games missing them. + + Reads the polls cache, identifies finished games whose poll count is + still ``0`` (typically because the cache predates the polls schema), + and triggers a one-shot HLTB search to backfill them. Returns the + refreshed polls cache. + """ + polls_cache = load_hltb_polls_cache() + name_by_id = {g.app_id: g.name for g in games} + missing = [ + (aid, name_by_id[aid]) + for aid in state.finished_app_ids + if aid in name_by_id and polls_cache.get(aid, 0) == 0 + ] + if not missing: + return polls_cache + + logger.info( + "Backfilling HLTB poll counts for %d already-finished games...", + len(missing), + ) + # Force a fresh search by removing the hours entries we want to refetch. + # (fetch_hltb_times_cached skips entries already in the hours cache.) + cache = load_hltb_cache() + preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} + for aid, _name in missing: + cache.pop(aid, None) + save_hltb_cache(cache, polls_cache) + + fetch_hltb_confidence_cached(missing) + + # Restore any previously-known hours that the refetch may have replaced + # with a worse match (we trust prior leisure+dlc estimates). + refreshed_hours = load_hltb_cache() + refreshed_polls = load_hltb_polls_cache() + for aid, prior_hours in preserved_hours.items(): + if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0: + refreshed_hours[aid] = prior_hours + save_hltb_cache(refreshed_hours, refreshed_polls) + return refreshed_polls + + +def _report_poll_confidence( + chosen: GameInfo, + games: list[GameInfo], + state: State, +) -> None: + """Print HLTB poll-count confidence info for the just-assigned game. + + Shows the chosen game's ``comp_100_count`` (number of polled + completionist times on HowLongToBeat) and the historical minimum + among the user's previously-finished games. Marks a new historical + low so the user can be skeptical of unreliable estimates. + """ + polls_cache = _backfill_polls_for_finished(state, games) + chosen_polls = polls_cache.get(chosen.app_id, chosen.comp_100_count) + chosen.comp_100_count = chosen_polls + + finished_polls = [ + (polls_cache[aid], aid) + for aid in state.finished_app_ids + if polls_cache.get(aid, 0) > 0 + ] + if not finished_polls: + _echo(f" HLTB confidence: {chosen_polls} polled completionist times") + return + + min_polls, min_aid = min(finished_polls) + name_by_id = {g.app_id: g.name for g in games} + min_name = name_by_id.get(min_aid, f"AppID={min_aid}") + + warning = "" + if 0 < chosen_polls < min_polls: + warning = " ⚠ NEW LOW — estimate may be unreliable" + elif chosen_polls == 0: + warning = " ⚠ no polls recorded — estimate may be unreliable" + + _echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}") + _echo(f" Historical min among finished: {min_polls} ({min_name})") + + # ────────────────────────────────────────────────────────────── # Checking & tampering detection # ────────────────────────────────────────────────────────────── diff --git a/python_pkg/steam_backlog_enforcer/steam_api.py b/python_pkg/steam_backlog_enforcer/steam_api.py index d8f1f9c..3ecbf76 100644 --- a/python_pkg/steam_backlog_enforcer/steam_api.py +++ b/python_pkg/steam_backlog_enforcer/steam_api.py @@ -41,6 +41,8 @@ class GameInfo: playtime_minutes: int achievements: list[AchievementInfo] = field(default_factory=list) completionist_hours: float = -1 + comp_100_count: int = 0 + count_comp: int = 0 @property def completion_pct(self) -> float: @@ -66,6 +68,8 @@ class GameInfo: "unlocked_achievements": self.unlocked_achievements, "playtime_minutes": self.playtime_minutes, "completionist_hours": self.completionist_hours, + "comp_100_count": self.comp_100_count, + "count_comp": self.count_comp, "achievements": [ { "api_name": a.api_name, @@ -96,6 +100,8 @@ class GameInfo: unlocked_achievements=data["unlocked_achievements"], playtime_minutes=data.get("playtime_minutes", 0), completionist_hours=data.get("completionist_hours", -1), + comp_100_count=data.get("comp_100_count", 0), + count_comp=data.get("count_comp", 0), achievements=achievements, ) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py b/python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py index 99c6d5c..8c7435a 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_cmd_done.py @@ -2,31 +2,32 @@ from __future__ import annotations -from typing import Any from unittest.mock import patch -from python_pkg.steam_backlog_enforcer._cmd_done import _try_reassign_shorter_game +from python_pkg.steam_backlog_enforcer._cmd_done import ( + _should_reassign_candidate, + _try_reassign_shorter_game, +) from python_pkg.steam_backlog_enforcer.config import Config, State from python_pkg.steam_backlog_enforcer.steam_api import GameInfo CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done" -def _snap( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, +def _snap(**overrides: object) -> dict[str, object]: + snapshot: dict[str, object] = { + "app_id": 1, + "name": "G", + "total_achievements": 10, + "unlocked_achievements": 0, "playtime_minutes": 60, - "completionist_hours": hours, + "completionist_hours": -1, + "comp_100_count": 3, + "count_comp": 15, } + snapshot["app_id"] = overrides.get("app_id", 1) + snapshot.update(overrides) + return snapshot class TestTryReassignShorterGame: @@ -37,7 +38,12 @@ class TestTryReassignShorterGame: assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config()) def test_no_shorter_candidate(self) -> None: - snap = [_snap(1, "G", 10, 5, 10.0), _snap(2, "H", 10, 5, -1)] + snap = [ + _snap( + app_id=1, name="G", unlocked_achievements=5, completionist_hours=10.0 + ), + _snap(app_id=2, name="H", unlocked_achievements=5), + ] with ( patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}._echo"), @@ -53,8 +59,15 @@ class TestTryReassignShorterGame: def test_reassigns(self) -> None: snap = [ - _snap(1, "Long", 10, 5, 100.0), - _snap(2, "Short", 10, 5, 5.0), + _snap( + app_id=1, + name="Long", + unlocked_achievements=5, + completionist_hours=100.0, + ), + _snap( + app_id=2, name="Short", unlocked_achievements=5, completionist_hours=5.0 + ), ] state = State(current_app_id=2, current_game_name="Short") short_game = GameInfo( @@ -69,8 +82,8 @@ class TestTryReassignShorterGame: patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}._echo"), patch( - f"{CMD_DONE_PKG}._pick_playable_candidate", - return_value=short_game, + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(short_game, 0, 0), ), patch(f"{CMD_DONE_PKG}.pick_next_game"), patch( @@ -91,8 +104,15 @@ class TestTryReassignShorterGame: def test_reassigns_no_hide_when_no_owned_ids(self) -> None: snap = [ - _snap(1, "Long", 10, 5, 100.0), - _snap(2, "Short", 10, 5, 5.0), + _snap( + app_id=1, + name="Long", + unlocked_achievements=5, + completionist_hours=100.0, + ), + _snap( + app_id=2, name="Short", unlocked_achievements=5, completionist_hours=5.0 + ), ] state = State(current_app_id=2, current_game_name="Short") short_game = GameInfo( @@ -107,8 +127,8 @@ class TestTryReassignShorterGame: patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}._echo") as mock_echo, patch( - f"{CMD_DONE_PKG}._pick_playable_candidate", - return_value=short_game, + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(short_game, 0, 0), ), patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), @@ -128,8 +148,15 @@ class TestTryReassignShorterGame: def test_reassigns_skip_hide_when_no_app_assigned(self) -> None: snap = [ - _snap(1, "Long", 10, 5, 100.0), - _snap(2, "Short", 10, 5, 5.0), + _snap( + app_id=1, + name="Long", + unlocked_achievements=5, + completionist_hours=100.0, + ), + _snap( + app_id=2, name="Short", unlocked_achievements=5, completionist_hours=5.0 + ), ] state = State(current_app_id=None, current_game_name="") short_game = GameInfo( @@ -144,8 +171,8 @@ class TestTryReassignShorterGame: patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}._echo"), patch( - f"{CMD_DONE_PKG}._pick_playable_candidate", - return_value=short_game, + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(short_game, 0, 0), ), patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids") as mock_owned, @@ -164,12 +191,22 @@ class TestTryReassignShorterGame: def test_playable_none(self) -> None: snap = [ - _snap(1, "Long", 10, 5, 100.0), - _snap(2, "Short", 10, 5, 5.0), + _snap( + app_id=1, + name="Long", + unlocked_achievements=5, + completionist_hours=100.0, + ), + _snap( + app_id=2, name="Short", unlocked_achievements=5, completionist_hours=5.0 + ), ] with ( patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), - patch(f"{CMD_DONE_PKG}._pick_playable_candidate", return_value=None), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(None, 0, 0), + ), patch(f"{CMD_DONE_PKG}._echo"), ): result = _try_reassign_shorter_game( @@ -184,8 +221,18 @@ class TestTryReassignShorterGame: def test_playable_longer(self) -> None: """Playable candidate is longer than current — no reassign.""" snap = [ - _snap(1, "Short", 10, 5, 10.0), - _snap(2, "Long", 10, 5, 200.0), + _snap( + app_id=1, + name="Short", + unlocked_achievements=5, + completionist_hours=10.0, + ), + _snap( + app_id=2, + name="Long", + unlocked_achievements=5, + completionist_hours=200.0, + ), ] long_game = GameInfo( app_id=2, @@ -197,7 +244,10 @@ class TestTryReassignShorterGame: ) with ( patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), - patch(f"{CMD_DONE_PKG}._pick_playable_candidate", return_value=long_game), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(long_game, 0, 0), + ), patch(f"{CMD_DONE_PKG}._echo"), ): result = _try_reassign_shorter_game( @@ -212,8 +262,13 @@ class TestTryReassignShorterGame: def test_refreshes_stale_shorter_snapshot_entry(self) -> None: """Uncached shorter snapshot candidates are refreshed before reassigning.""" snap = [ - _snap(1, "Current", 10, 5, 20.1), - _snap(2, "Lacuna", 10, 0, 0.9), + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=20.1, + ), + _snap(app_id=2, name="Lacuna", completionist_hours=0.9), ] state = State(current_app_id=1, current_game_name="Current") refreshed_short = GameInfo( @@ -231,9 +286,9 @@ class TestTryReassignShorterGame: return_value={2: 18.8}, ) as mock_fetch_hltb, patch( - f"{CMD_DONE_PKG}._pick_playable_candidate", - return_value=refreshed_short, - ) as mock_pick_playable, + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(refreshed_short, 0, 0), + ) as mock_pick_candidate, patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), @@ -249,4 +304,328 @@ class TestTryReassignShorterGame: assert result mock_fetch_hltb.assert_called_once_with([(2, "Lacuna")]) - mock_pick_playable.assert_called_once() + mock_pick_candidate.assert_called_once() + + def test_reassigns_when_current_confidence_too_low(self) -> None: + """If current game fails confidence thresholds, reassign anyway.""" + snap = [ + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=20.0, + comp_100_count=0, + count_comp=0, + ), + _snap( + app_id=2, + name="Confident", + unlocked_achievements=5, + completionist_hours=25.0, + ), + ] + state = State(current_app_id=2, current_game_name="Confident") + confident_game = GameInfo( + app_id=2, + name="Confident", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=25.0, + comp_100_count=3, + count_comp=15, + ) + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(confident_game, 0, 0), + ), + patch(f"{CMD_DONE_PKG}.pick_next_game"), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{CMD_DONE_PKG}.hide_other_games"), + patch(f"{CMD_DONE_PKG}._echo") as mock_echo, + ): + result = _try_reassign_shorter_game( + {1: 20.0, 2: 25.0}, + 1, + 20.0, + state, + Config(), + ) + + assert result + assert any( + "confidence too low" in str(call).lower() + for call in mock_echo.call_args_list + ) + + def test_does_not_force_refresh_current_when_cached_confidence_is_good( + self, + ) -> None: + """Current-game confidence check should use cache-backed values first.""" + snap = [ + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=20.0, + comp_100_count=0, + count_comp=0, + ), + _snap( + app_id=2, + name="Shorter", + unlocked_achievements=5, + completionist_hours=5.0, + comp_100_count=3, + count_comp=15, + ), + ] + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_hltb_polls_cache", return_value={1: 36, 2: 20}), + patch( + f"{CMD_DONE_PKG}.load_hltb_count_comp_cache", + return_value={1: 200, 2: 50}, + ), + patch(f"{CMD_DONE_PKG}._refresh_candidate_confidence") as mock_refresh, + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(None, 0, 0), + ), + patch(f"{CMD_DONE_PKG}._echo"), + ): + result = _try_reassign_shorter_game( + {1: 20.0, 2: 5.0}, + 1, + 20.0, + State(), + Config(), + ) + + assert not result + mock_refresh.assert_not_called() + + def test_only_checks_strictly_shorter_candidates_when_not_forced(self) -> None: + """No confidence checks should run for non-shorter games.""" + snap = [ + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=4.0, + comp_100_count=10, + count_comp=40, + ), + _snap( + app_id=2, + name="TooLong", + unlocked_achievements=5, + completionist_hours=8.0, + comp_100_count=1, + count_comp=8, + ), + ] + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_hltb_polls_cache", return_value={1: 10, 2: 1}), + patch( + f"{CMD_DONE_PKG}.load_hltb_count_comp_cache", return_value={1: 40, 2: 8} + ), + patch(f"{CMD_DONE_PKG}._pick_next_shortest_candidate") as mock_pick, + patch(f"{CMD_DONE_PKG}._echo"), + ): + result = _try_reassign_shorter_game( + {1: 4.0, 2: 8.0}, + 1, + 4.0, + State(), + Config(), + ) + + assert not result + mock_pick.assert_not_called() + + def test_reassigns_when_current_hours_unknown(self) -> None: + """If current game has unknown hours, allow a confident replacement.""" + snap = [ + _snap(app_id=1, name="Current", unlocked_achievements=5), + _snap( + app_id=2, name="Known", unlocked_achievements=5, completionist_hours=9.0 + ), + ] + state = State(current_app_id=2, current_game_name="Known") + known_game = GameInfo( + app_id=2, + name="Known", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=9.0, + comp_100_count=3, + count_comp=15, + ) + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(known_game, 0, 0), + ), + patch(f"{CMD_DONE_PKG}.pick_next_game"), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{CMD_DONE_PKG}.hide_other_games"), + ): + result = _try_reassign_shorter_game( + {2: 9.0}, + 1, + -1.0, + state, + Config(), + ) + + assert result + + def test_try_reassign_returns_false_when_playable_not_shorter(self) -> None: + """_try_reassign_shorter_game should not reassign to longer candidates.""" + snap = [ + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=8.0, + comp_100_count=10, + count_comp=40, + ), + _snap( + app_id=2, + name="Longer", + unlocked_achievements=5, + completionist_hours=12.0, + comp_100_count=10, + count_comp=40, + ), + ] + longer = GameInfo( + app_id=2, + name="Longer", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=12.0, + comp_100_count=10, + count_comp=40, + ) + + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch( + f"{CMD_DONE_PKG}.load_hltb_polls_cache", + return_value={1: 10, 2: 10}, + ), + patch( + f"{CMD_DONE_PKG}.load_hltb_count_comp_cache", + return_value={1: 40, 2: 40}, + ), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(longer, 0, 0), + ), + patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next, + patch(f"{CMD_DONE_PKG}._echo"), + ): + result = _try_reassign_shorter_game( + hltb_cache={1: 8.0, 2: 12.0}, + app_id=1, + hours=8.0, + state=State(), + config=Config(), + ) + + assert not result + mock_pick_next.assert_not_called() + + def test_try_reassign_stops_when_should_reassign_is_false(self) -> None: + """Covers early return when policy says not to reassign.""" + snap = [ + _snap( + app_id=1, + name="Current", + unlocked_achievements=5, + completionist_hours=8.0, + comp_100_count=10, + count_comp=40, + ), + _snap( + app_id=2, + name="Candidate", + unlocked_achievements=5, + completionist_hours=6.0, + comp_100_count=10, + count_comp=40, + ), + ] + candidate = GameInfo( + app_id=2, + name="Candidate", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=6.0, + comp_100_count=10, + count_comp=40, + ) + + with ( + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch( + f"{CMD_DONE_PKG}.load_hltb_polls_cache", + return_value={1: 10, 2: 10}, + ), + patch( + f"{CMD_DONE_PKG}.load_hltb_count_comp_cache", + return_value={1: 40, 2: 40}, + ), + patch( + f"{CMD_DONE_PKG}._pick_next_shortest_candidate", + return_value=(candidate, 0, 0), + ), + patch( + f"{CMD_DONE_PKG}._should_reassign_candidate", + return_value=False, + ), + patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next, + patch(f"{CMD_DONE_PKG}._echo"), + ): + result = _try_reassign_shorter_game( + hltb_cache={1: 8.0, 2: 6.0}, + app_id=1, + hours=8.0, + state=State(), + config=Config(), + ) + + assert not result + mock_pick_next.assert_not_called() + + +class TestShouldReassignCandidate: + """Tests for _should_reassign_candidate.""" + + def test_returns_false_when_candidate_not_shorter(self) -> None: + candidate = GameInfo( + app_id=2, + name="Candidate", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=9.0, + comp_100_count=3, + count_comp=15, + ) + should = _should_reassign_candidate( + candidate, + 8.0, + force_reassign=False, + ) + assert should is False diff --git a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py b/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py index a3994f2..5f06504 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py @@ -21,9 +21,12 @@ PKG = "python_pkg.steam_backlog_enforcer._enforce_loop" class TestGetAllOwnedAppIds: """Tests for get_all_owned_app_ids.""" - def test_from_snapshot(self) -> None: + def test_snapshot_used_when_api_fails(self) -> None: snap = [{"app_id": 1}, {"app_id": 2}] - with patch(f"{PKG}.load_snapshot", return_value=snap): + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}.SteamAPIClient", side_effect=OSError("boom")), + ): assert get_all_owned_app_ids(Config()) == [1, 2] def test_no_snapshot_falls_back_to_api(self) -> None: @@ -60,6 +63,21 @@ class TestGetAllOwnedAppIds: ): assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5] + def test_merges_snapshot_with_api_results(self) -> None: + mock_client = MagicMock() + mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}] + with ( + patch( + f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}] + ), + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + ): + assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [ + 10, + 20, + 30, + ] + class TestGuardInstalledGames: """Tests for _guard_installed_games.""" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py b/python_pkg/steam_backlog_enforcer/tests/test_game_install.py index 6b558cf..58d7e7f 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_game_install.py @@ -63,6 +63,20 @@ class TestAssertNotRealSteam: ): _assert_not_real_steam(fake_manifest) + def test_noop_outside_pytest(self, tmp_path: Path) -> None: + """In production (no PYTEST_CURRENT_TEST) the guard is a no-op.""" + real = tmp_path / "real_steam" + real.mkdir() + fake_manifest = real / "appmanifest_440.acf" + fake_manifest.touch() + env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} + with ( + patch.dict(os.environ, env, clear=True), + patch(f"{PKG}._REAL_STEAMAPPS", real), + patch(f"{PKG}.STEAMAPPS_PATH", real), + ): + _assert_not_real_steam(fake_manifest) + class TestEcho: """Tests for _echo.""" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py index 7d28c4e..8c6d274 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py @@ -203,7 +203,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, return_value=game_data, ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) assert cache[440] == round(21243 / 3600, 2) assert results[0].completionist_hours == round(21243 / 3600, 2) @@ -218,12 +218,12 @@ class TestFetchLeisureTimes: ), ] cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) assert not cache def test_empty_results(self) -> None: cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times([], cache, None)) + asyncio.run(_fetch_leisure_times([], cache, {}, None)) assert not cache def test_detail_returns_none(self) -> None: @@ -242,7 +242,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, return_value=None, ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) assert not cache assert results[0].completionist_hours == 50.0 @@ -263,7 +263,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, return_value=game_data, ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) assert not cache assert results[0].completionist_hours == 50.0 @@ -288,7 +288,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, return_value=game_data, ): - asyncio.run(_fetch_leisure_times(results, cache, cb)) + asyncio.run(_fetch_leisure_times(results, cache, {}, cb)) cb.assert_called_once() def test_save_interval(self) -> None: @@ -318,7 +318,7 @@ class TestFetchLeisureTimes: "python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache" ) as mock_save, ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) mock_save.assert_called_once() def test_dlc_detail_overrides_relationship_fallback(self) -> None: @@ -345,7 +345,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, side_effect=[base_data, dlc_data], ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) expected = round((21243 + 12298) / 3600, 2) assert cache[1289310] == expected @@ -371,7 +371,7 @@ class TestFetchLeisureTimes: new_callable=AsyncMock, side_effect=[base_data, None], ): - asyncio.run(_fetch_leisure_times(results, cache, None)) + asyncio.run(_fetch_leisure_times(results, cache, {}, None)) expected = round((21243 + 4075) / 3600, 2) assert cache[1289310] == expected diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py index 4f0eedc..a600baf 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py @@ -2,11 +2,18 @@ from __future__ import annotations +import asyncio from unittest.mock import MagicMock, patch +from typing_extensions import Self + from python_pkg.steam_backlog_enforcer.hltb import ( HLTB_BASE_URL, HLTBResult, + _AuthInfo, + _fetch_batch_confidence_only, + fetch_hltb_confidence, + fetch_hltb_confidence_cached, fetch_hltb_times_cached, get_hltb_submit_url, ) @@ -35,10 +42,16 @@ class TestFetchHltbTimesCached: def add_to_cache( _games: object, cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, progress_cb: object = None, + count_comp: dict[int, int] | None = None, ) -> list[object]: if cache is not None: cache[730] = 20.0 + if polls is not None: + polls[730] = 0 + if count_comp is not None: + count_comp[730] = 0 return [] mock_fetch.side_effect = add_to_cache @@ -87,11 +100,19 @@ class TestFetchHltbTimesCached: def add_found( _games: object, cache: dict[int, float] | None = None, + polls: dict[int, int] | None = None, progress_cb: object = None, + count_comp: dict[int, int] | None = None, ) -> list[object]: if cache is not None: cache[440] = 50.0 cache[730] = -1 + if polls is not None: + polls[440] = 5 + polls[730] = 0 + if count_comp is not None: + count_comp[440] = 15 + count_comp[730] = 0 return [] mock_fetch.side_effect = add_found @@ -133,3 +154,82 @@ class TestGetHltbSubmitUrl: with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): url = get_hltb_submit_url("TF2") assert url is None + + +class _DummySession: + """Minimal async context manager used to mock aiohttp ClientSession.""" + + async def __aenter__(self) -> Self: + """Enter async context.""" + return self + + async def __aexit__(self, *_args: object) -> bool: + """Exit async context.""" + return False + + +class TestConfidenceHelpers: + """Coverage tests for confidence-fetch helpers.""" + + def test_fetch_batch_confidence_only_returns_empty_without_auth(self) -> None: + with ( + patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), + patch(f"{PKG}.aiohttp.TCPConnector"), + patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), + patch(f"{PKG}._get_auth_info", return_value=None), + ): + result = asyncio.run( + _fetch_batch_confidence_only([(1, "Game")], {}, {}, None), + ) + assert result == [] + + def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts( + self, + ) -> None: + auth_token = str(1) + with ( + patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), + patch(f"{PKG}.aiohttp.TCPConnector"), + patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), + patch( + f"{PKG}._get_auth_info", + return_value=_AuthInfo(token=auth_token, hp_key="", hp_val=""), + ), + patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, + ): + result = asyncio.run( + _fetch_batch_confidence_only( + games=[(1, "Game")], + cache={}, + polls={}, + progress_cb=None, + count_comp=None, + ), + ) + assert result == [] + mock_search.assert_called_once() + + def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None: + with patch(f"{PKG}.asyncio.run", return_value=[]) as mock_run: + result = fetch_hltb_confidence([(1, "Game")]) + assert result == [] + mock_run.assert_called_once() + + def test_fetch_hltb_confidence_empty_games_returns_empty(self) -> None: + with patch(f"{PKG}.asyncio.run") as mock_run: + result = fetch_hltb_confidence([]) + assert result == [] + mock_run.assert_not_called() + + def test_fetch_hltb_confidence_cached_all_cached_skips_fetch(self) -> None: + with ( + patch(f"{PKG}.load_hltb_cache", return_value={1: 12.0}), + patch(f"{PKG}.load_hltb_polls_cache", return_value={1: 30}), + patch(f"{PKG}.load_hltb_count_comp_cache", return_value={1: 200}), + patch(f"{PKG}.fetch_hltb_confidence") as mock_fetch, + patch(f"{PKG}.save_hltb_cache") as mock_save, + ): + result = fetch_hltb_confidence_cached([(1, "Game")]) + assert result == {1: 12.0} + mock_fetch.assert_not_called() + mock_save.assert_not_called() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py index 1d24037..ba4c2a6 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py @@ -19,6 +19,7 @@ from python_pkg.steam_backlog_enforcer.hltb import ( HLTBResult, _AuthInfo, _fetch_batch, + _pick_best_hltb_entry, _search_one, _SearchCtx, ) @@ -109,6 +110,37 @@ class TestSearchOne: result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) assert result is None + def test_fallback_name_without_year_suffix(self) -> None: + session = MagicMock() + session.post.side_effect = [ + _FakeResponse(200, {"data": []}), + _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Final Fantasy VII", + "game_alias": "", + "game_type": "game", + "comp_100": 141120, + "game_id": 435, + "comp_100_count": 746, + "count_comp": 10450, + } + ] + }, + ), + ] + ctx = _make_ctx(session) + result = asyncio.run( + _search_one(asyncio.Semaphore(1), ctx, 39140, "Final Fantasy VII (2013)") + ) + assert result is not None + assert result.app_id == 39140 + assert result.comp_100_count == 746 + assert result.count_comp == 10450 + assert session.post.call_count == 2 + def test_with_progress_cb(self) -> None: resp = _FakeResponse(200, {"data": []}) cb = MagicMock() @@ -235,9 +267,69 @@ class TestFetchBatchHltb: return_value=None, ), ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) assert results == [] + +class TestPickBestEntry: + """Tests for exact-vs-extended entry choice logic.""" + + def test_prefers_exact_over_low_confidence_modded_extended(self) -> None: + exact = ( + { + "game_name": "Celeste", + "game_alias": "", + "game_type": "game", + "comp_100": 141105, + "comp_100_count": 899, + "count_comp": 14055, + }, + 1.0, + ) + mod_extended = ( + { + "game_name": "Celeste - Strawberry Jam", + "game_alias": "", + "game_type": "mod", + "comp_100": 952080, + "comp_100_count": 1, + "count_comp": 6, + }, + 0.9, + ) + + best = _pick_best_hltb_entry("Celeste", [exact, mod_extended]) + assert best is not None + assert best[0]["game_name"] == "Celeste" + + def test_prefers_extended_when_confident_and_longer(self) -> None: + exact_demo = ( + { + "game_name": "FAITH", + "game_alias": "", + "game_type": "game", + "comp_100": 1800, + "comp_100_count": 1, + "count_comp": 1, + }, + 1.0, + ) + full_extended = ( + { + "game_name": "FAITH: The Unholy Trinity", + "game_alias": "", + "game_type": "game", + "comp_100": 25200, + "comp_100_count": 50, + "count_comp": 500, + }, + 0.9, + ) + + best = _pick_best_hltb_entry("FAITH", [exact_demo, full_extended]) + assert best is not None + assert best[0]["game_name"] == "FAITH: The Unholy Trinity" + def test_with_auth(self) -> None: auth = _AuthInfo("token123", "ign_x", "ff") with ( @@ -266,7 +358,7 @@ class TestFetchBatchHltb: new_callable=AsyncMock, ), ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) assert len(results) == 1 def test_with_auth_no_hp(self) -> None: @@ -291,7 +383,7 @@ class TestFetchBatchHltb: new_callable=AsyncMock, ), ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) assert results == [] def test_filters_none_results(self) -> None: @@ -316,7 +408,7 @@ class TestFetchBatchHltb: new_callable=AsyncMock, ), ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) assert results == [] diff --git a/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py index 0b5583f..a91c03c 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py @@ -206,6 +206,8 @@ class TestEnforceOnDone: ), patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2), patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1), ): _enforce_on_done(config, state) @@ -220,6 +222,8 @@ class TestEnforceOnDone: patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]), patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0), patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True), + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0), ): _enforce_on_done(config, state) @@ -234,6 +238,8 @@ class TestEnforceOnDone: patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False), patch(f"{CMD_DONE_PKG}.install_game") as mock_install, + patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0), ): _enforce_on_done(config, state) mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py b/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py new file mode 100644 index 0000000..e934c4d --- /dev/null +++ b/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py @@ -0,0 +1,729 @@ +"""Tests for HLTB poll-count tracking, schema migration, and confidence display.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from python_pkg.steam_backlog_enforcer import _cmd_done, scanning +from python_pkg.steam_backlog_enforcer._hltb_types import ( + HLTBResult, + load_hltb_cache, + load_hltb_count_comp_cache, + load_hltb_polls_cache, + save_hltb_cache, +) +from python_pkg.steam_backlog_enforcer.config import State +from python_pkg.steam_backlog_enforcer.steam_api import GameInfo + +if TYPE_CHECKING: + from pathlib import Path + +_TYPES = "python_pkg.steam_backlog_enforcer._hltb_types" +_CMD = "python_pkg.steam_backlog_enforcer._cmd_done" +_SCAN = "python_pkg.steam_backlog_enforcer.scanning" + + +class TestCacheSchema: + """Tests for the new cache schema and back-compat migration.""" + + def test_legacy_float_migrates(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") + with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): + assert load_hltb_cache() == {440: 10.5} + assert load_hltb_polls_cache() == {440: 0} + assert load_hltb_count_comp_cache() == {440: 0} + + def test_new_dict_schema(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"440": {"hours": 10.5, "polls": 7, "count_comp": 20}}), + encoding="utf-8", + ) + with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): + assert load_hltb_cache() == {440: 10.5} + assert load_hltb_polls_cache() == {440: 7} + assert load_hltb_count_comp_cache() == {440: 20} + + def test_invalid_app_id_skipped(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"notanint": 1.0, "440": 5.0}), encoding="utf-8" + ) + with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): + assert load_hltb_cache() == {440: 5.0} + + def test_unparseable_value_skipped(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text(json.dumps({"440": "notafloat"}), encoding="utf-8") + with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): + assert load_hltb_cache() == {} + + def test_save_with_polls_roundtrip(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + ): + save_hltb_cache({440: 10.5}, {440: 7}, {440: 20}) + data = json.loads(cache_file.read_text(encoding="utf-8")) + assert data == {"440": {"hours": 10.5, "polls": 7, "count_comp": 20}} + + def test_save_without_polls_defaults_zero(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + ): + save_hltb_cache({440: 10.5}) + data = json.loads(cache_file.read_text(encoding="utf-8")) + assert data == {"440": {"hours": 10.5, "polls": 0, "count_comp": 0}} + + +class TestHltbResultPolls: + def test_default_zero(self) -> None: + r = HLTBResult(app_id=1, game_name="x", completionist_hours=1.0, similarity=1) + assert r.comp_100_count == 0 + assert r.count_comp == 0 + + def test_explicit(self) -> None: + r = HLTBResult( + app_id=1, + game_name="x", + completionist_hours=1.0, + similarity=1, + comp_100_count=42, + count_comp=100, + ) + assert r.comp_100_count == 42 + assert r.count_comp == 100 + + +class TestGameInfoPolls: + def test_snapshot_roundtrip(self) -> None: + g = GameInfo( + app_id=1, + name="X", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=30, + comp_100_count=8, + count_comp=20, + ) + snap = g.to_snapshot() + assert snap["comp_100_count"] == 8 + assert snap["count_comp"] == 20 + restored = GameInfo.from_snapshot(snap) + assert restored.comp_100_count == 8 + assert restored.count_comp == 20 + + def test_snapshot_missing_field_defaults(self) -> None: + snap = { + "app_id": 1, + "name": "X", + "total_achievements": 0, + "unlocked_achievements": 0, + } + restored = GameInfo.from_snapshot(snap) + assert restored.comp_100_count == 0 + assert restored.count_comp == 0 + + +def _state(finished: list[int], current: int | None = None) -> State: + s = State() + s.finished_app_ids = list(finished) + s.current_app_id = current + s.current_game_name = "" + return s + + +class TestBackfillPollsForFinished: + def test_no_missing_returns_existing(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8" + ) + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + ): + result = _cmd_done._backfill_polls_for_finished(_state([1])) + assert result == {1: 5} + + def test_no_snapshot_no_missing(self) -> None: + with ( + patch(f"{_CMD}.load_hltb_polls_cache", return_value={}), + patch(f"{_CMD}.load_snapshot", return_value=None), + ): + assert _cmd_done._backfill_polls_for_finished(_state([1])) == {} + + def test_missing_triggers_fetch(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": 2.0, "polls": 9} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: 2.0 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + result = _cmd_done._backfill_polls_for_finished(_state([1])) + assert result == {1: 9} + + def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": 1.0, "polls": 4} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: 1.0 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + result = _cmd_done._backfill_polls_for_finished( + _state([], current=7), extra_app_id=7 + ) + assert result == {7: 4} + + def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + # Simulate a refetch returning a miss (hours -1, polls 0). + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": -1, "polls": 0} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: -1 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), + patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_CMD}._echo"), + ): + _cmd_done._backfill_polls_for_finished(_state([3])) + # Prior hours should be preserved on miss. + final = json.loads(cache_file.read_text(encoding="utf-8")) + assert final["3"]["hours"] == 4.0 + + +class TestReportAssignedConfidence: + def test_new_low_warning(self) -> None: + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 1, 2: 5, 3: 10}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "OldShortest"}, + {"app_id": 3, "name": "Other"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2, 3], current=1)) + assert any("NEW LOW" in s for s in echoed) + assert any("Historical min" in s and "OldShortest" in s for s in echoed) + + def test_zero_polls_warning_with_history(self) -> None: + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 0, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert any("no polls recorded" in s for s in echoed) + + def test_zero_polls_warning_no_history(self) -> None: + echoed: list[str] = [] + with ( + patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([], current=1)) + assert any("no polls recorded" in s for s in echoed) + assert not any("Historical min" in s for s in echoed) + + def test_healthy_no_warning(self) -> None: + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 50, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + assert any("HLTB confidence: 50" in s for s in echoed) + + def test_unknown_finished_uses_appid_label(self) -> None: + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 50, 99: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([99], current=1)) + assert any("AppID=99" in s for s in echoed) + + def test_chosen_equals_min_no_warning(self) -> None: + # Edge case: chosen_polls == min_polls (not a new low). + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 5, 2: 5}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + {"app_id": 2, "name": "Old"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + + +class TestScanningPollsIntegration: + def test_do_scan_kept_assignment_reports(self) -> None: + # Targeted test for scanning's `else` branch that prints CURRENT. + echoed: list[str] = [] + games = [ + GameInfo( + app_id=1, + name="X", + total_achievements=10, + unlocked_achievements=2, + playtime_minutes=0, + completionist_hours=5.0, + comp_100_count=20, + ) + ] + state = _state([], current=1) + with ( + patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + patch(f"{_SCAN}._report_poll_confidence") as mock_report, + ): + # Directly invoke just the kept-assignment branch. + current = next((g for g in games if g.app_id == state.current_app_id), None) + assert current is not None + scanning._echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") + scanning._report_poll_confidence(current, games, state) + assert any("CURRENT" in s for s in echoed) + mock_report.assert_called_once() + + def test_report_poll_confidence_new_low(self) -> None: + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=0, + ) + games = [ + chosen, + GameInfo( + app_id=2, + name="Old", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=0, + ), + ] + with ( + patch( + f"{_SCAN}._backfill_polls_for_finished", + return_value={1: 1, 2: 5}, + ), + patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + scanning._report_poll_confidence(chosen, games, _state([2], current=1)) + assert any("NEW LOW" in s for s in echoed) + assert chosen.comp_100_count == 1 + + def test_report_poll_confidence_no_history(self) -> None: + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=4, + ) + with ( + patch(f"{_SCAN}._backfill_polls_for_finished", return_value={1: 4}), + patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + scanning._report_poll_confidence(chosen, [chosen], _state([], current=1)) + # No "Historical min" line when no finished games have polls. + assert not any("Historical min" in s for s in echoed) + assert any("HLTB confidence: 4" in s for s in echoed) + + def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8" + ) + with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): + result = scanning._backfill_polls_for_finished( + _state([2]), + [ + GameInfo( + app_id=2, + name="X", + total_achievements=0, + unlocked_achievements=0, + playtime_minutes=0, + ) + ], + ) + assert result == {2: 5} + + def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": 3.0, "polls": 8} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: 3.0 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + ): + result = scanning._backfill_polls_for_finished( + _state([2]), + [ + GameInfo( + app_id=2, + name="X", + total_achievements=0, + unlocked_achievements=0, + playtime_minutes=0, + ) + ], + ) + assert result == {2: 8} + + def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8" + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + data = json.loads(cache_file.read_text(encoding="utf-8")) + for aid, _name in games: + data[str(aid)] = {"hours": -1, "polls": 0} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {aid: -1 for aid, _ in games} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + ): + scanning._backfill_polls_for_finished( + _state([2]), + [ + GameInfo( + app_id=2, + name="X", + total_achievements=0, + unlocked_achievements=0, + playtime_minutes=0, + ) + ], + ) + final = json.loads(cache_file.read_text(encoding="utf-8")) + assert final["2"]["hours"] == 9.0 + + def test_report_poll_confidence_chosen_zero_polls(self) -> None: + """Covers scanning.py 301-302: 0-poll chosen with history yields warning.""" + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=0, + ) + old = GameInfo( + app_id=2, + name="Old", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=0, + ) + with ( + patch( + f"{_SCAN}._backfill_polls_for_finished", + return_value={1: 0, 2: 5}, + ), + patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + scanning._report_poll_confidence( + chosen, [chosen, old], _state([2], current=1) + ) + assert any("no polls recorded" in s for s in echoed) + + def test_do_scan_kept_assignment_missing_game(self) -> None: + """Covers scanning.py 110->116: current_app_id set but game absent.""" + from python_pkg.steam_backlog_enforcer.config import Config + from python_pkg.steam_backlog_enforcer.scanning import do_scan + + other = GameInfo( + app_id=999, + name="Other", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=0, + ) + from unittest.mock import MagicMock + + mock_client = MagicMock() + mock_client.build_game_list.return_value = [other] + with ( + patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client), + patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), + patch(f"{_SCAN}.save_snapshot"), + patch(f"{_SCAN}.pick_next_game") as mock_pick, + patch(f"{_SCAN}._echo"), + patch(f"{_SCAN}._report_poll_confidence") as mock_report, + ): + config = Config(steam_api_key="k", steam_id="i") + state = State(current_app_id=440) # not in games + do_scan(config, state) + mock_pick.assert_not_called() + mock_report.assert_not_called() + + def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None: + """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls.""" + echoed: list[str] = [] + with ( + patch( + f"{_CMD}._backfill_polls_for_finished", + return_value={1: 7}, + ), + patch( + f"{_CMD}.load_snapshot", + return_value=[ + {"app_id": 1, "name": "Chosen"}, + ], + ), + patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + _cmd_done._report_assigned_confidence(1, _state([], current=1)) + assert any("HLTB confidence: 7" in s for s in echoed) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + + def test_report_poll_confidence_chosen_equals_min(self) -> None: + """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning.""" + echoed: list[str] = [] + chosen = GameInfo( + app_id=1, + name="Chosen", + total_achievements=10, + unlocked_achievements=0, + playtime_minutes=0, + comp_100_count=5, + ) + old = GameInfo( + app_id=2, + name="Old", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=0, + ) + with ( + patch( + f"{_SCAN}._backfill_polls_for_finished", + return_value={1: 5, 2: 5}, + ), + patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), + ): + scanning._report_poll_confidence( + chosen, [chosen, old], _state([2], current=1) + ) + assert not any("NEW LOW" in s for s in echoed) + assert not any("no polls recorded" in s for s in echoed) + + def test_refresh_candidate_confidence_noop_when_present(self) -> None: + game = GameInfo( + app_id=1, + name="Known", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=3, + count_comp=15, + ) + with patch(f"{_SCAN}.fetch_hltb_confidence_cached") as mock_fetch: + scanning._refresh_candidate_confidence(game) + mock_fetch.assert_not_called() + + def test_refresh_candidate_confidence_backfills_zeroes( + self, tmp_path: Path + ) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}), + encoding="utf-8", + ) + game = GameInfo( + app_id=1, + name="NeedsRefresh", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + + def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]: + data = json.loads(cache_file.read_text(encoding="utf-8")) + data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {1: 4.0} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch(f"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch), + patch(f"{_SCAN}._echo"), + ): + scanning._refresh_candidate_confidence(game) + + assert game.comp_100_count == 3 + assert game.count_comp == 15 + + def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None: + """Filtering refreshes missing confidence in one batched cache lookup.""" + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text( + json.dumps( + { + "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, + "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, + } + ), + encoding="utf-8", + ) + game_a = GameInfo( + app_id=1, + name="A", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + game_b = GameInfo( + app_id=2, + name="B", + total_achievements=10, + unlocked_achievements=1, + playtime_minutes=0, + comp_100_count=0, + count_comp=0, + ) + + def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: + assert sorted(games) == [(1, "A"), (2, "B")] + data = json.loads(cache_file.read_text(encoding="utf-8")) + data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} + data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15} + cache_file.write_text(json.dumps(data), encoding="utf-8") + return {1: 4.0, 2: 5.0} + + with ( + patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{_TYPES}.CONFIG_DIR", tmp_path), + patch( + f"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch + ) as mock_fetch, + patch(f"{_SCAN}._echo"), + ): + kept = scanning._filter_hltb_confident_candidates([game_a, game_b]) + + assert [game.app_id for game in kept] == [1, 2] + mock_fetch.assert_called_once() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning.py index 3cdb73c..4eefb5a 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_scanning.py @@ -8,7 +8,11 @@ from unittest.mock import MagicMock, patch from python_pkg.steam_backlog_enforcer.config import Config, State from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating from python_pkg.steam_backlog_enforcer.scanning import ( + _filter_hltb_confident_candidates, + _force_refresh_candidate_confidence, + _pick_next_shortest_candidate, _pick_playable_candidate, + _refresh_candidate_confidence_batch, do_check, do_scan, pick_next_game, @@ -33,6 +37,8 @@ def _game( unlocked_achievements=unlocked, playtime_minutes=60, completionist_hours=hours, + comp_100_count=3, + count_comp=15, ) @@ -219,6 +225,9 @@ class TestPickNextGame: config = Config(steam_api_key="k", steam_id="i") state = State() with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence" + ), patch( "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", side_effect=lambda c: c[0] if c else None, @@ -286,6 +295,9 @@ class TestPickNextGame: config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) state = State() with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence" + ), patch( "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", side_effect=lambda c: c[0] if c else None, @@ -308,6 +320,9 @@ class TestPickNextGame: config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) state = State() with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence" + ), patch( "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", side_effect=lambda c: c[0] if c else None, @@ -370,6 +385,191 @@ class TestPickNextGame: pick_next_game([g1], state, config) assert state.current_app_id == 1 + def test_skips_low_confidence_and_picks_next(self) -> None: + low = _game(app_id=1, name="LowConfidence", hours=1.0) + low.comp_100_count = 1 + low.count_comp = 5 + valid = _game(app_id=2, name="ValidConfidence", hours=2.0) + valid.comp_100_count = 3 + valid.count_comp = 15 + echoed: list[str] = [] + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence" + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + ): + pick_next_game([low, valid], state, config) + assert state.current_app_id == 2 + assert any("Skipping LowConfidence" in line for line in echoed) + assert any("comp_100 polls 1 < 3" in line for line in echoed) + + def test_all_candidates_filtered_by_confidence(self) -> None: + low_a = _game(app_id=1, name="LowA", hours=1.0) + low_a.comp_100_count = 2 + low_a.count_comp = 15 + low_b = _game(app_id=2, name="LowB", hours=2.0) + low_b.comp_100_count = 3 + low_b.count_comp = 14 + echoed: list[str] = [] + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence" + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ) as mock_pick, + ): + pick_next_game([low_a, low_b], state, config) + assert state.current_app_id is None + mock_pick.assert_not_called() + assert any("No assignable games found" in line for line in echoed) + + def test_zero_confidence_is_refreshed_before_skipping(self) -> None: + """Missing confidence fields are refreshed once before final skip decision.""" + stale = _game(app_id=1, name="Celeste", hours=1.0) + stale.comp_100_count = 0 + stale.count_comp = 0 + fallback = _game(app_id=2, name="Fallback", hours=2.0) + + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + + def refresh_side_effect(game: GameInfo) -> None: + if game.app_id == 1: + game.comp_100_count = 899 + game.count_comp = 14055 + + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._refresh_candidate_confidence", + side_effect=refresh_side_effect, + ) as mock_refresh, + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + ): + pick_next_game([stale, fallback], state, config) + + assert state.current_app_id == 1 + mock_refresh.assert_called_once_with(stale) + assert not any("Skipping Celeste" in line for line in echoed) + + def test_nonzero_low_confidence_does_not_force_refetch(self) -> None: + """Non-zero low-confidence entries are skipped using cached values.""" + low = _game(app_id=1, name="Low", hours=1.0) + low.comp_100_count = 1 + low.count_comp = 8 + fallback = _game(app_id=2, name="Fallback", hours=2.0) + + config = Config(steam_api_key="k", steam_id="i") + state = State() + + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._refresh_candidate_confidence_batch" + ) as mock_refresh_batch, + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + ): + pick_next_game([low, fallback], state, config) + + assert state.current_app_id == 2 + mock_refresh_batch.assert_not_called() + + def test_stops_after_first_confident_assignment(self) -> None: + """Only candidates up to the winning one are checked/skipped.""" + low = _game(app_id=1, name="Low", hours=1.0) + low.comp_100_count = 1 + low.count_comp = 2 + good = _game(app_id=2, name="Good", hours=2.0) + good.comp_100_count = 10 + good.count_comp = 50 + never_checked = _game(app_id=3, name="NeverChecked", hours=3.0) + never_checked.comp_100_count = 0 + never_checked.count_comp = 0 + + config = Config(steam_api_key="k", steam_id="i") + state = State() + echoed: list[str] = [] + + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._refresh_candidate_confidence" + ) as mock_refresh, + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=0, + ), + ): + pick_next_game([low, good, never_checked], state, config) + + assert state.current_app_id == 2 + mock_refresh.assert_called_once_with(low) + assert any("Skipping Low" in line for line in echoed) + assert not any("Skipping NeverChecked" in line for line in echoed) + class TestDoCheck: """Tests for do_check.""" @@ -393,6 +593,100 @@ class TestDoCheck: state = State(current_app_id=440, current_game_name="TF2") do_check(Config(steam_api_key="k", steam_id="i"), state) + +class TestConfidenceHelpers: + """Coverage-focused tests for scanning confidence helper branches.""" + + def test_force_refresh_candidate_confidence_delegates(self) -> None: + game = _game(app_id=10, name="A") + with patch( + "python_pkg.steam_backlog_enforcer.scanning._refresh_candidate_confidence_batch", + ) as mock_batch: + _force_refresh_candidate_confidence(game) + mock_batch.assert_called_once_with([game], force=True) + + def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None: + game = _game(app_id=20, name="B", hours=12.0) + game.comp_100_count = 3 + game.count_comp = 15 + with patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_confidence_cached", + ) as mock_fetch: + _refresh_candidate_confidence_batch([game], force=False) + mock_fetch.assert_not_called() + + def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None: + game = _game(app_id=30, name="C", hours=9.5) + game.comp_100_count = 0 + game.count_comp = 0 + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.load_hltb_cache", + side_effect=[{30: 9.5}, {30: -1.0}], + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.load_hltb_polls_cache", + return_value={30: 0}, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.load_hltb_count_comp_cache", + return_value={30: 0}, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_confidence_cached", + return_value={30: -1.0}, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.save_hltb_cache", + ) as mock_save, + ): + _refresh_candidate_confidence_batch([game], force=True) + + assert game.completionist_hours == 9.5 + saved_cache = mock_save.call_args.args[0] + assert saved_cache[30] == 9.5 + + def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: + low = _game(app_id=40, name="Low", hours=2.0) + low.comp_100_count = 1 + low.count_comp = 2 + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._refresh_candidate_confidence_batch", + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, + ): + result = _filter_hltb_confident_candidates([low]) + assert result == [] + assert mock_echo.called + + def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None: + bad = _game(app_id=50, name="Bad", hours=1.0) + good = _game(app_id=51, name="Good", hours=2.0) + bad.comp_100_count = 3 + bad.count_comp = 15 + good.comp_100_count = 3 + good.count_comp = 15 + + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=[None, good], + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, + ): + picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( + [bad, good], + ) + + assert picked is good + assert skipped_low_conf == 0 + assert skipped_linux == 1 + assert any( + "Skipped 1 game(s) with poor Linux compatibility" in str(call) + for call in mock_echo.call_args_list + ) + def test_complete(self) -> None: game = _game(app_id=440, name="TF2", total=5, unlocked=5) mock_client = MagicMock() diff --git a/run.sh b/run.sh index 18d5a02..744a441 100755 --- a/run.sh +++ b/run.sh @@ -1,11 +1,13 @@ #!/bin/bash -# Easy entrypoint for system usage reports. +# Easy entrypoint for system usage reports and polling script diagnostics. # Usage: -# ./run.sh # today's report to stdout -# ./run.sh --date 20260501 # specific day -# ./run.sh --top 25 # override row count +# ./run.sh # today's report to stdout +# ./run.sh --date 20260501 # specific day +# ./run.sh --top 25 # override row count +# ./run.sh --profile [duration] # profile polling scripts (default 60s) +# ./run.sh --diagnose # find inefficient shell scripts # -# Any args are forwarded to usage_report.py unchanged. +# Any other args are forwarded to usage_report.py unchanged. set -euo pipefail @@ -17,4 +19,119 @@ if [[ ! -f "$REPORT_SCRIPT" ]]; then exit 1 fi +# Profiling mode: trace fork-heavy scripts over time +profile_polling_scripts() { + local duration="${1:-60}" + echo "=== Polling Script Profiler (${duration}s) ===" >&2 + echo "Tracing fork/exec calls in shell scripts..." >&2 + echo "" >&2 + + # Find common polling script processes and trace them + local trace_file="/tmp/polling_trace_$$.txt" + + # Use perf/strace to capture system calls + ( + timeout "$duration" strace -f -e trace=clone,execve -c -p $$ 2>&1 || true + ) > "$trace_file" 2>&1 + + echo "Trace completed. Analyzing results:" >&2 + echo "" >&2 + + # Show fork/exec heavy processes + if ! grep -e "execve" -e "clone" "$trace_file" | head -20; then + : + fi + + rm -f "$trace_file" +} + +# Diagnostic mode: find inefficient patterns in shell scripts +diagnose_polling_scripts() { + echo "=== Shell Script Efficiency Audit ===" >&2 + echo "" >&2 + + local issues_found=0 + + # Check for common anti-patterns + echo "Checking for anti-patterns in shell scripts..." >&2 + echo "" >&2 + + # Pattern 1: while true with sleep (no event-driven check) + echo "1. Polling loops (while true + sleep):" >&2 + set +e + grep -r "while true\|while :" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ + | grep -v "Binary" | grep -v ".git" | head -5 + set -e + issues_found=$((issues_found + 1)) + echo "" >&2 + + # Pattern 2: $(date +...) calls in loops (fork-heavy) + echo "2. Excessive date calls (each forks a process):" >&2 + set +e + grep -r '\$(date' --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ + | grep -v "Binary" | grep -v ".git" | head -5 + set -e + issues_found=$((issues_found + 1)) + echo "" >&2 + + # Pattern 3: pgrep/xdotool in loops + echo "3. Process inspection in loops (pgrep, xdotool):" >&2 + set +e + grep -r "while.*pgrep\|while.*xdotool\|pgrep.*while" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ + | grep -v "Binary" | grep -v ".git" | head -5 + set -e + issues_found=$((issues_found + 1)) + echo "" >&2 + + # Pattern 4: pipes in hot paths + echo "4. Heavy pipes in polling scripts (| awk, | grep, | tr):" >&2 + set +e + while_true_file_list="$(mktemp)" + heavy_pipe_matches="$(mktemp)" + grep -r "while true" --include="*.sh" "$SCRIPT_DIR" > "$while_true_file_list" 2>/dev/null + if [ -s "$while_true_file_list" ]; then + xargs grep -l -e " | awk" -e " | grep" -e " | tr" < "$while_true_file_list" > "$heavy_pipe_matches" 2>/dev/null + head -5 "$heavy_pipe_matches" + fi + rm -f "$while_true_file_list" "$heavy_pipe_matches" + set -e + issues_found=$((issues_found + 1)) + echo "" >&2 + + # Pattern 5: sleep with very short intervals + echo "5. Aggressive polling (sleep < 1s):" >&2 + set +e + grep -rE "sleep 0\.[0-9]|sleep 0[^0-9]" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ + | grep -v "Binary" | grep -v ".git" | head -5 + set -e + issues_found=$((issues_found + 1)) + echo "" >&2 + + echo "=== Recommendations ===" >&2 + echo "1. Replace 'while true + sleep' with event-driven I/O (inotifywait, read -t, etc.)" >&2 + echo "2. Use /proc and /sys instead of forking date, sensors, acpi, etc." >&2 + echo "3. Cache frequently accessed values (e.g., in /tmp state files)" >&2 + echo "4. Use bash builtins: printf %()T instead of date, \${var//} instead of tr, etc." >&2 + echo "5. Use i3blocks interval=persist + event loop instead of polling mode" >&2 + echo "6. Increase polling intervals: 1s → 5s → 10s where acceptable" >&2 +} + +# Handle special modes +case "${1:-}" in + --profile) + profile_polling_scripts "${2:-60}" + exit 0 + ;; + --diagnose) + diagnose_polling_scripts + exit 0 + ;; + --help) + grep '^# Usage:' "$0" | sed 's/^# //' | head -1 + grep '^# ' "$0" | sed 's/^# / /' + exit 0 + ;; +esac + +# Default: run usage_report.py with all remaining args exec python3 "$REPORT_SCRIPT" "$@"