Apply focus-mode, screen-locker, and steam backlog updates

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-03 22:30:48 +02:00
parent 59e863f2a5
commit fa24f22ca0
38 changed files with 3808 additions and 965 deletions

117
batch3_bloatware_uninstall.sh Executable file
View File

@ -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 "========================================="

View File

@ -45,7 +45,7 @@ color=#FFFFFF
[battery] [battery]
command=~/.config/i3blocks/battery_status.sh command=~/.config/i3blocks/battery_status.sh
interval=1 interval=5
markup=pango markup=pango

View File

@ -34,8 +34,9 @@ total_tx_now=0
total_last_rx=0 total_last_rx=0
total_last_tx=0 total_last_tx=0
# Initialize time variables # Initialize time variables without forking: read from /proc/uptime
current_time=$(date +%s) read -r uptime_s _ < /proc/uptime
current_time=${uptime_s%%.*}
last_time=$current_time last_time=$current_time
# Iterate over each interface and accumulate RX and TX bytes # 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_rx=$((total_last_rx + last_rx))
total_last_tx=$((total_last_tx + last_tx)) total_last_tx=$((total_last_tx + last_tx))
# Save current RX and TX bytes for the next check # Save current RX and TX bytes for the next check (using uptime as source)
echo "$rx_now $tx_now $current_time" > "$state_file" printf '%s %s %s\n' "$rx_now" "$tx_now" "$current_time" > "$state_file"
done done
# Calculate time difference # Calculate time difference

View File

@ -19,6 +19,7 @@ LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism"
mkdir -p "$LOG_DIR" 2> /dev/null || true mkdir -p "$LOG_DIR" 2> /dev/null || true
export LOG_FILE="$LOG_DIR/music-parallelism.log" export LOG_FILE="$LOG_DIR/music-parallelism.log"
CHECK_INTERVAL=3 CHECK_INTERVAL=3
FAST_CHECK_INTERVAL=0.5
# Override focus apps with extended list for this script # Override focus apps with extended list for this script
FOCUS_APPS_WINDOWS=( FOCUS_APPS_WINDOWS=(
@ -182,13 +183,13 @@ notify_user() {
log_message "$message" log_message "$message"
} }
# Instant monitoring loop - uses polling at high frequency # Instant monitoring loop - uses polling at high frequency ONLY when focus app is detected
# This runs every 0.5 seconds for near-instant detection # When focus app active: checks every 0.5s. When idle: checks every 3s. Reduces fork overhead.
instant_monitor_loop() { instant_monitor_loop() {
log_message "=== Music Parallelism INSTANT Monitor Started ===" log_message "=== Music Parallelism INSTANT Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}" log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}" 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 while true; do
# Only check if focus app is running # Only check if focus app is running
@ -204,8 +205,11 @@ instant_monitor_loop() {
pkill -9 -x "spotify" 2> /dev/null || true pkill -9 -x "spotify" 2> /dev/null || true
log_message "INSTANT KILL: Spotify terminated" log_message "INSTANT KILL: Spotify terminated"
fi 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 fi
sleep 0.5
done done
} }

View File

@ -44,7 +44,13 @@ check_schedule_protection() {
canonical_morning_end="${MORNING_END_HOUR:-}" canonical_morning_end="${MORNING_END_HOUR:-}"
# If canonical values are empty, skip check # 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 return 0
fi fi
@ -666,7 +672,7 @@ Requires=day-specific-shutdown.service
[Timer] [Timer]
EOF EOF
# Evening hours: from earliest shutdown hour to 23:30 # 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:00:00\n' "$hour"
printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour" printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour"
done done
@ -810,7 +816,15 @@ fi
source "$CONFIG_FILE" source "$CONFIG_FILE"
# Validate config # 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" logger -t day-specific-shutdown "ERROR: Config file missing required variables"
exit 1 exit 1
fi fi
@ -1165,7 +1179,9 @@ test_setup() {
echo "" echo ""
echo "Next scheduled checks:" 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) # Display the shutdown schedule (used in multiple places)

View File

@ -1,131 +1,186 @@
## Phone focus mode # Phone Focus Mode
Rooted-Android hardening + recovery workflow for daily backup/monitoring and Location-based app restriction for a rooted Android phone using wireless ADB.
post-format recovery.
The visible entrypoint is: When within ~500m of home: only whitelisted productive apps remain usable.
When outside that radius: all apps work normally.
```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=<device-serial> ./scripts/run_all/run_phone.sh auto
ADB_SERIAL=<device-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
```
## Requirements ## Requirements
- rooted phone with Magisk installed - Rooted phone with **Magisk** installed
- USB debugging enabled and authorized (or paired wireless ADB) - Wireless ADB enabled (`Settings → Developer options → Wireless debugging`)
- `adb` available on PC (`sudo pacman -S android-tools` on Arch Linux) - `adb` installed on your PC (`sudo apt install adb` on Debian/Ubuntu)
- location services enabled on phone - GPS/Location enabled on the phone
## Setup essentials ## Setup (first time)
1. Set home coordinates in `phone_focus_mode/config_secrets.sh`. ### 1. Find your home coordinates
2. Optionally tune whitelist and behavior in `phone_focus_mode/config.sh`.
3. Perform initial deploy:
```bash Open Google Maps, right-click your apartment → copy the coordinates shown.
bash phone_focus_mode/deploy.sh <phone_ip>
```
## Systemd automation (PC user service) ### 2. Edit `config_secrets.sh`
Install timer-based periodic runs: ```sh
HOME_LAT="-48.876667" # your latitude
```bash HOME_LON="-123.393333" # your longitude
bash phone_focus_mode/systemd/install_pc_phone_automation.sh
``` ```
This installs user units under `~/.config/systemd/user/`: ### 3. (Optional) Adjust the whitelist in `config.sh`
- `phone-auto-sync.service` To find the exact package name of any app:
- `phone-auto-sync.timer` (every 30 minutes, persistent)
## Relevant files ```bash
./deploy.sh <phone_ip> --find-pkg stronglift
./deploy.sh <phone_ip> --find-pkg anki
./deploy.sh <phone_ip> --find-pkg pomodoro
```
| File | Purpose | Then add the correct package name to `WHITELIST` in `config.sh`.
| ------------------------------------- | ------------------------------------------ |
| `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 |
## Notes ### 4. Deploy
- Backup scope and restore policies live in `phone_focus_mode/backup_manifest.sh`. ```bash
- Sensitive coordinates should stay in `config_secrets.sh` and out of version chmod +x deploy.sh
control. ./deploy.sh 192.168.1.42 # replace with your phone's IP
- On-device direct control remains available via `focus_ctl.sh`. ```
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 <ip> --status # Current mode, location, distance from home
./deploy.sh <ip> --log # View recent daemon log
./deploy.sh <ip> --list # List all apps + whitelist status
./deploy.sh <ip> --enable # Force focus mode ON (for testing)
./deploy.sh <ip> --disable # Force focus mode OFF
./deploy.sh <ip> --stop # Stop daemon entirely (restores all apps)
./deploy.sh <ip> --start # Start daemon
./deploy.sh <ip> --restart # Restart daemon (picks up config changes)
./deploy.sh <ip> --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 <ip> --hosts-status
./deploy.sh <ip> --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 <ip> # re-pushes all files
# or just the config:
adb push config.sh /data/local/tmp/focus_mode/config.sh
./deploy.sh <ip> --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 <ip> --find-pkg <keyword>` to find the exact package name
- Package names are case-sensitive

View File

@ -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 # $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. # 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 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 fi
. "$SCRIPT_DIR/config_secrets.sh" . "$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_SHA_FILE="$STATE_DIR/hosts.sha256"
export HOSTS_CHECK_INTERVAL=15 export HOSTS_CHECK_INTERVAL=15
export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" 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 <host>`
# 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) --- # --- DNS enforcer state (see dns_enforcer.sh) ---
# The hosts file is only consulted by the *system* resolver. Apps using # 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::ad1:ff
2a10:50c0::ad2: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) --- # --- Launcher enforcer state (see launcher_enforcer.sh) ---
# Keeps Minimalist Phone installed and locked as the default HOME app. # Keeps Minimalist Phone installed and locked as the default HOME app.
# The APK is snapshotted by `deploy.sh --snapshot-launcher` from the # The APK is snapshotted by `deploy.sh --snapshot-launcher` from the
# currently-installed copy (user installs once via Aurora/Play). # currently-installed copy (user installs once via Aurora/Play).
export LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher" export LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher"
export LAUNCHER_APK="$STATE_DIR/minimalist_launcher.apk" export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk"
export LAUNCHER_SHA_FILE="$STATE_DIR/minimalist_launcher.sha256" export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256"
# Captured home-activity component (package/.Activity). Saved by # Captured home-activity component (package/.Activity). Saved by
# --snapshot-launcher so the enforcer knows which component to pin as HOME. # --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 # Competing launchers to disable so the "pick a launcher" dialog has
# nothing else to offer. Matched exactly; add more with `focus_ctl.sh # nothing else to offer. Matched exactly; add more with `focus_ctl.sh
# launcher-disable-other <pkg>`. # launcher-disable-other <pkg>`.
@ -183,11 +193,6 @@ com.google.android.apps.nexuslauncher
" "
export LAUNCHER_CHECK_INTERVAL=15 export LAUNCHER_CHECK_INTERVAL=15
export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" 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 # WHITELISTED APPS
@ -212,9 +217,34 @@ com.kuhy.focusstatus
com.stronglifts.app com.stronglifts.app
com.ichi2.anki com.ichi2.anki
com.metrolist.music com.metrolist.music
org.mozilla.fenix
org.fossify.clock
ws.xsoh.etar
com.fsck.k9
com.kuhy.pomodoro_app com.kuhy.pomodoro_app
com.kuhy.horatio 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) --- # --- Google system apps (add by name even though they show as system) ---
com.google.android.apps.maps com.google.android.apps.maps
com.google.android.calendar com.google.android.calendar
@ -245,9 +275,11 @@ com.microsoft.office.outlook
com.google.android.gm com.google.android.gm
ch.protonmail.android ch.protonmail.android
com.microsoft.teams 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.aurora.store
com.machiav3lli.fdroid
# --- Manga reader --- # --- Manga reader ---
eu.kanade.tachiyomi.sy eu.kanade.tachiyomi.sy
@ -293,15 +325,10 @@ export BLOCKED_SYSTEM_APPS="
# pm disable-user state persists across reboots. Android always kills daemon # pm disable-user state persists across reboots. Android always kills daemon
# processes with SIGKILL during shutdown, bypassing the shell cleanup trap. # processes with SIGKILL during shutdown, bypassing the shell cleanup trap.
# Any system package left disabled across a reboot can trigger MTK bootloop # 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 # System/distraction apps are enforced via DNS+iptables in dns_enforcer.sh
# DNS+iptables in dns_enforcer.sh instead — that layer is stateless and # instead of persistent package-disable state.
# 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 / essential packages that must NEVER be disabled --- # --- System / essential packages that must NEVER be disabled ---

