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