View File

@ -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/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/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/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" 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). # Generate and upload the canonical hosts file (StevenBlack + custom entries).
# This mirrors what linux_configuration/hosts/install.sh installs on the PC. # This mirrors what linux_configuration/hosts/install.sh installs on the PC.
HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/hosts/generate_hosts_file.sh" 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)..." 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_TMP" "/data/local/tmp/focus_stage/hosts.canonical"
adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256" 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 ("<ip> <domain>" 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" <<PY_EOF || cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP"
import sys
unblock = set("""
$UNBLOCK_DOMAINS
""".split())
with open(sys.argv[1], 'r', encoding='utf-8', errors='replace') as src, \
open(sys.argv[2], 'w', encoding='utf-8') as dst:
for line in src:
s = line.strip()
if not s or s.startswith('#'):
dst.write(line)
continue
parts = s.split()
# Hosts entry layout: <ip> <name> [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_TMP"
rm -f "$HOSTS_SHA_TMP" rm -f "$HOSTS_SHA_TMP"
else 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/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/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/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 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" adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh"
else 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.canonical 2>/dev/null; true"
adb_root "chattr +i $REMOTE_DIR/hosts.sha256 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) ---- # ---- Magisk Systemless Hosts module (REQUIRED) ----
# This module magic-mounts /data/adb/modules/hosts/system/etc/hosts # 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 # 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" adb_root "rm -rf /data/local/tmp/focus_stage"
echo "[5/7] Setting permissions..." 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 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" adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh"
fi 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 # 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..." echo "[6/7] Starting daemons..."
# Stop existing daemons, then start fresh # 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/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/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/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/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/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/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/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 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/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/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/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/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 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. # Start hosts enforcer first so hosts are locked before user can react.
# Use --mount-master so bind mounts propagate to the global namespace # Use --mount-master so bind mounts propagate to the global namespace
# (where app processes live). Without this, only our isolated `su` session # (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 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 >/dev/null 2>/dev/null &' adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/hosts_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
fi 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 >/dev/null 2>/dev/null &'
fi
# Start DNS enforcer (forces Private DNS off, blocks DoH/DoT). Always on. # 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 >/dev/null 2>/dev/null &' adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
# Start launcher enforcer only if a snapshot APK exists. If not, warn the # 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" APK="$APP_DIR/build/focus_status.apk"
if [ -d "$APP_DIR" ]; then if [ -d "$APP_DIR" ]; then
echo "[7/7] Building & installing companion status-notification app..." 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..." echo " Building APK..."
(cd "$APP_DIR" && bash build.sh) >/dev/null (cd "$APP_DIR" && bash build.sh) >/dev/null
fi fi
@ -389,7 +492,10 @@ do_deploy() {
# Grant runtime permission (Android 13+ requires it for notifications). # 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 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. # 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 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 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 fi

View File

@ -25,119 +25,16 @@
# leaves tamper logs. # leaves tamper logs.
# ============================================================ # ============================================================
SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh # shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh" . "$SCRIPT_DIR/config.sh"
PIDFILE="$STATE_DIR/dns_enforcer.pid" 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" mkdir -p "$STATE_DIR"
touch "$DNS_LOG" touch "$DNS_LOG"
chmod 666 "$DNS_LOG" 2>/dev/null || true 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() { log() {
local ts local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')" 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 \ iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true --reject-with tcp-reset 2>/dev/null || true
done 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() { fill_chain_v6() {
@ -291,36 +158,9 @@ fill_chain_v6() {
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true --reject-with tcp-reset 2>/dev/null || true
done 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() { enforce_iptables() {
refresh_blocked_content_ips
refresh_blocked_app_uids
if command -v iptables >/dev/null 2>&1; then if command -v iptables >/dev/null 2>&1; then
ensure_chain iptables && fill_chain_v4 ensure_chain iptables && fill_chain_v4
fi fi
@ -356,6 +196,4 @@ main() {
done done
} }
if [ "${FOCUS_MODE_DNS_ENFORCER_TESTING:-0}" != "1" ]; then main "$@"
main "$@"
fi

View File

@ -14,30 +14,6 @@ SCRIPT_DIR="/data/local/tmp/focus_mode"
PIDFILE="$STATE_DIR/daemon.pid" 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 ---- # ---- Logging ----
log() { log() {
local ts local ts
@ -45,6 +21,33 @@ log() {
echo "[$ts] $1" >> "$LOG_FILE" 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() { usage() {
echo "Usage: focus_ctl.sh <command>" echo "Usage: focus_ctl.sh <command>"
echo "" echo ""
@ -71,6 +74,10 @@ usage() {
echo " launcher-stop - Stop the launcher enforcer daemon" echo " launcher-stop - Stop the launcher enforcer daemon"
echo " launcher-log - Show launcher enforcer log" echo " launcher-log - Show launcher enforcer log"
echo " launcher-snapshot - Back up currently-installed launcher APK" 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 " recheck - Nudge the daemon to perform a fresh location check now"
echo " notif-status - Show companion status-notification details" echo " notif-status - Show companion status-notification details"
echo "" echo ""
@ -78,7 +85,13 @@ usage() {
# Helper to check if daemon is running # Helper to check if daemon is running
daemon_pid() { 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() { cmd_start() {
@ -170,7 +183,7 @@ cmd_enable() {
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
# Check whitelist # Check whitelist
whitelisted=0 whitelisted=0
for w in $WHITELIST; do for w in $(iter_whitelist_packages); do
w_clean="$(echo "$w" | tr -d '[:space:]')" w_clean="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w_clean" ] && continue [ -z "$w_clean" ] && continue
[ "$pkg" = "$w_clean" ] && { whitelisted=1; break; } [ "$pkg" = "$w_clean" ] && { whitelisted=1; break; }
@ -253,7 +266,7 @@ cmd_list_apps() {
echo "=== Third-party apps NOT in whitelist ===" echo "=== Third-party apps NOT in whitelist ==="
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
whitelisted=0 whitelisted=0
for w in $WHITELIST; do for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')" w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue [ -z "$w" ] && continue
[ "$pkg" = "$w" ] && { whitelisted=1; break; } [ "$pkg" = "$w" ] && { whitelisted=1; break; }
@ -269,7 +282,7 @@ cmd_list_apps() {
done done
echo "" echo ""
echo "=== Whitelisted apps ===" echo "=== Whitelisted apps ==="
for w in $WHITELIST; do for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')" w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue [ -z "$w" ] && continue
echo " [allowed] $w" echo " [allowed] $w"
@ -278,7 +291,7 @@ cmd_list_apps() {
cmd_whitelist() { cmd_whitelist() {
echo "=== Whitelisted packages ===" echo "=== Whitelisted packages ==="
for w in $WHITELIST; do for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')" w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue [ -z "$w" ] && continue
# Check if installed # Check if installed
@ -293,7 +306,13 @@ cmd_whitelist() {
HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid" HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid"
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() { cmd_hosts_status() {
@ -380,7 +399,13 @@ cmd_hosts_log() {
DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid" DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid"
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() { cmd_dns_status() {
@ -467,7 +492,13 @@ LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid"
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt" DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
launcher_enforcer_pid() { 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() { cmd_launcher_snapshot() {
@ -601,6 +632,120 @@ cmd_launcher_log() {
fi 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: <missing or not executable — detector cannot query DB>"
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="<unset>"
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:-<query failed>}"
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: <missing — deploy.sh must regenerate it>"
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 >/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 case "$1" in
start) cmd_start ;; start) cmd_start ;;
stop) cmd_stop ;; stop) cmd_stop ;;
@ -624,6 +769,10 @@ case "$1" in
launcher-stop) cmd_launcher_stop ;; launcher-stop) cmd_launcher_stop ;;
launcher-log) cmd_launcher_log "${2:-50}" ;; launcher-log) cmd_launcher_log "${2:-50}" ;;
launcher-snapshot) cmd_launcher_snapshot ;; 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 ;; recheck) cmd_recheck ;;
notif-status) cmd_notif_status ;; notif-status) cmd_notif_status ;;
*) usage ;; *) usage ;;

View File

@ -54,6 +54,17 @@ rotate_log() {
build_whitelist_file() { build_whitelist_file() {
echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/whitelist.txt" | 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() { build_sysprotect_file() {
@ -112,6 +123,7 @@ init() {
build_whitelist_file build_whitelist_file
build_sysprotect_file build_sysprotect_file
refresh_default_handlers
rotate_log rotate_log
if [ -f "$MODE_FILE" ]; then if [ -f "$MODE_FILE" ]; then
@ -168,9 +180,51 @@ is_allowed() {
"$prefix"*) return 0 ;; "$prefix"*) return 0 ;;
esac esac
done < "$STATE_DIR/sysprotect.txt" 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 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 "<pkg>/<.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 ---- # ---- Focus Mode Control ----
enable_focus_mode() { enable_focus_mode() {
@ -181,6 +235,11 @@ enable_focus_mode() {
: > "$DISABLED_APPS_FILE" : > "$DISABLED_APPS_FILE"
fi 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) # Build blocked system app list (used both at entry and for periodic sweep)
local blocked_sys="$STATE_DIR/blocked_sys.txt" local blocked_sys="$STATE_DIR/blocked_sys.txt"
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \

View File

@ -19,7 +19,7 @@ import android.os.Looper;
*/ */
public final class StatusService extends Service { 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 int NOTIF_ID = 1042;
private static final long REFRESH_MS = 5_000L; private static final long REFRESH_MS = 5_000L;
@ -87,8 +87,8 @@ public final class StatusService extends Service {
return; return;
} }
NotificationChannel ch = new NotificationChannel( NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Focus Mode Status", CHANNEL_ID, "Focus Mode Status",
NotificationManager.IMPORTANCE_DEFAULT); NotificationManager.IMPORTANCE_LOW);
ch.setDescription("Persistent status of the focus-mode daemon"); ch.setDescription("Persistent status of the focus-mode daemon");
ch.setShowBadge(false); ch.setShowBadge(false);
ch.setSound(null, null); ch.setSound(null, null);

View File

@ -28,14 +28,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/config.sh" . "$SCRIPT_DIR/config.sh"
PIDFILE="$STATE_DIR/hosts_enforcer.pid" 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")" mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")"
touch "$HOSTS_LOG" touch "$HOSTS_LOG"
@ -83,24 +75,48 @@ sha256_of() {
fi 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() { is_bind_mounted_correctly() {
# Android devices often already have /system/etc/hosts as its own mount # 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" # 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 # 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 if [ ! -f "$HOSTS_TARGET" ]; then
return 1 return 1
fi fi
local target_hash canonical_hash local target_hash canonical_hash canonical
canonical="$(current_canonical)"
target_hash="$(sha256_of "$HOSTS_TARGET")" target_hash="$(sha256_of "$HOSTS_TARGET")"
canonical_hash="$(sha256_of "$HOSTS_CANONICAL")" canonical_hash="$(sha256_of "$canonical")"
[ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ] [ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ]
} }
has_hosts_target() {
[ -f "$HOSTS_TARGET" ]
}
unmount_existing_hosts_mount() { unmount_existing_hosts_mount() {
# If anything else is already mounted on /system/etc/hosts (OEM overlay # 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. # 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() { make_target_writable_once() {
# /system is usually mounted read-only. Make it rw just long enough # /system is usually mounted read-only. Make it rw just long enough
# to overwrite HOSTS_TARGET with the canonical content, then remount ro. # 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)" system_mount="$(awk '$2=="/system"{print $2; exit}' /proc/self/mounts)"
if [ -z "$system_mount" ]; then if [ -z "$system_mount" ]; then
system_mount="/system" system_mount="/system"
fi fi
mount -o remount,rw "$system_mount" 2>/dev/null || true mount -o remount,rw "$system_mount" 2>/dev/null || true
chattr -i "$HOSTS_TARGET" 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 chmod 644 "$HOSTS_TARGET" 2>/dev/null || true
chattr +i "$HOSTS_TARGET" 2>/dev/null || true chattr +i "$HOSTS_TARGET" 2>/dev/null || true
mount -o remount,ro "$system_mount" 2>/dev/null || true mount -o remount,ro "$system_mount" 2>/dev/null || true
} }
assert_bind_mount() { 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 if is_bind_mounted_correctly; then
return 0 return 0
fi fi
# Something is in the way (OEM overlay or previous partial mount). # Something is in the way (OEM overlay or previous partial mount).
unmount_existing_hosts_mount unmount_existing_hosts_mount
local canonical
canonical="$(current_canonical)"
# Try plain bind mount - no remount-rw of /system needed. # Try plain bind mount - no remount-rw of /system needed.
# Android toybox mount commonly supports "-o bind" but not "--bind". if mount --bind "$canonical" "$HOSTS_TARGET" 2>/dev/null; then
if mount -o bind "$HOSTS_CANONICAL" "$HOSTS_TARGET" 2>/dev/null; then
mount -o remount,ro,bind "$HOSTS_TARGET" 2>/dev/null || true mount -o remount,ro,bind "$HOSTS_TARGET" 2>/dev/null || true
if is_bind_mounted_correctly; then 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 return 0
fi fi
log "Bind mount reported success but target still mismatches - unmounting" 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" log "Bind mount failed - falling back to direct overwrite"
make_target_writable_once make_target_writable_once
if is_bind_mounted_correctly; then if is_bind_mounted_correctly; then
sync_magisk_module "$canonical"
return 0 return 0
fi fi
return 1 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() { 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 chmod 644 "$HOSTS_CANONICAL" 2>/dev/null || true
chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true
} if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
chmod 644 "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true
# Populate the Magisk "Systemless Hosts" module. Magisk's magic mount picks chattr +i "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true
# up files under /data/adb/modules/<id>/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
fi 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() { verify_and_restore() {
if [ ! -f "$HOSTS_CANONICAL" ]; then local canonical sha_file
log "ERROR: canonical hosts missing at $HOSTS_CANONICAL" canonical="$(current_canonical)"
sha_file="$(current_sha_file)"
if [ ! -f "$canonical" ]; then
log "ERROR: canonical hosts missing at $canonical"
return 1 return 1
fi fi
local expected local expected
expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)" expected="$(cat "$sha_file" 2>/dev/null)"
if [ -z "$expected" ]; then if [ -z "$expected" ]; then
expected="$(sha256_of "$HOSTS_CANONICAL")" expected="$(sha256_of "$canonical")"
echo "$expected" > "$HOSTS_SHA_FILE" echo "$expected" > "$sha_file"
chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true chmod 644 "$sha_file" 2>/dev/null || true
chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true chattr +i "$sha_file" 2>/dev/null || true
fi fi
# Canonical integrity check # Canonical integrity check
local actual_canonical local actual_canonical
actual_canonical="$(sha256_of "$HOSTS_CANONICAL")" actual_canonical="$(sha256_of "$canonical")"
if [ "$actual_canonical" != "$expected" ]; then 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. # We cannot fix the canonical from here - it is the source of truth.
# Just log and continue; deploy.sh must re-push. # Just log and continue; deploy.sh must re-push.
return 1 return 1
fi fi
if ! has_hosts_target; then # Live target integrity check. Mismatch can mean either tampering OR a
if [ "$MISSING_TARGET_LOGGED" -eq 0 ]; then # legitimate workout-state transition that swapped the active canonical.
log "WARN: hosts target missing on this ROM: $HOSTS_TARGET (integrity checks skipped)" # In both cases the fix is the same: re-assert the bind mount with the
MISSING_TARGET_LOGGED=1 # currently-active canonical.
fi
return 0
fi
MISSING_TARGET_LOGGED=0
# Live target integrity check
local actual_target local actual_target
actual_target="$(sha256_of "$HOSTS_TARGET")" actual_target="$(sha256_of "$HOSTS_TARGET")"
if [ "$actual_target" != "$expected" ]; then 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 assert_bind_mount
fi fi
} }
@ -278,21 +267,22 @@ main() {
log "hosts_enforcer started (PID=$$)" log "hosts_enforcer started (PID=$$)"
ensure_canonical_immutable ensure_canonical_immutable
# Seed the Magisk systemless hosts module so /system/etc/hosts gets # Initial assertion
# magic-mounted on next boot.
populate_magisk_hosts_module || true
# Initial assertion (covers the case where target already exists).
assert_bind_mount || true assert_bind_mount || true
# Seed sha file if missing # Seed sha files if missing — one per canonical variant.
if [ ! -f "$HOSTS_SHA_FILE" ]; then if [ ! -f "$HOSTS_SHA_FILE" ] && [ -f "$HOSTS_CANONICAL" ]; then
sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE" sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE"
chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true
chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true
fi 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 while true; do
populate_magisk_hosts_module || true
verify_and_restore verify_and_restore
rotate_log rotate_log
sleep "$HOSTS_CHECK_INTERVAL" sleep "$HOSTS_CHECK_INTERVAL"

View File

@ -6,177 +6,42 @@
# Magisk executes everything in service.d on boot with root. # 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() { # Ensure scripts are executable
if [ -f "$SCRIPT_DIR/config.sh" ]; then chmod +x "$SCRIPT_DIR/focus_daemon.sh"
export FOCUS_MODE_SCRIPT_DIR="$SCRIPT_DIR" chmod +x "$SCRIPT_DIR/focus_ctl.sh"
# shellcheck source=/dev/null chmod +x "$SCRIPT_DIR/hosts_enforcer.sh"
. "$SCRIPT_DIR/config.sh" chmod +x "$SCRIPT_DIR/dns_enforcer.sh"
return 0 chmod +x "$SCRIPT_DIR/launcher_enforcer.sh"
fi 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 >/dev/null 2>&1 &
boot_config_ready() { # Start workout detector early so the hosts enforcer's first integrity
[ -f "$SCRIPT_DIR/config.sh" ] # 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
launcher_boot_autostart_enabled() { setsid sh "$SCRIPT_DIR/workout_detector.sh" </dev/null >/dev/null 2>&1 &
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 >/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 >/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 >/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 >/dev/null 2>&1 &
exit 0
}
if [ "${FOCUS_MODE_MAGISK_SERVICE_TESTING:-0}" != "1" ]; then
main "$@"
fi 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 >/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 >/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 >/dev/null 2>&1 &
exit 0

View File

@ -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:<path>?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 "$@"

View File

@ -255,20 +255,19 @@ class PhoneVerificationMixin:
return 0 return 0
def _is_workout_finish_recent(self, db_path: Path) -> bool: 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 A fresh workout should have finished within the last 24 hours.
the future. This prevents using an old pre-prepared database dump This prevents using an old pre-prepared database dump while
while still allowing workouts done earlier in the day (e.g. a still accepting workouts done earlier the same day.
morning workout being verified in the evening).
Args: Args:
db_path: Path to the locally-pulled StrongLifts database. db_path: Path to the locally-pulled StrongLifts database.
Returns: Returns:
True if the latest finish time is today (local) and not in the True if the latest finish time is within 24 hours of now.
future.
""" """
max_age_seconds = 24 * 3600 # accept same-day workouts
try: try:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
try: try:
@ -276,16 +275,13 @@ class PhoneVerificationMixin:
"SELECT MAX(finish) FROM workouts " "SELECT MAX(finish) FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') " "WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime') " "= date('now', 'localtime') "
"AND finish > start " "AND finish > start",
"AND date(finish / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime')",
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row or row[0] is None: if not row or row[0] is None:
return False return False
finish_epoch = int(row[0]) / 1000.0 finish_epoch = int(row[0]) / 1000.0
# Reject future timestamps (clock-skew / tampering guard). return (time.time() - finish_epoch) < max_age_seconds
return finish_epoch <= time.time()
finally: finally:
conn.close() conn.close()
except (sqlite3.Error, ValueError, TypeError): except (sqlite3.Error, ValueError, TypeError):

View File

@ -34,7 +34,7 @@ MORNING_END_HOUR="$3"
# Validate hours are integers between 0-23 # Validate hours are integers between 0-23
for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do 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 echo "Error: Hours must be integers between 0 and 23" >&2
exit 1 exit 1
fi fi

View File

@ -28,6 +28,7 @@ from python_pkg.screen_locker._constants import (
STRONGLIFTS_DB_REMOTE, STRONGLIFTS_DB_REMOTE,
) )
from python_pkg.screen_locker._log_integrity import ( from python_pkg.screen_locker._log_integrity import (
_load_hmac_key,
compute_entry_hmac, compute_entry_hmac,
verify_entry_hmac, verify_entry_hmac,
) )
@ -153,8 +154,8 @@ class ScreenLocker(
"No sick day logged today. Nothing to verify.", "No sick day logged today. Nothing to verify.",
) )
sys.exit(0) sys.exit(0)
else: return
self._check_non_verify_exits() self._check_non_verify_exits()
def _check_non_verify_exits(self) -> None: def _check_non_verify_exits(self) -> None:
"""Check all normal (non-verify) startup early-exit conditions.""" """Check all normal (non-verify) startup early-exit conditions."""
@ -193,11 +194,7 @@ class ScreenLocker(
return now.hour * 60 + now.minute return now.hour * 60 + now.minute
def _is_early_bird_time(self) -> bool: def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window. """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).
"""
minutes = self._get_local_time_minutes() minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60 start = EARLY_BIRD_START_HOUR * 60
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
@ -224,16 +221,7 @@ class ScreenLocker(
self.save_workout_log() self.save_workout_log()
def _try_auto_upgrade_early_bird(self) -> bool: def _try_auto_upgrade_early_bird(self) -> bool:
"""Silently upgrade today's early_bird entry if phone shows a workout. """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.
"""
try: try:
status, message = self._verify_phone_workout() status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
@ -254,18 +242,7 @@ class ScreenLocker(
return True return True
def _try_auto_upgrade_sick_day(self) -> bool: def _try_auto_upgrade_sick_day(self) -> bool:
"""Silently upgrade today's sick_day entry if phone shows a workout. """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``).
"""
try: try:
status, message = self._verify_phone_workout() status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
@ -417,12 +394,7 @@ class ScreenLocker(
self.root.after(1500, self.close) self.root.after(1500, self.close)
def has_logged_today(self) -> bool: def has_logged_today(self) -> bool:
"""Check if workout has been logged today. """Check if workout has been logged today with valid HMAC."""
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.
"""
if not self.log_file.exists(): if not self.log_file.exists():
return False return False
@ -436,15 +408,17 @@ class ScreenLocker(
entry = logs.get(today) entry = logs.get(today)
if entry is None: if entry is None:
return False return False
if "hmac" not in entry: if verify_entry_hmac(entry):
_logger.warning( return entry.get("workout_data", {}).get("type") != "early_bird"
"Today's log entry is unsigned; accepting legacy fallback" 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" return entry.get("workout_data", {}).get("type") != "early_bird"
if not verify_entry_hmac(entry): _logger.warning(
_logger.warning("HMAC verification failed for today's log entry") "HMAC verification failed for today's log entry",
return False )
return entry.get("workout_data", {}).get("type") != "early_bird" return False
def _load_existing_logs(self) -> dict: def _load_existing_logs(self) -> dict:
"""Load existing workout logs from file.""" """Load existing workout logs from file."""

View File

@ -795,62 +795,7 @@ class TestIsWorkoutFinishRecent:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Test returns False for workout that finished on a previous day.""" """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))
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)."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "sl_test.db" db_file = tmp_path / "sl_test.db"
conn = sqlite3.connect(str(db_file)) conn = sqlite3.connect(str(db_file))
@ -858,11 +803,12 @@ class TestIsWorkoutFinishRecent:
"CREATE TABLE workouts " "CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
) )
# Finished 25 hours ago (not "today" in local time either)
now_ms = int(time.time() * 1000) 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( conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)", "INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, future_ms), ("w1", old_finish - 3600000, old_finish),
) )
conn.commit() conn.commit()
conn.close() conn.close()

View File

@ -154,29 +154,59 @@ class TestHasLoggedToday:
): ):
assert locker.has_logged_today() is False 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, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Unsigned legacy entries still count as logged workouts.""" """Accept unsigned entry when HMAC key is unavailable."""
log_file = tmp_path / "workout_log.json" log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text( log_file.write_text(
json.dumps( json.dumps({today: {"workout": "data"}}),
{
today: {
"timestamp": "2026-05-01T14:46:32.206951+00:00",
"workout_data": {"type": "phone_verified"},
}
}
),
) )
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file 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( def test_other_day_logged(
self, self,
@ -330,7 +360,7 @@ class TestRun:
class TestAutoUpgradeSickDay: 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( def test_upgrade_succeeds_when_phone_verified(
self, self,
@ -404,7 +434,7 @@ class TestAutoUpgradeSickDay:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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) mock_sys_exit.side_effect = SystemExit(0)
with ( with (
patch.object( patch.object(
@ -418,30 +448,6 @@ class TestAutoUpgradeSickDay:
mock_upgrade.assert_called_once() mock_upgrade.assert_called_once()
mock_sys_exit.assert_called_once_with(0) 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: class TestMainEntry:
"""Tests for main entry point.""" """Tests for main entry point."""

View File

@ -15,12 +15,18 @@ from python_pkg.steam_backlog_enforcer.game_install import (
uninstall_other_games, uninstall_other_games,
) )
from python_pkg.steam_backlog_enforcer.hltb import ( from python_pkg.steam_backlog_enforcer.hltb import (
fetch_hltb_confidence_cached,
fetch_hltb_times_cached, fetch_hltb_times_cached,
load_hltb_cache, 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.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.scanning import ( from python_pkg.steam_backlog_enforcer.scanning import (
_pick_playable_candidate, _confidence_fail_reasons,
_pick_next_shortest_candidate,
_refresh_candidate_confidence,
pick_next_game, pick_next_game,
) )
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient 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 _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( def _apply_cached_hours_to_games(
games: list[GameInfo], games: list[GameInfo],
hltb_cache: dict[int, float], hltb_cache: dict[int, float],
@ -38,6 +119,17 @@ def _apply_cached_hours_to_games(
game.completionist_hours = hltb_cache[game.app_id] 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( def _refresh_uncached_shortlist_hours(
games: list[GameInfo], games: list[GameInfo],
hltb_cache: dict[int, float], hltb_cache: dict[int, float],
@ -69,6 +161,46 @@ def _refresh_uncached_shortlist_hours(
hltb_cache.update(refreshed) 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( def _try_reassign_shorter_game(
hltb_cache: dict[int, float], hltb_cache: dict[int, float],
app_id: int, app_id: int,
@ -89,23 +221,44 @@ def _try_reassign_shorter_game(
upper_bound_hours=hours, upper_bound_hours=hours,
) )
_apply_cached_hours_to_games(all_games, hltb_cache) _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 = [ candidates = [
g g
for g in all_games for g in all_games
if not g.is_complete and g.app_id not in skip and g.completionist_hours > 0 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) 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 return False
# Filter out Linux-incompatible games before deciding to reassign.
playable = _pick_playable_candidate( playable, _confidence_skipped, _linux_skipped = _pick_next_shortest_candidate(
[c for c in candidates if c.app_id != app_id], candidates,
) )
if playable is None or playable.completionist_hours >= hours: if playable is None:
return False return False
_echo(
f"\n Reassigning: {playable.name} is shorter" if not _should_reassign_candidate(
f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)" 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) pick_next_game(all_games, state, config)
@ -193,6 +346,15 @@ def _enforce_on_done(config: Config, state: State) -> None:
use_steam_protocol=True, 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: def cmd_done(config: Config, state: State) -> None:
"""Check completion, pick next game, uninstall & hide. """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) hours = hltb_cache.get(app_id, -1.0)
if hours > 0: if hours > 0:
_echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours") _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): if _try_reassign_shorter_game(hltb_cache, app_id, hours, state, config):
return return

View File

@ -37,19 +37,34 @@ logger = logging.getLogger(__name__)
def get_all_owned_app_ids(config: Config) -> list[int]: def get_all_owned_app_ids(config: Config) -> list[int]:
"""Get all owned game app IDs from the snapshot or Steam API.""" """Get all owned game app IDs from Steam API plus snapshot fallback.
snapshot = load_snapshot()
if snapshot: Snapshot data contains only games with achievements, so API data is the
return [d["app_id"] for d in snapshot] 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: try:
client = SteamAPIClient(config.steam_api_key, config.steam_id) client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games() 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): except (OSError, RuntimeError, ValueError):
if snapshot_ids:
return snapshot_ids
logger.warning("Could not fetch owned game list for hiding.") logger.warning("Could not fetch owned game list for hiding.")
return [] return []
else:
return merged_ids
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────

View File

@ -149,12 +149,20 @@ async def _fetch_detail_one(
async def _fetch_leisure_times( async def _fetch_leisure_times(
search_results: list[HLTBResult], search_results: list[HLTBResult],
cache: dict[int, float], cache: dict[int, float],
polls: dict[int, int],
progress_cb: ProgressCb | None, progress_cb: ProgressCb | None,
count_comp: dict[int, int] | None = None,
) -> None: ) -> None:
"""Fetch leisure times from game detail pages for all search results. """Fetch leisure times from game detail pages for all search results.
Updates ``cache`` in-place with leisure hours (including DLC time). 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] valid = [r for r in search_results if r.hltb_game_id > 0]
if not valid: if not valid:
return return
@ -198,7 +206,7 @@ async def _fetch_leisure_times(
progress_cb(done, total, found, r.game_name) progress_cb(done, total, found, r.game_name)
if not done % _SAVE_INTERVAL: if not done % _SAVE_INTERVAL:
save_hltb_cache(cache) save_hltb_cache(cache, polls, count_comp)
def _collect_dlc_relationships( def _collect_dlc_relationships(

View File

@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import logging import logging
from typing import Any
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
@ -42,6 +43,8 @@ class HLTBResult:
completionist_hours: float completionist_hours: float
similarity: float similarity: float
hltb_game_id: int = 0 hltb_game_id: int = 0
comp_100_count: int = 0
count_comp: int = 0
@dataclass @dataclass
@ -53,26 +56,91 @@ class _AuthInfo:
hp_val: str = "" 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):
{
"<app_id>": {
"hours": <float>,
"polls": <int>,
"count_comp": <int>
}
}
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]: 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). Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB).
""" """
if HLTB_CACHE_FILE.exists(): return {aid: v["hours"] for aid, v in _read_raw_cache().items()}
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 {}
def save_hltb_cache(cache: dict[int, float]) -> None: def load_hltb_polls_cache() -> dict[int, int]:
"""Save the HLTB cache to disk.""" """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: try:
_atomic_write( _atomic_write(
HLTB_CACHE_FILE, 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: except OSError:
logger.exception("Failed to save HLTB cache") logger.exception("Failed to save HLTB cache")

View File

@ -21,12 +21,15 @@ _REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser()
def _assert_not_real_steam(path: Path) -> None: 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 Defence-in-depth guard: when running under pytest, even if test
redirect ``STEAMAPPS_PATH``, destructive operations fixtures fail to redirect ``STEAMAPPS_PATH``, destructive
(uninstall, rmtree, unlink) will refuse to touch real files. 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: try:
path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) path.resolve().relative_to(_REAL_STEAMAPPS.resolve())
except ValueError: except ValueError:

View File

@ -18,6 +18,7 @@ from difflib import SequenceMatcher
from http import HTTPStatus from http import HTTPStatus
import json import json
import logging import logging
import re
import time import time
from typing import Any from typing import Any
@ -37,6 +38,8 @@ from python_pkg.steam_backlog_enforcer._hltb_types import (
ProgressCb, ProgressCb,
_AuthInfo, _AuthInfo,
load_hltb_cache, load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_polls_cache,
save_hltb_cache, save_hltb_cache,
) )
@ -145,6 +148,70 @@ def _build_search_payload(game_name: str, auth: _AuthInfo | None = None) -> str:
return json.dumps(payload) 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( def _pick_best_hltb_entry(
search_name: str, search_name: str,
candidates: list[tuple[dict[str, Any], float]], candidates: list[tuple[dict[str, Any], float]],
@ -204,6 +271,9 @@ def _find_best_extended(
""" """
best: tuple[dict[str, Any], float] | None = None best: tuple[dict[str, Any], float] | None = None
for entry, sim in usable: 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() entry_name = (entry.get("game_name") or "").lower()
if entry_name.startswith((lower + ":", lower + " -")): if entry_name.startswith((lower + ":", lower + " -")):
suffix = entry_name[len(lower) :].lstrip(" :-") 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: if best_exact is not None and best_extended is not None:
exact_hours = best_exact[0].get("comp_100", 0) exact_hours = best_exact[0].get("comp_100", 0)
extended_hours = best_extended[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 # Prefer the extended entry only when it has strictly more hours
# than the exact match. This lets "FAITH: The Unholy Trinity" # than the exact match AND at least as much confidence.
# (7 h) beat "FAITH" (0.5 h demo) while preventing # This lets "FAITH: The Unholy Trinity" (full game) beat
# "Timberman: The Big Adventure" (2 h) from beating # a low-confidence exact demo while preventing low-confidence
# "Timberman" (26 h). # mods like "Celeste - Strawberry Jam" from beating
if extended_hours > exact_hours: # the exact base game.
if extended_hours > exact_hours and extended_confidence >= exact_confidence:
return best_extended return best_extended
return best_exact return best_exact
if best_exact is not None: if best_exact is not None:
@ -253,6 +330,8 @@ class _SearchCtx:
search_url: str search_url: str
headers: dict[str, str] headers: dict[str, str]
cache: dict[int, float] 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 auth: _AuthInfo | None = None
counter: dict[str, int] = field(default_factory=dict) counter: dict[str, int] = field(default_factory=dict)
total: int = 0 total: int = 0
@ -268,71 +347,43 @@ async def _search_one(
"""Search HLTB for one game via direct POST, update cache.""" """Search HLTB for one game via direct POST, update cache."""
async with sem: async with sem:
result: HLTBResult | None = None result: HLTBResult | None = None
payload = _build_search_payload(name, ctx.auth) for query_name in _build_search_variants(name):
try: payload = _build_search_payload(query_name, ctx.auth)
async with ctx.session.post( try:
ctx.search_url, async with ctx.session.post(
headers=ctx.headers, ctx.search_url,
data=payload, headers=ctx.headers,
) as resp: data=payload,
if resp.status == HTTPStatus.OK: ) as resp:
if resp.status != HTTPStatus.OK:
continue
data = await resp.json() data = await resp.json()
candidates: list[tuple[dict[str, Any], float]] = [] candidates = _collect_candidates(query_name, data)
lower_name = name.lower() best = _pick_best_hltb_entry(query_name, candidates)
for entry in data.get("data", []): if best is None:
entry_name = entry.get("game_name", "") continue
entry_alias = entry.get("game_alias", "") or "" result = _build_result_from_best(app_id, name, query_name, best)
is_dlc = str(entry.get("game_type", "")).lower() == "dlc" break
sim = max( except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
_similarity(name, entry_name), logger.debug("HLTB search failed for '%s': %s", query_name, exc)
_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)
# Update cache immediately (miss = -1). # Update cache immediately (miss = -1).
if result is not None: if result is not None:
ctx.cache[app_id] = result.completionist_hours 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 ctx.counter["found"] += 1
else: else:
ctx.cache[app_id] = -1 ctx.cache[app_id] = -1
ctx.polls[app_id] = 0
ctx.count_comp[app_id] = 0
ctx.counter["done"] += 1 ctx.counter["done"] += 1
done = ctx.counter["done"] done = ctx.counter["done"]
# Incremental save every _SAVE_INTERVAL lookups. # Incremental save every _SAVE_INTERVAL lookups.
if not done % _SAVE_INTERVAL: if not done % _SAVE_INTERVAL:
save_hltb_cache(ctx.cache) save_hltb_cache(ctx.cache, ctx.polls, ctx.count_comp)
# Report progress. # Report progress.
if ctx.progress_cb is not None: if ctx.progress_cb is not None:
@ -344,7 +395,9 @@ async def _search_one(
async def _fetch_batch( async def _fetch_batch(
games: list[tuple[int, str]], games: list[tuple[int, str]],
cache: dict[int, float], cache: dict[int, float],
polls: dict[int, int],
progress_cb: ProgressCb | None, progress_cb: ProgressCb | None,
count_comp: dict[int, int] | None = None,
) -> list[HLTBResult]: ) -> list[HLTBResult]:
"""Fetch HLTB data for a batch of games using one shared session.""" """Fetch HLTB data for a batch of games using one shared session."""
# 1. Discover the search URL (sync, one-time). # 1. Discover the search URL (sync, one-time).
@ -380,6 +433,9 @@ async def _fetch_batch(
counter = {"done": 0, "found": 0} counter = {"done": 0, "found": 0}
total = len(games) total = len(games)
if count_comp is None:
count_comp = {}
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
limit=MAX_CONCURRENT, limit=MAX_CONCURRENT,
keepalive_timeout=30, keepalive_timeout=30,
@ -393,6 +449,8 @@ async def _fetch_batch(
search_url=search_url, search_url=search_url,
headers=headers, headers=headers,
cache=cache, cache=cache,
polls=polls,
count_comp=count_comp,
auth=auth, auth=auth,
counter=counter, counter=counter,
total=total, total=total,
@ -416,22 +474,141 @@ async def _fetch_batch(
"Fetching leisure times for %d games from detail pages...", "Fetching leisure times for %d games from detail pages...",
len(search_results), 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 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( def fetch_hltb_times(
games: list[tuple[int, str]], games: list[tuple[int, str]],
cache: dict[int, float] | None = None, cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: ProgressCb | None = None, progress_cb: ProgressCb | None = None,
count_comp: dict[int, int] | None = None,
) -> list[HLTBResult]: ) -> list[HLTBResult]:
"""Synchronous wrapper: fetch HLTB times for games.""" """Synchronous wrapper: fetch HLTB times for games."""
if not games: if not games:
return [] return []
if cache is None: if cache is None:
cache = {} 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( def fetch_hltb_times_cached(
@ -447,6 +624,8 @@ def fetch_hltb_times_cached(
Returns: dict mapping app_id -> completionist_hours. Returns: dict mapping app_id -> completionist_hours.
""" """
cache = load_hltb_cache() 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] uncached = [(app_id, name) for app_id, name in games if app_id not in cache]
if uncached: if uncached:
@ -456,11 +635,17 @@ def fetch_hltb_times_cached(
len(games) - len(uncached), len(games) - len(uncached),
) )
t0 = time.monotonic() 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 elapsed = time.monotonic() - t0
# Final save. # 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) found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0)
rate = len(uncached) / elapsed if elapsed > 0 else 0 rate = len(uncached) / elapsed if elapsed > 0 else 0
@ -477,6 +662,49 @@ def fetch_hltb_times_cached(
return cache 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: def get_hltb_submit_url(game_name: str) -> str | None:
"""Look up a game on HLTB and return its submit page URL. """Look up a game on HLTB and return its submit page URL.

View File

@ -6,6 +6,12 @@ import logging
import time import time
from typing import Any 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 ( from python_pkg.steam_backlog_enforcer.config import (
Config, Config,
State, State,
@ -21,7 +27,10 @@ from python_pkg.steam_backlog_enforcer.game_install import (
is_game_installed, is_game_installed,
uninstall_other_games, 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 ( from python_pkg.steam_backlog_enforcer.protondb import (
ProtonDBRating, ProtonDBRating,
fetch_protondb_ratings, fetch_protondb_ratings,
@ -31,6 +40,9 @@ from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_TAMPER_CHECK_LIMIT = 3 _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) hltb_cache = fetch_hltb_times_cached(incomplete, progress_cb=hltb_progress)
_echo("") # newline after progress bar _echo("") # newline after progress bar
polls_cache = load_hltb_polls_cache()
count_comp_cache = load_hltb_count_comp_cache()
for g in games: for g in games:
hours = hltb_cache.get(g.app_id, -1) hours = hltb_cache.get(g.app_id, -1)
g.completionist_hours = hours 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) found = sum(1 for h in hltb_cache.values() if h > 0)
_echo(f" HLTB data: {found} games have completion estimates") _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. # Auto-pick a game if none assigned.
if state.current_app_id is None: if state.current_app_id is None:
pick_next_game(games, state, config) 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 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] candidates = [g for g in games if not g.is_complete and g.app_id not in skip]
if not candidates: 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_app_id = None
state.current_game_name = "" state.current_game_name = ""
state.save() state.save()
@ -162,11 +191,19 @@ def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None:
candidates.sort(key=sort_key) candidates.sort(key=sort_key)
# Filter out Linux-incompatible games via ProtonDB. chosen, confidence_skipped, linux_skipped = _pick_next_shortest_candidate(
chosen = _pick_playable_candidate(candidates) candidates
)
if chosen is None: 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_app_id = None
state.current_game_name = "" state.current_game_name = ""
state.save() 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" Progress: {chosen.unlocked_achievements}/{chosen.total_achievements}"
f" ({chosen.completion_pct:.1f}%)" f" ({chosen.completion_pct:.1f}%)"
) )
_report_poll_confidence(chosen, games, state)
# Uninstall all other games first, then auto-install the assigned one. # Uninstall all other games first, then auto-install the assigned one.
if config.uninstall_other_games: 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 # Checking & tampering detection
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────

View File

@ -41,6 +41,8 @@ class GameInfo:
playtime_minutes: int playtime_minutes: int
achievements: list[AchievementInfo] = field(default_factory=list) achievements: list[AchievementInfo] = field(default_factory=list)
completionist_hours: float = -1 completionist_hours: float = -1
comp_100_count: int = 0
count_comp: int = 0
@property @property
def completion_pct(self) -> float: def completion_pct(self) -> float:
@ -66,6 +68,8 @@ class GameInfo:
"unlocked_achievements": self.unlocked_achievements, "unlocked_achievements": self.unlocked_achievements,
"playtime_minutes": self.playtime_minutes, "playtime_minutes": self.playtime_minutes,
"completionist_hours": self.completionist_hours, "completionist_hours": self.completionist_hours,
"comp_100_count": self.comp_100_count,
"count_comp": self.count_comp,
"achievements": [ "achievements": [
{ {
"api_name": a.api_name, "api_name": a.api_name,
@ -96,6 +100,8 @@ class GameInfo:
unlocked_achievements=data["unlocked_achievements"], unlocked_achievements=data["unlocked_achievements"],
playtime_minutes=data.get("playtime_minutes", 0), playtime_minutes=data.get("playtime_minutes", 0),
completionist_hours=data.get("completionist_hours", -1), 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, achievements=achievements,
) )

View File

@ -2,31 +2,32 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from unittest.mock import patch 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.config import Config, State
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done" CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
def _snap( def _snap(**overrides: object) -> dict[str, object]:
app_id: int = 1, snapshot: dict[str, object] = {
name: str = "G", "app_id": 1,
total: int = 10, "name": "G",
unlocked: int = 0, "total_achievements": 10,
hours: float = -1, "unlocked_achievements": 0,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 60, "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: class TestTryReassignShorterGame:
@ -37,7 +38,12 @@ class TestTryReassignShorterGame:
assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config()) assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config())
def test_no_shorter_candidate(self) -> None: 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 ( with (
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}._echo"),
@ -53,8 +59,15 @@ class TestTryReassignShorterGame:
def test_reassigns(self) -> None: def test_reassigns(self) -> None:
snap = [ snap = [
_snap(1, "Long", 10, 5, 100.0), _snap(
_snap(2, "Short", 10, 5, 5.0), 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") state = State(current_app_id=2, current_game_name="Short")
short_game = GameInfo( short_game = GameInfo(
@ -69,8 +82,8 @@ class TestTryReassignShorterGame:
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}._echo"),
patch( patch(
f"{CMD_DONE_PKG}._pick_playable_candidate", f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
return_value=short_game, return_value=(short_game, 0, 0),
), ),
patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.pick_next_game"),
patch( patch(
@ -91,8 +104,15 @@ class TestTryReassignShorterGame:
def test_reassigns_no_hide_when_no_owned_ids(self) -> None: def test_reassigns_no_hide_when_no_owned_ids(self) -> None:
snap = [ snap = [
_snap(1, "Long", 10, 5, 100.0), _snap(
_snap(2, "Short", 10, 5, 5.0), 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") state = State(current_app_id=2, current_game_name="Short")
short_game = GameInfo( short_game = GameInfo(
@ -107,8 +127,8 @@ class TestTryReassignShorterGame:
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}._echo") as mock_echo, patch(f"{CMD_DONE_PKG}._echo") as mock_echo,
patch( patch(
f"{CMD_DONE_PKG}._pick_playable_candidate", f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
return_value=short_game, return_value=(short_game, 0, 0),
), ),
patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.pick_next_game"),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), 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: def test_reassigns_skip_hide_when_no_app_assigned(self) -> None:
snap = [ snap = [
_snap(1, "Long", 10, 5, 100.0), _snap(
_snap(2, "Short", 10, 5, 5.0), 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="") state = State(current_app_id=None, current_game_name="")
short_game = GameInfo( short_game = GameInfo(
@ -144,8 +171,8 @@ class TestTryReassignShorterGame:
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}._echo"),
patch( patch(
f"{CMD_DONE_PKG}._pick_playable_candidate", f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
return_value=short_game, return_value=(short_game, 0, 0),
), ),
patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.pick_next_game"),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids") as mock_owned, 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: def test_playable_none(self) -> None:
snap = [ snap = [
_snap(1, "Long", 10, 5, 100.0), _snap(
_snap(2, "Short", 10, 5, 5.0), 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 ( with (
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), 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"), patch(f"{CMD_DONE_PKG}._echo"),
): ):
result = _try_reassign_shorter_game( result = _try_reassign_shorter_game(
@ -184,8 +221,18 @@ class TestTryReassignShorterGame:
def test_playable_longer(self) -> None: def test_playable_longer(self) -> None:
"""Playable candidate is longer than current — no reassign.""" """Playable candidate is longer than current — no reassign."""
snap = [ snap = [
_snap(1, "Short", 10, 5, 10.0), _snap(
_snap(2, "Long", 10, 5, 200.0), 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( long_game = GameInfo(
app_id=2, app_id=2,
@ -197,7 +244,10 @@ class TestTryReassignShorterGame:
) )
with ( with (
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), 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"), patch(f"{CMD_DONE_PKG}._echo"),
): ):
result = _try_reassign_shorter_game( result = _try_reassign_shorter_game(
@ -212,8 +262,13 @@ class TestTryReassignShorterGame:
def test_refreshes_stale_shorter_snapshot_entry(self) -> None: def test_refreshes_stale_shorter_snapshot_entry(self) -> None:
"""Uncached shorter snapshot candidates are refreshed before reassigning.""" """Uncached shorter snapshot candidates are refreshed before reassigning."""
snap = [ snap = [
_snap(1, "Current", 10, 5, 20.1), _snap(
_snap(2, "Lacuna", 10, 0, 0.9), 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") state = State(current_app_id=1, current_game_name="Current")
refreshed_short = GameInfo( refreshed_short = GameInfo(
@ -231,9 +286,9 @@ class TestTryReassignShorterGame:
return_value={2: 18.8}, return_value={2: 18.8},
) as mock_fetch_hltb, ) as mock_fetch_hltb,
patch( patch(
f"{CMD_DONE_PKG}._pick_playable_candidate", f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
return_value=refreshed_short, return_value=(refreshed_short, 0, 0),
) as mock_pick_playable, ) as mock_pick_candidate,
patch(f"{CMD_DONE_PKG}.pick_next_game"), patch(f"{CMD_DONE_PKG}.pick_next_game"),
patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
@ -249,4 +304,328 @@ class TestTryReassignShorterGame:
assert result assert result
mock_fetch_hltb.assert_called_once_with([(2, "Lacuna")]) 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

View File

@ -21,9 +21,12 @@ PKG = "python_pkg.steam_backlog_enforcer._enforce_loop"
class TestGetAllOwnedAppIds: class TestGetAllOwnedAppIds:
"""Tests for get_all_owned_app_ids.""" """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}] 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] assert get_all_owned_app_ids(Config()) == [1, 2]
def test_no_snapshot_falls_back_to_api(self) -> None: 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] 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: class TestGuardInstalledGames:
"""Tests for _guard_installed_games.""" """Tests for _guard_installed_games."""

View File

@ -63,6 +63,20 @@ class TestAssertNotRealSteam:
): ):
_assert_not_real_steam(fake_manifest) _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: class TestEcho:
"""Tests for _echo.""" """Tests for _echo."""

View File

@ -203,7 +203,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=game_data, 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 cache[440] == round(21243 / 3600, 2)
assert results[0].completionist_hours == round(21243 / 3600, 2) assert results[0].completionist_hours == round(21243 / 3600, 2)
@ -218,12 +218,12 @@ class TestFetchLeisureTimes:
), ),
] ]
cache: dict[int, float] = {} cache: dict[int, float] = {}
asyncio.run(_fetch_leisure_times(results, cache, None)) asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache assert not cache
def test_empty_results(self) -> None: def test_empty_results(self) -> None:
cache: dict[int, float] = {} cache: dict[int, float] = {}
asyncio.run(_fetch_leisure_times([], cache, None)) asyncio.run(_fetch_leisure_times([], cache, {}, None))
assert not cache assert not cache
def test_detail_returns_none(self) -> None: def test_detail_returns_none(self) -> None:
@ -242,7 +242,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=None, return_value=None,
): ):
asyncio.run(_fetch_leisure_times(results, cache, None)) asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache assert not cache
assert results[0].completionist_hours == 50.0 assert results[0].completionist_hours == 50.0
@ -263,7 +263,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=game_data, return_value=game_data,
): ):
asyncio.run(_fetch_leisure_times(results, cache, None)) asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache assert not cache
assert results[0].completionist_hours == 50.0 assert results[0].completionist_hours == 50.0
@ -288,7 +288,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=game_data, return_value=game_data,
): ):
asyncio.run(_fetch_leisure_times(results, cache, cb)) asyncio.run(_fetch_leisure_times(results, cache, {}, cb))
cb.assert_called_once() cb.assert_called_once()
def test_save_interval(self) -> None: def test_save_interval(self) -> None:
@ -318,7 +318,7 @@ class TestFetchLeisureTimes:
"python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache" "python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache"
) as mock_save, ) as mock_save,
): ):
asyncio.run(_fetch_leisure_times(results, cache, None)) asyncio.run(_fetch_leisure_times(results, cache, {}, None))
mock_save.assert_called_once() mock_save.assert_called_once()
def test_dlc_detail_overrides_relationship_fallback(self) -> None: def test_dlc_detail_overrides_relationship_fallback(self) -> None:
@ -345,7 +345,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=[base_data, dlc_data], 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) expected = round((21243 + 12298) / 3600, 2)
assert cache[1289310] == expected assert cache[1289310] == expected
@ -371,7 +371,7 @@ class TestFetchLeisureTimes:
new_callable=AsyncMock, new_callable=AsyncMock,
side_effect=[base_data, None], 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) expected = round((21243 + 4075) / 3600, 2)
assert cache[1289310] == expected assert cache[1289310] == expected

View File

@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer.hltb import ( from python_pkg.steam_backlog_enforcer.hltb import (
HLTB_BASE_URL, HLTB_BASE_URL,
HLTBResult, HLTBResult,
_AuthInfo,
_fetch_batch_confidence_only,
fetch_hltb_confidence,
fetch_hltb_confidence_cached,
fetch_hltb_times_cached, fetch_hltb_times_cached,
get_hltb_submit_url, get_hltb_submit_url,
) )
@ -35,10 +42,16 @@ class TestFetchHltbTimesCached:
def add_to_cache( def add_to_cache(
_games: object, _games: object,
cache: dict[int, float] | None = None, cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None, progress_cb: object = None,
count_comp: dict[int, int] | None = None,
) -> list[object]: ) -> list[object]:
if cache is not None: if cache is not None:
cache[730] = 20.0 cache[730] = 20.0
if polls is not None:
polls[730] = 0
if count_comp is not None:
count_comp[730] = 0
return [] return []
mock_fetch.side_effect = add_to_cache mock_fetch.side_effect = add_to_cache
@ -87,11 +100,19 @@ class TestFetchHltbTimesCached:
def add_found( def add_found(
_games: object, _games: object,
cache: dict[int, float] | None = None, cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None, progress_cb: object = None,
count_comp: dict[int, int] | None = None,
) -> list[object]: ) -> list[object]:
if cache is not None: if cache is not None:
cache[440] = 50.0 cache[440] = 50.0
cache[730] = -1 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 [] return []
mock_fetch.side_effect = add_found mock_fetch.side_effect = add_found
@ -133,3 +154,82 @@ class TestGetHltbSubmitUrl:
with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]):
url = get_hltb_submit_url("TF2") url = get_hltb_submit_url("TF2")
assert url is None 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()

View File

@ -19,6 +19,7 @@ from python_pkg.steam_backlog_enforcer.hltb import (
HLTBResult, HLTBResult,
_AuthInfo, _AuthInfo,
_fetch_batch, _fetch_batch,
_pick_best_hltb_entry,
_search_one, _search_one,
_SearchCtx, _SearchCtx,
) )
@ -109,6 +110,37 @@ class TestSearchOne:
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None 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: def test_with_progress_cb(self) -> None:
resp = _FakeResponse(200, {"data": []}) resp = _FakeResponse(200, {"data": []})
cb = MagicMock() cb = MagicMock()
@ -235,9 +267,69 @@ class TestFetchBatchHltb:
return_value=None, return_value=None,
), ),
): ):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == [] 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: def test_with_auth(self) -> None:
auth = _AuthInfo("token123", "ign_x", "ff") auth = _AuthInfo("token123", "ign_x", "ff")
with ( with (
@ -266,7 +358,7 @@ class TestFetchBatchHltb:
new_callable=AsyncMock, new_callable=AsyncMock,
), ),
): ):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert len(results) == 1 assert len(results) == 1
def test_with_auth_no_hp(self) -> None: def test_with_auth_no_hp(self) -> None:
@ -291,7 +383,7 @@ class TestFetchBatchHltb:
new_callable=AsyncMock, new_callable=AsyncMock,
), ),
): ):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == [] assert results == []
def test_filters_none_results(self) -> None: def test_filters_none_results(self) -> None:
@ -316,7 +408,7 @@ class TestFetchBatchHltb:
new_callable=AsyncMock, new_callable=AsyncMock,
), ),
): ):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == [] assert results == []

View File

@ -206,6 +206,8 @@ class TestEnforceOnDone:
), ),
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2), 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}.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) _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}.enforce_allowed_game", return_value=[]),
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0), 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}.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) _enforce_on_done(config, state)
@ -234,6 +238,8 @@ class TestEnforceOnDone:
patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False), 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}.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) _enforce_on_done(config, state)
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True) mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)

View File

@ -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()

View File

@ -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.config import Config, State
from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating
from python_pkg.steam_backlog_enforcer.scanning import ( from python_pkg.steam_backlog_enforcer.scanning import (
_filter_hltb_confident_candidates,
_force_refresh_candidate_confidence,
_pick_next_shortest_candidate,
_pick_playable_candidate, _pick_playable_candidate,
_refresh_candidate_confidence_batch,
do_check, do_check,
do_scan, do_scan,
pick_next_game, pick_next_game,
@ -33,6 +37,8 @@ def _game(
unlocked_achievements=unlocked, unlocked_achievements=unlocked,
playtime_minutes=60, playtime_minutes=60,
completionist_hours=hours, 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") config = Config(steam_api_key="k", steam_id="i")
state = State() state = State()
with ( with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
),
patch( patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None, 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) config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True)
state = State() state = State()
with ( with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
),
patch( patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None, 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) config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False)
state = State() state = State()
with ( with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
),
patch( patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None, side_effect=lambda c: c[0] if c else None,
@ -370,6 +385,191 @@ class TestPickNextGame:
pick_next_game([g1], state, config) pick_next_game([g1], state, config)
assert state.current_app_id == 1 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: class TestDoCheck:
"""Tests for do_check.""" """Tests for do_check."""
@ -393,6 +593,100 @@ class TestDoCheck:
state = State(current_app_id=440, current_game_name="TF2") state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state) 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: def test_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=5, unlocked=5) game = _game(app_id=440, name="TF2", total=5, unlocked=5)
mock_client = MagicMock() mock_client = MagicMock()

127
run.sh
View File

@ -1,11 +1,13 @@
#!/bin/bash #!/bin/bash
# Easy entrypoint for system usage reports. # Easy entrypoint for system usage reports and polling script diagnostics.
# Usage: # Usage:
# ./run.sh # today's report to stdout # ./run.sh # today's report to stdout
# ./run.sh --date 20260501 # specific day # ./run.sh --date 20260501 # specific day
# ./run.sh --top 25 # override row count # ./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 set -euo pipefail
@ -17,4 +19,119 @@ if [[ ! -f "$REPORT_SCRIPT" ]]; then
exit 1 exit 1
fi 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" "$@" exec python3 "$REPORT_SCRIPT" "$@"