From c647d5d1bdc9e5d0e4e9773a80ede8e77c3aa06c Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 17 Aug 2026 00:39:05 +0200 Subject: [PATCH 1/7] feat(parental): daily play time budget Adds Parental.pak, an optional cap on how long the device can be played each day. Set a limit (90 minutes by default); while time is left nothing changes. Once it is spent, a running game is warned, then stopped, and further rom launches are refused until the next day. The settings screen sits behind a button-sequence code (UP UP DOWN DOWN by default). This is a household guardrail, not a lock. Anyone who can edit the SD card can lift it, and every path fails open: a missing binary, a zero limit or an unreadable db all let the game start. Implementation -------------- All of the policy lives in the pak. The firmware only gained three small generic capabilities, each useful on its own: - Hooks. A synchronous pre-launch hook (*.sync.sh) that exits non-zero now cancels the launch: run_hooks.sh propagates the failure and MinUI.pak guards the launch with it. Background hooks keep the old behaviour -- their exit status is not recoverable from `wait` in POSIX sh, so only .sync.sh hooks get a vote. - minarch. SIGUSR1 saves and quits; SIGUSR2 shows the message left in NOTIFY_PATH over the running game as a NOTIFICATION_SYSTEM toast. The handlers only raise a flag -- the work happens on the main thread once per frame, since neither Menu_beforeSleep() nor Notification_push() is async-signal-safe. - gametimedb. play_activity_get_play_time_since() sums the seconds played across every rom since a given epoch, the session in progress included. Parental.pak composes the three. `parental.elf --gate` answers the pre-launch hook, exit 1 refusing the launch; `--watch` is backgrounded alongside the game and polls every 10s to warn, then stop it; `--stop` tears the watcher down from the post-launch hook. The budget is recomputed from gametimedb on every check rather than stored, so the daily reset is implicit and there is no counter to keep in sync. Opening the pak once is what arms it -- that is when its hooks are installed into .hooks/. Only rom launches are gated. Paks are left alone, both because they open no play session to measure and because blocking them would lock the owner out of the settings screen. Some launchers make a pak look like a rom, though -- a bridge emulator that execs a tool is indistinguishable from a real emulator at this level, since HOOK_TYPE describes the shape of the launch and not the nature of what is launched. The settings screen therefore lists the installed emulators and lets the owner exempt any of them from the budget. Only the exemptions are persisted, never the list of emulators found. A stored list would be a snapshot, and anything installed afterwards would fall outside it and run unmetered; asking instead whether a pak is explicitly exempted keeps the unknown case closed. --- HOOKS.md | 29 +- makefile | 3 + .../Parental.pak/hooks/parental-gate.sync.sh | 38 + .../Parental.pak/hooks/parental-stop.sh | 9 + .../Tools/tg5040/Parental.pak/launch.sh | 12 + .../Parental.pak/hooks/parental-gate.sync.sh | 38 + .../Parental.pak/hooks/parental-stop.sh | 9 + .../Tools/tg5050/Parental.pak/launch.sh | 12 + skeleton/SYSTEM/desktop/bin/run_hooks.sh | 14 +- .../SYSTEM/desktop/paks/MinUI.pak/launch.sh | 8 +- skeleton/SYSTEM/tg5040/bin/run_hooks.sh | 14 +- .../SYSTEM/tg5040/paks/MinUI.pak/launch.sh | 8 +- skeleton/SYSTEM/tg5050/bin/run_hooks.sh | 14 +- .../SYSTEM/tg5050/paks/MinUI.pak/launch.sh | 8 +- workspace/all/common/defines.h | 2 + workspace/all/common/notification.h | 1 + workspace/all/libgametimedb/gametimedb.c | 32 + workspace/all/libgametimedb/gametimedb.h | 5 + workspace/all/minarch/minarch.c | 52 +- workspace/all/parental/makefile | 40 + workspace/all/parental/parental.c | 958 ++++++++++++++++++ workspace/makefile | 2 + 22 files changed, 1293 insertions(+), 15 deletions(-) create mode 100755 skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-gate.sync.sh create mode 100755 skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-stop.sh create mode 100755 skeleton/EXTRAS/Tools/tg5040/Parental.pak/launch.sh create mode 100755 skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-gate.sync.sh create mode 100755 skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-stop.sh create mode 100755 skeleton/EXTRAS/Tools/tg5050/Parental.pak/launch.sh create mode 100644 workspace/all/parental/makefile create mode 100644 workspace/all/parental/parental.c diff --git a/HOOKS.md b/HOOKS.md index bd6583c83..02e47c772 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -65,13 +65,38 @@ echo "$(date): launched $HOOK_ROM_PATH" >> "$LOGS_PATH/launches.log" ## Rules -- Each script runs in a subshell. A crash or non-zero exit will not affect the launcher or other hooks. +- Each script runs in a subshell. A crash will not affect the launcher or other hooks. - Script output (stdout/stderr) is suppressed. If you need logging, write to your own log file. -- Pre-launch hooks cannot cancel the launch. They are for observation and setup only. +- A **synchronous** pre-launch hook (`*.sync.sh`) that exits non-zero **cancels the launch**: the rom or pak is never started, and `post-launch.d` is skipped. See "Vetoing a launch" below. +- Background hooks cannot cancel anything — their exit status is not recoverable from `wait` in POSIX sh, so only `.sync.sh` hooks get a vote. A non-zero exit from a background hook is ignored, as is any exit status outside `pre-launch.d`. - Keep hooks fast. A slow hook delays the launch or the return to the menu. - Unlike auto.sh, each pak should manage their own hook and use a descriptive filename to avoid collisions. +## Vetoing a launch + +Name the hook `*.sync.sh` and exit non-zero: + +```sh +#!/bin/sh +# no-roms-before-noon.sync.sh + +[ "$HOOK_TYPE" = "rom" ] || exit 0 +[ "$(date +%H)" -ge 12 ] && exit 0 + +show2.elf --mode=simple --text="Not before noon" --timeout=3 +exit 1 +``` + +Two things to know when you cancel a rom launch: + +- The launcher has already run `gametimectl.elf start` for that rom by the time the + hook runs, so a vetoing hook should call `gametimectl.elf stop_all` to avoid leaving + an open play session behind. +- Nothing is displayed for you. If the user should know why nothing happened, say so — + `show2.elf` is the usual way. + + ## Example: sync after ROM exit ```sh diff --git a/makefile b/makefile index fed3da9f6..cf2af7ea9 100644 --- a/makefile +++ b/makefile @@ -95,6 +95,9 @@ ifneq ($(PLATFORM), desktop) cp ./workspace/all/libgametimedb/build/$(PLATFORM)/libgametimedb.so ./build/SYSTEM/$(PLATFORM)/lib cp ./workspace/all/gametimectl/build/$(PLATFORM)/gametimectl.elf ./build/SYSTEM/$(PLATFORM)/bin/ cp ./workspace/all/gametime/build/$(PLATFORM)/gametime.elf ./build/EXTRAS/Tools/$(PLATFORM)/Game\ Tracker.pak/ + + # daily play time budget (reads the same db as game time tracking) + cp ./workspace/all/parental/build/$(PLATFORM)/parental.elf ./build/EXTRAS/Tools/$(PLATFORM)/Parental.pak/ endif cp ./workspace/$(PLATFORM)/libmsettings/libmsettings.so ./build/SYSTEM/$(PLATFORM)/lib cp ./workspace/all/nextui/build/$(PLATFORM)/nextui.elf ./build/SYSTEM/$(PLATFORM)/bin/ diff --git a/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-gate.sync.sh b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-gate.sync.sh new file mode 100755 index 000000000..50c67fbc6 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-gate.sync.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# parental-gate.sync.sh -- refuse a rom launch once the daily budget is spent. +# +# Must stay named *.sync.sh: only synchronous pre-launch hooks get a veto, +# and a non-zero exit is what cancels the launch. + +[ "$HOOK_TYPE" = "rom" ] || exit 0 + +# Emulators the owner exempted in the settings screen. HOOK_EMU_PATH points at +# the emulator's launch.sh, so the pak name is its parent directory -- basename +# alone would be "launch.sh" for every emulator alike. +# +# This is what lets tool shortcuts through: they are launched by the bridge +# emulator of the Shortcuts pak, which makes them look like roms to the launcher. +# Deny by default, so a pak that is not named here stays blocked. +EMU_PAK=$(basename "$(dirname "$HOOK_EMU_PATH")") +EXEMPT=$(sed -n 's/^exempt=//p' "$SHARED_USERDATA_PATH/parental.txt" 2>/dev/null) +case ",$EXEMPT," in + *",$EMU_PAK,"*) exit 0 ;; +esac + +PAK="$SDCARD_PATH/Tools/$PLATFORM/Parental.pak" +[ -x "$PAK/parental.elf" ] || exit 0 + +if "$PAK/parental.elf" --gate; then + # budget left: watch the session so it can be stopped on time + "$PAK/parental.elf" --watch & + exit 0 +fi + +# nextui already opened a play_activity row before handing off to us, close it +# so the refused launch doesn't count as time played +gametimectl.elf stop_all + +# --image is mandatory, show2.elf prints its usage and draws nothing without it +show2.elf --mode=simple --image="$SDCARD_PATH/.system/res/logo.png" --text="No play time left today" --timeout=3 + +exit 1 diff --git a/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-stop.sh b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-stop.sh new file mode 100755 index 000000000..dd5ba2147 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/hooks/parental-stop.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# parental-stop.sh -- tear down the watcher started by parental-gate.sync.sh + +[ "$HOOK_TYPE" = "rom" ] || exit 0 + +PAK="$SDCARD_PATH/Tools/$PLATFORM/Parental.pak" +[ -x "$PAK/parental.elf" ] || exit 0 + +"$PAK/parental.elf" --stop diff --git a/skeleton/EXTRAS/Tools/tg5040/Parental.pak/launch.sh b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/launch.sh new file mode 100755 index 000000000..6b8f675a2 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5040/Parental.pak/launch.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +cd "$(dirname "$0")" + +# (Re)install the hooks that actually enforce the budget. Opening the pak once +# is what arms it -- there is no other moment a Tools pak gets to run. +mkdir -p "$HOOKS_PATH/pre-launch.d" "$HOOKS_PATH/post-launch.d" +cp -f ./hooks/parental-gate.sync.sh "$HOOKS_PATH/pre-launch.d/" +cp -f ./hooks/parental-stop.sh "$HOOKS_PATH/post-launch.d/" +chmod +x "$HOOKS_PATH/pre-launch.d/parental-gate.sync.sh" "$HOOKS_PATH/post-launch.d/parental-stop.sh" + +./parental.elf # &> ./log.txt diff --git a/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-gate.sync.sh b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-gate.sync.sh new file mode 100755 index 000000000..50c67fbc6 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-gate.sync.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# parental-gate.sync.sh -- refuse a rom launch once the daily budget is spent. +# +# Must stay named *.sync.sh: only synchronous pre-launch hooks get a veto, +# and a non-zero exit is what cancels the launch. + +[ "$HOOK_TYPE" = "rom" ] || exit 0 + +# Emulators the owner exempted in the settings screen. HOOK_EMU_PATH points at +# the emulator's launch.sh, so the pak name is its parent directory -- basename +# alone would be "launch.sh" for every emulator alike. +# +# This is what lets tool shortcuts through: they are launched by the bridge +# emulator of the Shortcuts pak, which makes them look like roms to the launcher. +# Deny by default, so a pak that is not named here stays blocked. +EMU_PAK=$(basename "$(dirname "$HOOK_EMU_PATH")") +EXEMPT=$(sed -n 's/^exempt=//p' "$SHARED_USERDATA_PATH/parental.txt" 2>/dev/null) +case ",$EXEMPT," in + *",$EMU_PAK,"*) exit 0 ;; +esac + +PAK="$SDCARD_PATH/Tools/$PLATFORM/Parental.pak" +[ -x "$PAK/parental.elf" ] || exit 0 + +if "$PAK/parental.elf" --gate; then + # budget left: watch the session so it can be stopped on time + "$PAK/parental.elf" --watch & + exit 0 +fi + +# nextui already opened a play_activity row before handing off to us, close it +# so the refused launch doesn't count as time played +gametimectl.elf stop_all + +# --image is mandatory, show2.elf prints its usage and draws nothing without it +show2.elf --mode=simple --image="$SDCARD_PATH/.system/res/logo.png" --text="No play time left today" --timeout=3 + +exit 1 diff --git a/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-stop.sh b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-stop.sh new file mode 100755 index 000000000..dd5ba2147 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/hooks/parental-stop.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# parental-stop.sh -- tear down the watcher started by parental-gate.sync.sh + +[ "$HOOK_TYPE" = "rom" ] || exit 0 + +PAK="$SDCARD_PATH/Tools/$PLATFORM/Parental.pak" +[ -x "$PAK/parental.elf" ] || exit 0 + +"$PAK/parental.elf" --stop diff --git a/skeleton/EXTRAS/Tools/tg5050/Parental.pak/launch.sh b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/launch.sh new file mode 100755 index 000000000..6b8f675a2 --- /dev/null +++ b/skeleton/EXTRAS/Tools/tg5050/Parental.pak/launch.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +cd "$(dirname "$0")" + +# (Re)install the hooks that actually enforce the budget. Opening the pak once +# is what arms it -- there is no other moment a Tools pak gets to run. +mkdir -p "$HOOKS_PATH/pre-launch.d" "$HOOKS_PATH/post-launch.d" +cp -f ./hooks/parental-gate.sync.sh "$HOOKS_PATH/pre-launch.d/" +cp -f ./hooks/parental-stop.sh "$HOOKS_PATH/post-launch.d/" +chmod +x "$HOOKS_PATH/pre-launch.d/parental-gate.sync.sh" "$HOOKS_PATH/post-launch.d/parental-stop.sh" + +./parental.elf # &> ./log.txt diff --git a/skeleton/SYSTEM/desktop/bin/run_hooks.sh b/skeleton/SYSTEM/desktop/bin/run_hooks.sh index 30b86ca60..96785fe03 100755 --- a/skeleton/SYSTEM/desktop/bin/run_hooks.sh +++ b/skeleton/SYSTEM/desktop/bin/run_hooks.sh @@ -7,6 +7,12 @@ # # By default, scripts run in the background. Scripts ending in .sync.sh # always run synchronously. All background scripts are waited on before exit. +# +# Veto: a synchronous hook that exits non-zero makes run_hooks.sh itself exit +# non-zero, letting the caller cancel whatever the hooks were run ahead of +# (see pre-launch.d in MinUI.pak/launch.sh). Background hooks cannot veto -- +# their exit status is not recoverable from `wait` in POSIX sh -- so only +# .sync.sh hooks (or any hook under --sync-only) get a vote. DIR_NAME="$1" SYNC_ONLY="${2:-}" @@ -25,12 +31,18 @@ case "$DIR_NAME" in esac export HOOK_CATEGORY="$DIR_NAME" +VETOED=0 + for script in "$HOOK_DIR"/*.sh; do [ -f "$script" ] || continue if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then - ( "$script" ) > /dev/null 2>&1 || true + if ! ( "$script" ) > /dev/null 2>&1; then + VETOED=1 + fi else ( "$script" ) > /dev/null 2>&1 & fi done wait + +exit $VETOED diff --git a/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh index 9ba15c8d6..3f608968a 100755 --- a/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh @@ -81,9 +81,11 @@ touch "$EXEC_PATH" && sync if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` parse_hook_cmd "$CMD" - "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d - eval $CMD - "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + # a synchronous pre-launch hook that exits non-zero cancels the launch + if "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d; then + eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + fi rm -f $NEXT_PATH fi #done diff --git a/skeleton/SYSTEM/tg5040/bin/run_hooks.sh b/skeleton/SYSTEM/tg5040/bin/run_hooks.sh index 96fbe4833..ded102b99 100755 --- a/skeleton/SYSTEM/tg5040/bin/run_hooks.sh +++ b/skeleton/SYSTEM/tg5040/bin/run_hooks.sh @@ -7,6 +7,12 @@ # # By default, scripts run in the background. Scripts ending in .sync.sh # always run synchronously. All background scripts are waited on before exit. +# +# Veto: a synchronous hook that exits non-zero makes run_hooks.sh itself exit +# non-zero, letting the caller cancel whatever the hooks were run ahead of +# (see pre-launch.d in MinUI.pak/launch.sh). Background hooks cannot veto -- +# their exit status is not recoverable from `wait` in POSIX sh -- so only +# .sync.sh hooks (or any hook under --sync-only) get a vote. DIR_NAME="$1" SYNC_ONLY="${2:-}" @@ -25,12 +31,18 @@ case "$DIR_NAME" in esac export HOOK_CATEGORY="$DIR_NAME" +VETOED=0 + for script in "$HOOK_DIR"/*.sh; do [ -f "$script" ] || continue if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then - ( "$script" ) > /dev/null 2>&1 || true + if ! ( "$script" ) > /dev/null 2>&1; then + VETOED=1 + fi else ( "$script" ) > /dev/null 2>&1 & fi done wait + +exit $VETOED diff --git a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh index e4a585713..e6a500dc5 100755 --- a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh @@ -191,9 +191,11 @@ while [ -f $EXEC_PATH ]; do if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` parse_hook_cmd "$CMD" - "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d - eval $CMD - "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + # a synchronous pre-launch hook that exits non-zero cancels the launch + if "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d; then + eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + fi rm -f $NEXT_PATH # reset to performance when exiting, UI will reset to auto if needed sh "$SYSTEM_PATH/bin/governor.sh" "performance" diff --git a/skeleton/SYSTEM/tg5050/bin/run_hooks.sh b/skeleton/SYSTEM/tg5050/bin/run_hooks.sh index 7e6886546..dac362ec9 100755 --- a/skeleton/SYSTEM/tg5050/bin/run_hooks.sh +++ b/skeleton/SYSTEM/tg5050/bin/run_hooks.sh @@ -7,6 +7,12 @@ # # By default, scripts run in the background. Scripts ending in .sync.sh # always run synchronously. All background scripts are waited on before exit. +# +# Veto: a synchronous hook that exits non-zero makes run_hooks.sh itself exit +# non-zero, letting the caller cancel whatever the hooks were run ahead of +# (see pre-launch.d in MinUI.pak/launch.sh). Background hooks cannot veto -- +# their exit status is not recoverable from `wait` in POSIX sh -- so only +# .sync.sh hooks (or any hook under --sync-only) get a vote. DIR_NAME="$1" SYNC_ONLY="${2:-}" @@ -25,12 +31,18 @@ case "$DIR_NAME" in esac export HOOK_CATEGORY="$DIR_NAME" +VETOED=0 + for script in "$HOOK_DIR"/*.sh; do [ -f "$script" ] || continue if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then - ( "$script" ) > /dev/null 2>&1 || true + if ! ( "$script" ) > /dev/null 2>&1; then + VETOED=1 + fi else ( "$script" ) > /dev/null 2>&1 & fi done wait + +exit $VETOED diff --git a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh index ec5a9af56..a2600eb5a 100755 --- a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh @@ -191,9 +191,11 @@ while [ -f $EXEC_PATH ]; do if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` parse_hook_cmd "$CMD" - "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d - eval $CMD - "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + # a synchronous pre-launch hook that exits non-zero cancels the launch + if "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d; then + eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d + fi rm -f $NEXT_PATH # reset to performance when exiting, UI will reset to auto if needed sh "$SYSTEM_PATH/bin/governor.sh" "performance" diff --git a/workspace/all/common/defines.h b/workspace/all/common/defines.h index 2b71ce56a..66b172e66 100644 --- a/workspace/all/common/defines.h +++ b/workspace/all/common/defines.h @@ -35,6 +35,8 @@ #define CHANGE_DISC_PATH "/tmp/change_disc.txt" #define RESUME_SLOT_PATH "/tmp/resume_slot.txt" #define NOUI_PATH "/tmp/noui" +// message an external process wants shown over the running game, see SIGUSR2 in minarch +#define NOTIFY_PATH "/tmp/notify.txt" #define TRIAD_WHITE 0xff,0xff,0xff #define TRIAD_BLACK 0x00,0x00,0x00 diff --git a/workspace/all/common/notification.h b/workspace/all/common/notification.h index eeb3608fc..190238465 100644 --- a/workspace/all/common/notification.h +++ b/workspace/all/common/notification.h @@ -23,6 +23,7 @@ typedef enum { NOTIFICATION_SETTING, // volume/brightness/colortemp adjustments NOTIFICATION_ACHIEVEMENT, // RetroAchievements unlocks NOTIFICATION_OFFLINE_ACHIEVEMENT, // Offline RA unlocks (shows wifi-off icon) + NOTIFICATION_SYSTEM, // pushed by an external process, see SIGUSR2 in minarch } NotificationType; typedef enum { diff --git a/workspace/all/libgametimedb/gametimedb.c b/workspace/all/libgametimedb/gametimedb.c index afbb0469d..eb3261eba 100644 --- a/workspace/all/libgametimedb/gametimedb.c +++ b/workspace/all/libgametimedb/gametimedb.c @@ -138,6 +138,38 @@ int play_activity_get_total_play_time(void) return total_play_time; } +int play_activity_get_play_time_since(int since_epoch) +{ + int play_time = 0; + // play_time is only written when a session is stopped, so the session that + // is running right now still has it NULL -- fall back to the elapsed time + // since created_at for those, otherwise an in-progress session counts as 0. + char *sql = + "SELECT COALESCE(SUM(" + " CASE WHEN play_time IS NULL OR play_time <= 0 " + " THEN strftime('%s', 'now') - created_at " + " ELSE play_time END" + "), 0) FROM play_activity WHERE created_at >= ?;"; + sqlite3_stmt *stmt; + + sqlite3* game_log_db = play_activity_db_open(); + stmt = play_activity_db_prepare(game_log_db, sql); + + // an empty or half-written db yields no statement; report 0 rather than + // stepping a NULL one, callers may be gating on this + if (stmt != NULL) { + sqlite3_bind_int(stmt, 1, since_epoch); + if (sqlite3_step(stmt) == SQLITE_ROW) { + play_time = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + } + + play_activity_db_close(game_log_db); + + return play_time; +} + PlayActivities *play_activity_find_all(void) { PlayActivities *play_activities = NULL; diff --git a/workspace/all/libgametimedb/gametimedb.h b/workspace/all/libgametimedb/gametimedb.h index 4c0e4eb1d..5082f08c4 100644 --- a/workspace/all/libgametimedb/gametimedb.h +++ b/workspace/all/libgametimedb/gametimedb.h @@ -35,6 +35,11 @@ void free_play_activities(PlayActivities *pa_ptr); PlayActivities *play_activity_find_all(void); //int play_activity_get_play_time(const char *rom_path); +// Seconds played across every rom since since_epoch, the session in progress +// (if any) included. Used to answer "how long has this device been played +// today", which no per-rom aggregate can express. +int play_activity_get_play_time_since(int since_epoch); + // Main interface functions for write access void play_activity_start(char *rom_file_path); void play_activity_resume(void); diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index b3e156eda..3b4445b4d 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -1,4 +1,5 @@ #include +#include #include #include @@ -108,6 +109,51 @@ static void Special_quit(void) { /////////////////////////////// +/////////////////////////////// +// external control signals +// +// SIGUSR1: save and quit, as if the player had put the device to sleep and +// then exited. SIGUSR2: show the message left in NOTIFY_PATH over the +// running game. Handlers only raise a flag: the work happens once per +// frame on the main thread in sigmon(), because neither +// Menu_beforeSleep() nor Notification_push() is async-signal-safe +// (and Notification_push() mutates its queue without a mutex). + +static volatile sig_atomic_t sig_stop_requested = 0; +static volatile sig_atomic_t sig_notify_requested = 0; + +static void sigHandler(int sig) { + switch (sig) { + case SIGUSR1: sig_stop_requested = 1; break; + case SIGUSR2: sig_notify_requested = 1; break; + default: break; + } +} + +static void sigmon(void) { + if (sig_notify_requested) { + sig_notify_requested = 0; + if (exists(NOTIFY_PATH)) { + char msg[NOTIFICATION_MAX_MESSAGE]; + getFile(NOTIFY_PATH, msg, sizeof(msg)); + unlink(NOTIFY_PATH); + trimTrailingNewlines(msg); + if (msg[0]) Notification_push(NOTIFICATION_SYSTEM, msg, NULL); + } + } + + if (sig_stop_requested) { + sig_stop_requested = 0; + + LOG_info("stopping on SIGUSR1...\n"); + Menu_beforeSleep(); + show_menu = 0; + quit = 1; + } +} + +/////////////////////////////// + void hdmimon(void) { // handle HDMI change static int had_hdmi = -1; @@ -207,7 +253,10 @@ int main(int argc , char* argv[]) { InitSettings(); // after we initialize audio Menu_init(); Notification_init(); - + + signal(SIGUSR1, sigHandler); + signal(SIGUSR2, sigHandler); + // Load game for RetroAchievements tracking (must be after Notification_init) // Pass ROM data if available, otherwise just path (for cores that load from file) { @@ -324,6 +373,7 @@ int main(int argc , char* argv[]) { Audio_checkAndResetIfNeeded(); hdmimon(); + sigmon(); } int cw, ch; unsigned char* pixels = GFX_GL_screenCapture(&cw, &ch); diff --git a/workspace/all/parental/makefile b/workspace/all/parental/makefile new file mode 100644 index 000000000..9eeb5eef1 --- /dev/null +++ b/workspace/all/parental/makefile @@ -0,0 +1,40 @@ +########################################################### + +ifeq (,$(PLATFORM)) +PLATFORM=$(UNION_PLATFORM) +endif + +ifeq (,$(PLATFORM)) + $(error please specify PLATFORM, eg. PLATFORM=trimui make) +endif + +ifeq (,$(CROSS_COMPILE)) + $(error missing CROSS_COMPILE for this toolchain) +endif + +########################################################### + +include ../../$(PLATFORM)/platform/makefile.env +SDL?=SDL + +########################################################### + +TARGET = parental +INCDIR = -I. -I../common/ -I../../$(PLATFORM)/platform/ +SOURCE = $(TARGET).c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../../$(PLATFORM)/platform/platform.c + +CC = $(CROSS_COMPILE)gcc +CFLAGS += $(OPT) +CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -std=gnu99 +LDFLAGS += -lmsettings -lgametimedb -lsqlite3 + +PRODUCT= build/$(PLATFORM)/$(TARGET).elf + +all: $(PREFIX_LOCAL)/include/gametimedb.h + mkdir -p build/$(PLATFORM) + $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) +clean: + rm -f $(PRODUCT) + +$(PREFIX_LOCAL)/include/gametimedb.h: + cd ../libgametimedb && make diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c new file mode 100644 index 000000000..4c5ef0982 --- /dev/null +++ b/workspace/all/parental/parental.c @@ -0,0 +1,958 @@ +// Parental.pak -- daily play time budget. +// +// One binary, four modes: +// parental.elf the UI (remaining time, and a code-locked settings menu) +// parental.elf --gate called by the pre-launch hook; exit 1 refuses the launch +// parental.elf --watch backgrounded by the pre-launch hook; warns then stops the game +// parental.elf --stop called by the post-launch hook; kills the watcher +// +// Time played is not tracked here: nextui already logs every session through +// gametimectl into the shared sqlite db, so we only aggregate it per day via +// play_activity_get_play_time_since(). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "defines.h" +#include "api.h" +#include "utils.h" + +#include +#include + +/////////////////////////////////////// +// config + +#define PARENTAL_CFG_PATH SHARED_USERDATA_PATH "/parental.txt" +#define PARENTAL_PID_PATH "/tmp/parental.pid" + +#define MIN_CODE_LEN 2 +#define MAX_CODE_LEN 8 + +#define DEFAULT_LIMIT_MINUTES 90 +#define DEFAULT_WARN_MINUTES 5 + +#define WATCH_INTERVAL_SEC 10 + +// How long the "time is up" notice stays on screen before the game is stopped. +// The player keeps playing during it, which is the point: being cut off mid-move +// with no warning is what makes a limit feel broken rather than enforced. +#define STOP_NOTICE_SEC 3 + +// Emulator paks the budget does not apply to, stored as a comma separated list +// of pak names. Only exemptions are persisted, never the full list of installed +// emulators: a stored full list would be a snapshot, and anything installed +// afterwards would silently fall outside it. An unknown pak must stay blocked. +#define EXEMPT_MAX 512 +#define MAX_PAK_NAME 64 + +typedef struct { + int limit_minutes; // <= 0 disables the whole thing + int warn_minutes; // not exposed in the UI, edit the file to change it + unsigned long code_hash; + int code_len; + char exempt[EXEMPT_MAX]; +} Config; + +// Buttons a code can be made of. B is deliberately absent: it always means +// "leave this screen", which is what makes code entry need no confirm key. +// START is absent too, it terminates capture when setting a new code. +static const struct { + int btn; + const char *name; +} CODE_BUTTONS[] = { + { BTN_UP, "UP" }, + { BTN_DOWN, "DOWN" }, + { BTN_LEFT, "LEFT" }, + { BTN_RIGHT, "RIGHT" }, + { BTN_A, "A" }, + { BTN_X, "X" }, + { BTN_Y, "Y" }, + { BTN_L1, "L1" }, + { BTN_R1, "R1" }, + { BTN_SELECT, "SELECT" }, +}; +#define CODE_BUTTON_COUNT ((int)(sizeof(CODE_BUTTONS) / sizeof(CODE_BUTTONS[0]))) + +// The default code: UP, UP, DOWN, DOWN. +static const int DEFAULT_CODE[] = { 0, 0, 1, 1 }; +#define DEFAULT_CODE_LEN ((int)(sizeof(DEFAULT_CODE) / sizeof(DEFAULT_CODE[0]))) + +// djb2. Not a security measure -- the threat model here is a child poking +// around with Files.pak, not an attacker. It only keeps the code from being +// readable at a glance. +static unsigned long hashCode(const int *indices, int len) +{ + unsigned long hash = 5381; + for (int i = 0; i < len; i++) { + const char *name = CODE_BUTTONS[indices[i]].name; + for (const char *c = name; *c; c++) + hash = ((hash << 5) + hash) + (unsigned char)*c; + hash = ((hash << 5) + hash) + ','; + } + return hash; +} + +static void configSave(const Config *cfg) +{ + FILE *file = fopen(PARENTAL_CFG_PATH, "w"); + if (!file) return; + fprintf(file, "limit_minutes=%d\n", cfg->limit_minutes); + fprintf(file, "warn_minutes=%d\n", cfg->warn_minutes); + fprintf(file, "code_hash=%lu\n", cfg->code_hash); + fprintf(file, "code_len=%d\n", cfg->code_len); + fprintf(file, "exempt=%s\n", cfg->exempt); + fclose(file); + sync(); +} + +static void configLoad(Config *cfg) +{ + cfg->limit_minutes = DEFAULT_LIMIT_MINUTES; + cfg->warn_minutes = DEFAULT_WARN_MINUTES; + cfg->code_hash = hashCode(DEFAULT_CODE, DEFAULT_CODE_LEN); + cfg->code_len = DEFAULT_CODE_LEN; + cfg->exempt[0] = '\0'; + + FILE *file = fopen(PARENTAL_CFG_PATH, "r"); + if (!file) { + configSave(cfg); + return; + } + + // must hold a full exempt= line, a short buffer would truncate the list + // mid-name and silently drop or corrupt the last exemption + char line[EXEMPT_MAX + 64]; + while (fgets(line, sizeof(line), file)) { + trimTrailingNewlines(line); + char *sep = strchr(line, '='); + if (!sep) continue; + *sep = '\0'; + const char *key = line; + const char *value = sep + 1; + + if (strcmp(key, "limit_minutes") == 0) cfg->limit_minutes = atoi(value); + else if (strcmp(key, "warn_minutes") == 0) cfg->warn_minutes = atoi(value); + else if (strcmp(key, "code_hash") == 0) cfg->code_hash = strtoul(value, NULL, 10); + else if (strcmp(key, "code_len") == 0) cfg->code_len = atoi(value); + else if (strcmp(key, "exempt") == 0) { + strncpy(cfg->exempt, value, EXEMPT_MAX - 1); + cfg->exempt[EXEMPT_MAX - 1] = '\0'; + } + } + fclose(file); + + if (cfg->warn_minutes < 0) cfg->warn_minutes = 0; + if (cfg->code_len < MIN_CODE_LEN || cfg->code_len > MAX_CODE_LEN) + cfg->code_len = DEFAULT_CODE_LEN; +} + +/////////////////////////////////////// +// exemptions + +// Whole-token match, so "GB.pak" never matches inside "GBA.pak". +static bool isExempt(const Config *cfg, const char *name) +{ + const char *p = cfg->exempt; + size_t len = strlen(name); + + while (*p) { + const char *end = strchr(p, ','); + size_t span = end ? (size_t)(end - p) : strlen(p); + if (span == len && strncmp(p, name, len) == 0) return true; + if (!end) break; + p = end + 1; + } + return false; +} + +// Returns false when the list is full, so the caller can say so rather than +// letting the exemption silently not happen. +static bool exemptAdd(Config *cfg, const char *name) +{ + if (isExempt(cfg, name)) return true; + + size_t used = strlen(cfg->exempt); + size_t need = strlen(name) + (used ? 1 : 0); + if (used + need >= EXEMPT_MAX) return false; + + if (used) strcat(cfg->exempt, ","); + strcat(cfg->exempt, name); + return true; +} + +static void exemptRemove(Config *cfg, const char *name) +{ + char rebuilt[EXEMPT_MAX]; + size_t len = strlen(name); + const char *p = cfg->exempt; + + rebuilt[0] = '\0'; + while (*p) { + const char *end = strchr(p, ','); + size_t span = end ? (size_t)(end - p) : strlen(p); + + if (!(span == len && strncmp(p, name, len) == 0) && span > 0) { + if (rebuilt[0]) strcat(rebuilt, ","); + strncat(rebuilt, p, span); + } + if (!end) break; + p = end + 1; + } + + strcpy(cfg->exempt, rebuilt); +} + +/////////////////////////////////////// +// installed emulators +// +// Scanned only to populate the settings screen. The result is never written to +// parental.txt -- see the note on EXEMPT_MAX. + +#define EMUS_SD_PATH SDCARD_PATH "/Emus/" PLATFORM +#define EMUS_SYSTEM_PATH PAKS_PATH "/Emus" +#define MAX_EMUS 64 + +typedef struct { + char name[MAX_PAK_NAME]; // "GBA.pak" +} Emulator; + +static int emuCompare(const void *a, const void *b) +{ + return strcasecmp(((const Emulator *)a)->name, ((const Emulator *)b)->name); +} + +static int scanEmulatorDir(const char *path, Emulator *emus, int count) +{ + DIR *dir = opendir(path); + if (!dir) return count; + + struct dirent *entry; + while ((entry = readdir(dir)) && count < MAX_EMUS) { + if (entry->d_name[0] == '.') continue; + + size_t len = strlen(entry->d_name); + if (len < 5 || len >= MAX_PAK_NAME) continue; + if (strcmp(entry->d_name + len - 4, ".pak") != 0) continue; + + bool seen = false; + for (int i = 0; i < count; i++) + if (strcmp(emus[i].name, entry->d_name) == 0) { seen = true; break; } + if (seen) continue; + + strcpy(emus[count++].name, entry->d_name); + } + + closedir(dir); + return count; +} + +// Both locations getEmuPath() resolves against, the sd card taking priority. +static int scanEmulators(Emulator *emus) +{ + int count = scanEmulatorDir(EMUS_SD_PATH, emus, 0); + count = scanEmulatorDir(EMUS_SYSTEM_PATH, emus, count); + qsort(emus, count, sizeof(Emulator), emuCompare); + return count; +} + +/////////////////////////////////////// +// budget + +static int startOfToday(void) +{ + time_t now = time(NULL); + struct tm tm = *localtime(&now); + tm.tm_hour = 0; + tm.tm_min = 0; + tm.tm_sec = 0; + return (int)mktime(&tm); +} + +static int secondsPlayedToday(void) +{ + return play_activity_get_play_time_since(startOfToday()); +} + +// Seconds left today, or -1 when there is no limit. +static int remainingFrom(const Config *cfg, int played) +{ + if (cfg->limit_minutes <= 0) return -1; + int remaining = cfg->limit_minutes * 60 - played; + return remaining < 0 ? 0 : remaining; +} + +static int secondsRemaining(const Config *cfg) +{ + if (cfg->limit_minutes <= 0) return -1; + return remainingFrom(cfg, secondsPlayedToday()); +} + +/////////////////////////////////////// +// headless modes + +static int processRunning(const char *name) +{ + DIR *dir = opendir("/proc"); + if (!dir) return 1; // can't tell, assume it is + + int found = 0; + struct dirent *entry; + while (!found && (entry = readdir(dir)) != NULL) { + if (entry->d_name[0] < '0' || entry->d_name[0] > '9') continue; + + char path[64]; + snprintf(path, sizeof(path), "/proc/%s/comm", entry->d_name); + FILE *file = fopen(path, "r"); + if (!file) continue; + + char comm[64] = { 0 }; + if (fgets(comm, sizeof(comm), file)) { + trimTrailingNewlines(comm); + if (strcmp(comm, name) == 0) found = 1; + } + fclose(file); + } + + closedir(dir); + return found; +} + +// exit 0 to allow the launch, 1 to refuse it +static int modeGate(void) +{ + Config cfg; + configLoad(&cfg); + + if (cfg.limit_minutes <= 0) return EXIT_SUCCESS; + return secondsRemaining(&cfg) > 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +static int modeWatch(void) +{ + Config cfg; + configLoad(&cfg); + if (cfg.limit_minutes <= 0) return EXIT_SUCCESS; + + putInt(PARENTAL_PID_PATH, (int)getpid()); + + int warned = 0; + while (1) { + sleep(WATCH_INTERVAL_SEC); + + // the post-launch hook normally stops us, this is the safety net + if (!processRunning("minarch.elf")) break; + + int remaining = secondsRemaining(&cfg); + if (remaining <= 0) { + LOG_info("parental: budget exhausted, stopping the game\n"); + // say so before pulling the plug, reusing the same SIGUSR2 path as + // the warning above so minarch needs no second mechanism + putFile(NOTIFY_PATH, "Time is up"); + system("killall -USR2 minarch.elf"); + sleep(STOP_NOTICE_SEC); + system("killall -USR1 minarch.elf"); + break; + } + + if (!warned && remaining <= cfg.warn_minutes * 60) { + char msg[64]; + snprintf(msg, sizeof(msg), "Stopping in %d min", cfg.warn_minutes); + putFile(NOTIFY_PATH, msg); + system("killall -USR2 minarch.elf"); + warned = 1; + } + } + + unlink(PARENTAL_PID_PATH); + return EXIT_SUCCESS; +} + +static int modeStop(void) +{ + if (exists(PARENTAL_PID_PATH)) { + int pid = getInt(PARENTAL_PID_PATH); + if (pid > 1) kill(pid, SIGTERM); + unlink(PARENTAL_PID_PATH); + } + return EXIT_SUCCESS; +} + +/////////////////////////////////////// +// UI + +enum { + SCREEN_HOME, + SCREEN_CODE, + SCREEN_ADMIN, + SCREEN_LIMIT, + SCREEN_NEWCODE, + SCREEN_EXEMPT, +}; + +enum { + ADMIN_LIMIT, + ADMIN_EXEMPT, + ADMIN_CODE, + ADMIN_COUNT, +}; + +enum { + FIELD_HOURS, + FIELD_MINUTES, + FIELD_COUNT, +}; + +#define ROW_HEIGHT (PILL_SIZE + 6) +#define SLOT_GAP 6 +#define TOAST_DURATION_MS 1500 + +static bool quit = false; +static SDL_Surface *screen; + +static char toast_message[64] = { 0 }; +static uint32_t toast_until = 0; + +static void sigHandler(int sig) +{ + switch (sig) { + case SIGINT: + case SIGTERM: + quit = true; + break; + default: + break; + } +} + +static void toast(const char *message) +{ + strncpy(toast_message, message, sizeof(toast_message) - 1); + toast_message[sizeof(toast_message) - 1] = '\0'; + toast_until = SDL_GetTicks() + TOAST_DURATION_MS; +} + +static int renderText(const char *text, TTF_Font *font, SDL_Color color, int x, int y) +{ + SDL_Surface *surface = TTF_RenderUTF8_Blended(font, text, color); + if (!surface) return 0; + int width = surface->w; + SDL_BlitSurface(surface, NULL, screen, &(SDL_Rect){ x, y }); + SDL_FreeSurface(surface); + return width; +} + +static int textWidth(const char *text, TTF_Font *font) +{ + int w = 0, h = 0; + TTF_SizeUTF8(font, text, &w, &h); + return w; +} + +static int renderTextCentered(const char *text, TTF_Font *font, SDL_Color color, int y) +{ + return renderText(text, font, color, (screen->w - textWidth(text, font)) / 2, y); +} + +static void renderTitle(const char *name, int show_setting) +{ + int max_width = screen->w - SCALE1(PADDING * 2); + if (screen->w >= SCALE1(320)) { + int ow = GFX_blitHardwareGroup(screen, show_setting); + max_width = screen->w - SCALE1(PADDING * 2) - ow; + } + + char title[256]; + int text_width = GFX_truncateText(font.large, name, title, max_width, SCALE1(BUTTON_PADDING * 2)); + max_width = MIN(max_width, text_width); + + SDL_Surface *text = TTF_RenderUTF8_Blended(font.large, title, COLOR_WHITE); + GFX_blitPill(ASSET_BLACK_PILL, screen, &(SDL_Rect){ SCALE1(PADDING), SCALE1(PADDING), max_width, SCALE1(PILL_SIZE) }); + if (text) { + SDL_BlitSurface(text, &(SDL_Rect){ 0, 0, max_width - SCALE1(BUTTON_PADDING * 2), text->h }, screen, + &(SDL_Rect){ SCALE1(PADDING + BUTTON_PADDING), SCALE1(PADDING + 4) }); + SDL_FreeSurface(text); + } +} + +// A full width selectable row, same look as the launcher's lists. +static void renderRow(const char *label, const char *value, int row, bool selected) +{ + int x = SCALE1(PADDING); + int y = SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN) + row * SCALE1(ROW_HEIGHT); + int w = screen->w - SCALE1(PADDING * 2); + + GFX_blitPill(selected ? ASSET_WHITE_PILL : ASSET_BLACK_PILL, screen, + &(SDL_Rect){ x, y, w, SCALE1(PILL_SIZE) }); + + SDL_Color color = selected ? COLOR_BLACK : COLOR_WHITE; + renderText(label, font.medium, color, x + SCALE1(BUTTON_PADDING), y + SCALE1(4)); + if (value) { + int vw = textWidth(value, font.medium); + renderText(value, font.medium, color, x + w - SCALE1(BUTTON_PADDING) - vw, y + SCALE1(4)); + } +} + +// How many rows fit between the title and the button hints. +static int listRows(void) +{ + int top = SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN); + int bottom = screen->h - SCALE1(PILL_SIZE + PADDING * 2); + int rows = (bottom - top) / SCALE1(ROW_HEIGHT); + return rows < 1 ? 1 : rows; +} + +static void renderToast(void) +{ + if (!toast_message[0] || SDL_GetTicks() >= toast_until) return; + + int tw = textWidth(toast_message, font.medium); + int w = tw + SCALE1(BUTTON_PADDING * 2); + int x = (screen->w - w) / 2; + int y = screen->h - SCALE1(PADDING + PILL_SIZE * 2 + BUTTON_MARGIN); + + GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x, y, w, SCALE1(PILL_SIZE) }); + renderText(toast_message, font.medium, COLOR_BLACK, x + SCALE1(BUTTON_PADDING), y + SCALE1(4)); +} + +// The code entry widget, shared by the unlock screen and the change-code screen. +// Filled slots show the button name so the parent can see what they typed. +static void renderCodeSlots(const int *entered, int entered_len, int slot_count, int y) +{ + int slot_w = SCALE1(56); + int gap = SCALE1(SLOT_GAP); + int total = slot_count * slot_w + (slot_count - 1) * gap; + + // shrink the slots rather than overflow on narrow screens + int available = screen->w - SCALE1(PADDING * 2); + if (total > available) { + slot_w = (available - (slot_count - 1) * gap) / slot_count; + total = slot_count * slot_w + (slot_count - 1) * gap; + } + + int x = (screen->w - total) / 2; + for (int i = 0; i < slot_count; i++) { + bool filled = i < entered_len; + GFX_blitPill(filled ? ASSET_WHITE_PILL : ASSET_BLACK_PILL, screen, + &(SDL_Rect){ x, y, slot_w, SCALE1(PILL_SIZE) }); + if (filled) { + const char *name = CODE_BUTTONS[entered[i]].name; + char label[16]; + GFX_truncateText(font.small, name, label, slot_w, SCALE1(4)); + int lw = textWidth(label, font.small); + renderText(label, font.small, COLOR_BLACK, x + (slot_w - lw) / 2, y + SCALE1(6)); + } + x += slot_w + gap; + } +} + +// Returns the CODE_BUTTONS index that was just pressed, or -1. +static int pollCodeButton(void) +{ + for (int i = 0; i < CODE_BUTTON_COUNT; i++) + if (PAD_justPressed(CODE_BUTTONS[i].btn)) return i; + return -1; +} + +int main(int argc, char *argv[]) +{ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--gate") == 0) return modeGate(); + if (strcmp(argv[i], "--watch") == 0) return modeWatch(); + if (strcmp(argv[i], "--stop") == 0) return modeStop(); + } + + Config cfg; + configLoad(&cfg); + + InitSettings(); + PWR_setCPUSpeed(CPU_SPEED_AUTO); + + screen = GFX_init(MODE_MAIN); + PAD_init(); + PWR_init(); + + signal(SIGINT, sigHandler); + signal(SIGTERM, sigHandler); + + int state = SCREEN_HOME; + int admin_selected = ADMIN_LIMIT; + + int code_entered[MAX_CODE_LEN]; + int code_len = 0; + + // change-code screen: capture, then confirm + int new_code[MAX_CODE_LEN]; + int new_len = 0; + int confirming = 0; + + // limit editor + int edit_hours = 0; + int edit_minutes = 0; + int edit_field = FIELD_HOURS; + + // emulator exemptions, scanned when the screen is opened + Emulator emus[MAX_EMUS]; + int emu_count = 0; + int emu_selected = 0; + int emu_top = 0; + + int played = secondsPlayedToday(); + uint32_t refreshed_at = SDL_GetTicks(); + + int dirty = 1; + int show_setting = 0; + while (!quit) { + GFX_startFrame(); + PAD_poll(); + + // This might be too harsh, but ignore all combos with MENU (most likely a shortcut for someone else) + if (PAD_justPressed(BTN_MENU)) { + // ? + } + else switch (state) { + + case SCREEN_HOME: + if (PAD_justPressed(BTN_A)) { + code_len = 0; + state = SCREEN_CODE; + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + quit = true; + } + break; + + case SCREEN_CODE: + if (PAD_justPressed(BTN_B)) { + state = SCREEN_HOME; + dirty = 1; + } + else { + int pressed = pollCodeButton(); + if (pressed >= 0 && code_len < cfg.code_len) { + code_entered[code_len++] = pressed; + dirty = 1; + + if (code_len == cfg.code_len) { + if (hashCode(code_entered, code_len) == cfg.code_hash) { + admin_selected = ADMIN_LIMIT; + state = SCREEN_ADMIN; + } + else { + toast("Wrong code"); + code_len = 0; + } + } + } + } + break; + + case SCREEN_ADMIN: + if (PAD_justRepeated(BTN_UP)) { + admin_selected = (admin_selected - 1 + ADMIN_COUNT) % ADMIN_COUNT; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + admin_selected = (admin_selected + 1) % ADMIN_COUNT; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + if (admin_selected == ADMIN_LIMIT) { + edit_hours = cfg.limit_minutes / 60; + edit_minutes = cfg.limit_minutes % 60; + edit_field = FIELD_HOURS; + state = SCREEN_LIMIT; + } + else if (admin_selected == ADMIN_EXEMPT) { + emu_count = scanEmulators(emus); + emu_selected = 0; + emu_top = 0; + state = SCREEN_EXEMPT; + } + else { + new_len = 0; + code_len = 0; + confirming = 0; + state = SCREEN_NEWCODE; + } + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + state = SCREEN_HOME; + dirty = 1; + } + break; + + case SCREEN_LIMIT: + if (PAD_justRepeated(BTN_LEFT) || PAD_justRepeated(BTN_RIGHT)) { + edit_field = (edit_field + 1) % FIELD_COUNT; + dirty = 1; + } + else if (PAD_justRepeated(BTN_UP)) { + if (edit_field == FIELD_HOURS) edit_hours = (edit_hours + 1) % 24; + else edit_minutes = (edit_minutes + 5) % 60; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + if (edit_field == FIELD_HOURS) edit_hours = (edit_hours + 23) % 24; + else edit_minutes = (edit_minutes + 55) % 60; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + cfg.limit_minutes = edit_hours * 60 + edit_minutes; + configSave(&cfg); + toast(cfg.limit_minutes > 0 ? "Limit saved" : "Limit disabled"); + state = SCREEN_ADMIN; + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + state = SCREEN_ADMIN; + dirty = 1; + } + break; + + case SCREEN_EXEMPT: { + int rows = listRows(); + + if (PAD_justRepeated(BTN_UP) && emu_count > 0) { + emu_selected = (emu_selected - 1 + emu_count) % emu_count; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN) && emu_count > 0) { + emu_selected = (emu_selected + 1) % emu_count; + dirty = 1; + } + else if (PAD_justPressed(BTN_A) && emu_count > 0) { + const char *name = emus[emu_selected].name; + if (isExempt(&cfg, name)) exemptRemove(&cfg, name); + else if (!exemptAdd(&cfg, name)) toast("Too many exemptions"); + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + configSave(&cfg); + state = SCREEN_ADMIN; + dirty = 1; + } + + // keep the selection inside the visible window + if (emu_selected < emu_top) emu_top = emu_selected; + if (emu_selected >= emu_top + rows) emu_top = emu_selected - rows + 1; + break; + } + + case SCREEN_NEWCODE: + if (PAD_justPressed(BTN_B)) { + state = SCREEN_ADMIN; + dirty = 1; + } + else if (PAD_justPressed(BTN_START)) { + if (!confirming) { + if (new_len < MIN_CODE_LEN) { + toast("Too short"); + } + else { + confirming = 1; + code_len = 0; + } + } + else { + if (code_len == new_len && memcmp(code_entered, new_code, new_len * sizeof(int)) == 0) { + cfg.code_hash = hashCode(new_code, new_len); + cfg.code_len = new_len; + configSave(&cfg); + toast("Code updated"); + state = SCREEN_ADMIN; + } + else { + toast("Codes differ"); + confirming = 0; + new_len = 0; + code_len = 0; + } + } + dirty = 1; + } + else { + int pressed = pollCodeButton(); + if (pressed >= 0) { + if (!confirming) { + if (new_len < MAX_CODE_LEN) new_code[new_len++] = pressed; + } + else { + if (code_len < MAX_CODE_LEN) code_entered[code_len++] = pressed; + } + dirty = 1; + } + } + break; + } + + PWR_update(&dirty, &show_setting, NULL, NULL); + + // the played total moves on its own while a session is open elsewhere + uint32_t now = SDL_GetTicks(); + if (now - refreshed_at >= 1000) { + refreshed_at = now; + int fresh = secondsPlayedToday(); + if (fresh != played) { + played = fresh; + if (state == SCREEN_HOME) dirty = 1; + } + } + + if (toast_message[0]) { + if (now >= toast_until) toast_message[0] = '\0'; + dirty = 1; + } + + if (dirty) { + GFX_clear(screen); + renderTitle("Parental", show_setting); + + int content_y = SCALE1(PADDING + PILL_SIZE + BUTTON_MARGIN); + + switch (state) { + + case SCREEN_HOME: { + char buffer[64]; + char formatted[25]; + + // from the cached total, this runs on every redrawn frame + int remaining = remainingFrom(&cfg, played); + if (remaining < 0) renderTextCentered("No limit", font.large, COLOR_WHITE, content_y + SCALE1(16)); + else { + serializeTime(formatted, remaining); + snprintf(buffer, sizeof(buffer), "%s left today", formatted); + renderTextCentered(buffer, font.large, remaining > 0 ? COLOR_WHITE : COLOR_LIGHT_TEXT, content_y + SCALE1(16)); + } + + serializeTime(formatted, played); + snprintf(buffer, sizeof(buffer), "Played today %s", formatted); + renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(44)); + + if (cfg.limit_minutes > 0) { + serializeTime(formatted, cfg.limit_minutes * 60); + snprintf(buffer, sizeof(buffer), "Daily limit %s", formatted); + renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(60)); + } + + renderRow("Restricted Access", NULL, 3, true); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + else GFX_blitButtonGroup((char *[]){ "A", "ENTER", NULL }, 0, screen, 0); + GFX_blitButtonGroup((char *[]){ "B", "EXIT", NULL }, 1, screen, 1); + break; + } + + case SCREEN_CODE: + renderTextCentered("Enter code", font.medium, COLOR_WHITE, content_y + SCALE1(10)); + renderCodeSlots(code_entered, code_len, cfg.code_len, content_y + SCALE1(40)); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + GFX_blitButtonGroup((char *[]){ "B", "BACK", NULL }, 1, screen, 1); + break; + + case SCREEN_ADMIN: { + char limit[25]; + if (cfg.limit_minutes > 0) serializeTime(limit, cfg.limit_minutes * 60); + else snprintf(limit, sizeof(limit), "Off"); + + renderRow("Daily limit", limit, 0, admin_selected == ADMIN_LIMIT); + renderRow("Emulators", cfg.exempt[0] ? "Some allowed" : "All blocked", 1, admin_selected == ADMIN_EXEMPT); + renderRow("Change code", NULL, 2, admin_selected == ADMIN_CODE); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + else GFX_blitButtonGroup((char *[]){ "U/D", "SELECT", "A", "EDIT", NULL }, 0, screen, 0); + GFX_blitButtonGroup((char *[]){ "B", "BACK", NULL }, 1, screen, 1); + break; + } + + case SCREEN_LIMIT: { + renderTextCentered("Daily limit", font.medium, COLOR_WHITE, content_y + SCALE1(10)); + + char hours[8], minutes[8]; + snprintf(hours, sizeof(hours), "%02dh", edit_hours); + snprintf(minutes, sizeof(minutes), "%02dm", edit_minutes); + + int hw = textWidth(hours, font.large); + int mw = textWidth(minutes, font.large); + int gap = SCALE1(16); + int x = (screen->w - (hw + gap + mw)) / 2; + int y = content_y + SCALE1(44); + + renderText(hours, font.large, COLOR_WHITE, x, y); + renderText(minutes, font.large, COLOR_WHITE, x + hw + gap, y); + + // underline the field the d-pad is acting on, like Clock.pak + int uy = y + SCALE1(FONT_LARGE + 6); + if (edit_field == FIELD_HOURS) + GFX_blitPill(ASSET_UNDERLINE, screen, &(SDL_Rect){ x, uy, hw }); + else + GFX_blitPill(ASSET_UNDERLINE, screen, &(SDL_Rect){ x + hw + gap, uy, mw }); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + else GFX_blitButtonGroup((char *[]){ "L/R", "FIELD", "U/D", "ADJUST", NULL }, 0, screen, 0); + GFX_blitButtonGroup((char *[]){ "A", "SAVE", "B", "BACK", NULL }, 1, screen, 1); + break; + } + + case SCREEN_EXEMPT: { + if (emu_count == 0) { + renderTextCentered("No emulators found", font.medium, COLOR_DARK_TEXT, content_y + SCALE1(16)); + } + else { + int rows = listRows(); + for (int i = 0; i < rows && emu_top + i < emu_count; i++) { + int idx = emu_top + i; + // the pak suffix is noise once they are all listed together + char label[MAX_PAK_NAME]; + snprintf(label, sizeof(label), "%.*s", + (int)(strlen(emus[idx].name) - 4), emus[idx].name); + renderRow(label, isExempt(&cfg, emus[idx].name) ? "Allowed" : "Blocked", + i, idx == emu_selected); + } + } + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + else GFX_blitButtonGroup((char *[]){ "U/D", "SELECT", "A", "TOGGLE", NULL }, 0, screen, 0); + GFX_blitButtonGroup((char *[]){ "B", "SAVE", NULL }, 1, screen, 1); + break; + } + + case SCREEN_NEWCODE: + renderTextCentered(confirming ? "Repeat new code" : "Enter new code", font.medium, COLOR_WHITE, content_y + SCALE1(10)); + if (confirming) renderCodeSlots(code_entered, code_len, new_len, content_y + SCALE1(40)); + else renderCodeSlots(new_code, new_len, MAX(new_len + 1, MIN_CODE_LEN), content_y + SCALE1(40)); + + renderTextCentered("START when done", font.small, COLOR_DARK_TEXT, content_y + SCALE1(80)); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + GFX_blitButtonGroup((char *[]){ "B", "BACK", NULL }, 1, screen, 1); + break; + } + + renderToast(); + + GFX_flip(screen); + dirty = 0; + } + else + GFX_sync(); + } + + QuitSettings(); + PWR_quit(); + PAD_quit(); + GFX_quit(); + + return EXIT_SUCCESS; +} diff --git a/workspace/makefile b/workspace/makefile index f71af4d62..0cef9f7f9 100644 --- a/workspace/makefile +++ b/workspace/makefile @@ -47,6 +47,7 @@ endif cd ./all/libgametimedb/ && make cd ./all/gametimectl/ && make cd ./all/gametime/ && make + cd ./all/parental/ && make cd ./all/minput/ && make cd ./all/syncsettings/ && make cd ./all/nextval/ && make @@ -95,6 +96,7 @@ endif cd ./all/libgametimedb/ && make clean cd ./all/gametimectl/ && make clean cd ./all/gametime/ && make clean + cd ./all/parental/ && make clean cd ./all/minput/ && make clean cd ./all/nextval/ && make clean cd ./all/settings/ && make clean From 00f9fad9090b06ca40055bbf5a5a99be7c38732c Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 17 Aug 2026 22:17:19 +0200 Subject: [PATCH 2/7] feat(parental): show remaining budget as a progress bar Adds a track+fill bar under "X left today" on the home screen, filled proportionally to remaining/limit. Confirmed and documented that the bar doesn't move just from having the screen open: opening a Tools pak never writes to the play-time DB the remaining-time calc reads from (only real rom launches do, via gametimectl.elf start). --- workspace/all/parental/parental.c | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c index 4c5ef0982..918169c83 100644 --- a/workspace/all/parental/parental.c +++ b/workspace/all/parental/parental.c @@ -501,6 +501,23 @@ static void renderRow(const char *label, const char *value, int row, bool select } } +// A track + fill bar, same width as renderRow. fraction is clamped to [0,1]; +// the fill never drops below a full circle so it stays visible near empty. +static void renderProgressBar(int y, float fraction) +{ + int x = SCALE1(PADDING); + int w = screen->w - SCALE1(PADDING * 2); + int h = SCALE1(10); + + if (fraction < 0) fraction = 0; + if (fraction > 1) fraction = 1; + + GFX_blitPill(ASSET_BLACK_PILL, screen, &(SDL_Rect){ x, y, w, h }); + + int fill_w = (int)(w * fraction); + if (fill_w > 0) GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x, y, fill_w, h }); +} + // How many rows fit between the title and the button hints. static int listRows(void) { @@ -826,23 +843,27 @@ int main(int argc, char *argv[]) char buffer[64]; char formatted[25]; - // from the cached total, this runs on every redrawn frame + // from the cached total (see the "played" refresh above), so this + // bar does not move just from having the screen open int remaining = remainingFrom(&cfg, played); if (remaining < 0) renderTextCentered("No limit", font.large, COLOR_WHITE, content_y + SCALE1(16)); else { serializeTime(formatted, remaining); snprintf(buffer, sizeof(buffer), "%s left today", formatted); renderTextCentered(buffer, font.large, remaining > 0 ? COLOR_WHITE : COLOR_LIGHT_TEXT, content_y + SCALE1(16)); + + float fraction = cfg.limit_minutes > 0 ? (float)remaining / (cfg.limit_minutes * 60) : 0; + renderProgressBar(content_y + SCALE1(46), fraction); } serializeTime(formatted, played); snprintf(buffer, sizeof(buffer), "Played today %s", formatted); - renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(44)); + renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(66)); if (cfg.limit_minutes > 0) { serializeTime(formatted, cfg.limit_minutes * 60); snprintf(buffer, sizeof(buffer), "Daily limit %s", formatted); - renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(60)); + renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(82)); } renderRow("Restricted Access", NULL, 3, true); From 5e7e8822d3f387577719509d39ed5aacfebf234b Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 17 Aug 2026 22:25:36 +0200 Subject: [PATCH 3/7] feat(parental): add a same-day extra time grant to the admin menu Adds an "Extra time" entry next to Daily limit: same hours/minutes editor, but the amount only counts for the calendar day it was granted on (extra_day tracks which day, so it stops applying once the day rolls over, no reset job needed). Folded into remainingFrom() so the gate/watch enforcement and the home screen (including the progress bar's fraction) all pick it up automatically. --- workspace/all/parental/parental.c | 112 ++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c index 918169c83..70d46c531 100644 --- a/workspace/all/parental/parental.c +++ b/workspace/all/parental/parental.c @@ -60,6 +60,9 @@ typedef struct { unsigned long code_hash; int code_len; char exempt[EXEMPT_MAX]; + int extra_minutes; // one-off top-up on top of limit_minutes + int extra_day; // startOfToday() when extra_minutes was granted; a + // stale (non-matching) value means it has expired } Config; // Buttons a code can be made of. B is deliberately absent: it always means @@ -110,6 +113,8 @@ static void configSave(const Config *cfg) fprintf(file, "code_hash=%lu\n", cfg->code_hash); fprintf(file, "code_len=%d\n", cfg->code_len); fprintf(file, "exempt=%s\n", cfg->exempt); + fprintf(file, "extra_minutes=%d\n", cfg->extra_minutes); + fprintf(file, "extra_day=%d\n", cfg->extra_day); fclose(file); sync(); } @@ -121,6 +126,8 @@ static void configLoad(Config *cfg) cfg->code_hash = hashCode(DEFAULT_CODE, DEFAULT_CODE_LEN); cfg->code_len = DEFAULT_CODE_LEN; cfg->exempt[0] = '\0'; + cfg->extra_minutes = 0; + cfg->extra_day = 0; FILE *file = fopen(PARENTAL_CFG_PATH, "r"); if (!file) { @@ -147,6 +154,8 @@ static void configLoad(Config *cfg) strncpy(cfg->exempt, value, EXEMPT_MAX - 1); cfg->exempt[EXEMPT_MAX - 1] = '\0'; } + else if (strcmp(key, "extra_minutes") == 0) cfg->extra_minutes = atoi(value); + else if (strcmp(key, "extra_day") == 0) cfg->extra_day = atoi(value); } fclose(file); @@ -282,11 +291,19 @@ static int secondsPlayedToday(void) return play_activity_get_play_time_since(startOfToday()); } +// Extra minutes granted for today, or 0 once the day has moved on. Expires +// on its own -- a stale extra_day just stops counting, nothing to clean up. +static int extraMinutesToday(const Config *cfg) +{ + return cfg->extra_day == startOfToday() ? cfg->extra_minutes : 0; +} + // Seconds left today, or -1 when there is no limit. static int remainingFrom(const Config *cfg, int played) { if (cfg->limit_minutes <= 0) return -1; - int remaining = cfg->limit_minutes * 60 - played; + int budget_minutes = cfg->limit_minutes + extraMinutesToday(cfg); + int remaining = budget_minutes * 60 - played; return remaining < 0 ? 0 : remaining; } @@ -394,12 +411,14 @@ enum { SCREEN_CODE, SCREEN_ADMIN, SCREEN_LIMIT, + SCREEN_EXTRA, SCREEN_NEWCODE, SCREEN_EXEMPT, }; enum { ADMIN_LIMIT, + ADMIN_EXTRA, ADMIN_EXEMPT, ADMIN_CODE, ADMIN_COUNT, @@ -689,6 +708,13 @@ int main(int argc, char *argv[]) edit_field = FIELD_HOURS; state = SCREEN_LIMIT; } + else if (admin_selected == ADMIN_EXTRA) { + int extra = extraMinutesToday(&cfg); + edit_hours = extra / 60; + edit_minutes = extra % 60; + edit_field = FIELD_HOURS; + state = SCREEN_EXTRA; + } else if (admin_selected == ADMIN_EXEMPT) { emu_count = scanEmulators(emus); emu_selected = 0; @@ -737,6 +763,35 @@ int main(int argc, char *argv[]) } break; + case SCREEN_EXTRA: + if (PAD_justRepeated(BTN_LEFT) || PAD_justRepeated(BTN_RIGHT)) { + edit_field = (edit_field + 1) % FIELD_COUNT; + dirty = 1; + } + else if (PAD_justRepeated(BTN_UP)) { + if (edit_field == FIELD_HOURS) edit_hours = (edit_hours + 1) % 24; + else edit_minutes = (edit_minutes + 5) % 60; + dirty = 1; + } + else if (PAD_justRepeated(BTN_DOWN)) { + if (edit_field == FIELD_HOURS) edit_hours = (edit_hours + 23) % 24; + else edit_minutes = (edit_minutes + 55) % 60; + dirty = 1; + } + else if (PAD_justPressed(BTN_A)) { + cfg.extra_minutes = edit_hours * 60 + edit_minutes; + cfg.extra_day = startOfToday(); + configSave(&cfg); + toast(cfg.extra_minutes > 0 ? "Extra time added" : "Extra time cleared"); + state = SCREEN_ADMIN; + dirty = 1; + } + else if (PAD_justPressed(BTN_B)) { + state = SCREEN_ADMIN; + dirty = 1; + } + break; + case SCREEN_EXEMPT: { int rows = listRows(); @@ -845,6 +900,9 @@ int main(int argc, char *argv[]) // from the cached total (see the "played" refresh above), so this // bar does not move just from having the screen open + int extra_today = extraMinutesToday(&cfg); + int budget_minutes = cfg.limit_minutes + extra_today; + int remaining = remainingFrom(&cfg, played); if (remaining < 0) renderTextCentered("No limit", font.large, COLOR_WHITE, content_y + SCALE1(16)); else { @@ -852,7 +910,7 @@ int main(int argc, char *argv[]) snprintf(buffer, sizeof(buffer), "%s left today", formatted); renderTextCentered(buffer, font.large, remaining > 0 ? COLOR_WHITE : COLOR_LIGHT_TEXT, content_y + SCALE1(16)); - float fraction = cfg.limit_minutes > 0 ? (float)remaining / (cfg.limit_minutes * 60) : 0; + float fraction = budget_minutes > 0 ? (float)remaining / (budget_minutes * 60) : 0; renderProgressBar(content_y + SCALE1(46), fraction); } @@ -861,8 +919,14 @@ int main(int argc, char *argv[]) renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(66)); if (cfg.limit_minutes > 0) { - serializeTime(formatted, cfg.limit_minutes * 60); - snprintf(buffer, sizeof(buffer), "Daily limit %s", formatted); + char limit[25], extra[32] = { 0 }; + serializeTime(limit, cfg.limit_minutes * 60); + if (extra_today > 0) { + char extra_time[25]; + serializeTime(extra_time, extra_today * 60); + snprintf(extra, sizeof(extra), " (+%s today)", extra_time); + } + snprintf(buffer, sizeof(buffer), "Daily limit %s%s", limit, extra); renderTextCentered(buffer, font.small, COLOR_DARK_TEXT, content_y + SCALE1(82)); } @@ -887,9 +951,15 @@ int main(int argc, char *argv[]) if (cfg.limit_minutes > 0) serializeTime(limit, cfg.limit_minutes * 60); else snprintf(limit, sizeof(limit), "Off"); + char extra[25]; + int extra_today = extraMinutesToday(&cfg); + if (extra_today > 0) serializeTime(extra, extra_today * 60); + else snprintf(extra, sizeof(extra), "None"); + renderRow("Daily limit", limit, 0, admin_selected == ADMIN_LIMIT); - renderRow("Emulators", cfg.exempt[0] ? "Some allowed" : "All blocked", 1, admin_selected == ADMIN_EXEMPT); - renderRow("Change code", NULL, 2, admin_selected == ADMIN_CODE); + renderRow("Extra time", extra, 1, admin_selected == ADMIN_EXTRA); + renderRow("Emulators", cfg.exempt[0] ? "Some allowed" : "All blocked", 2, admin_selected == ADMIN_EXEMPT); + renderRow("Change code", NULL, 3, admin_selected == ADMIN_CODE); if (show_setting) GFX_blitHardwareHints(screen, show_setting); else GFX_blitButtonGroup((char *[]){ "U/D", "SELECT", "A", "EDIT", NULL }, 0, screen, 0); @@ -926,6 +996,36 @@ int main(int argc, char *argv[]) break; } + case SCREEN_EXTRA: { + renderTextCentered("Extra time today", font.medium, COLOR_WHITE, content_y + SCALE1(10)); + + char hours[8], minutes[8]; + snprintf(hours, sizeof(hours), "%02dh", edit_hours); + snprintf(minutes, sizeof(minutes), "%02dm", edit_minutes); + + int hw = textWidth(hours, font.large); + int mw = textWidth(minutes, font.large); + int gap = SCALE1(16); + int x = (screen->w - (hw + gap + mw)) / 2; + int y = content_y + SCALE1(44); + + renderText(hours, font.large, COLOR_WHITE, x, y); + renderText(minutes, font.large, COLOR_WHITE, x + hw + gap, y); + + int uy = y + SCALE1(FONT_LARGE + 6); + if (edit_field == FIELD_HOURS) + GFX_blitPill(ASSET_UNDERLINE, screen, &(SDL_Rect){ x, uy, hw }); + else + GFX_blitPill(ASSET_UNDERLINE, screen, &(SDL_Rect){ x + hw + gap, uy, mw }); + + renderTextCentered("Resets at midnight", font.small, COLOR_DARK_TEXT, content_y + SCALE1(80)); + + if (show_setting) GFX_blitHardwareHints(screen, show_setting); + else GFX_blitButtonGroup((char *[]){ "L/R", "FIELD", "U/D", "ADJUST", NULL }, 0, screen, 0); + GFX_blitButtonGroup((char *[]){ "A", "SAVE", "B", "BACK", NULL }, 1, screen, 1); + break; + } + case SCREEN_EXEMPT: { if (emu_count == 0) { renderTextCentered("No emulators found", font.medium, COLOR_DARK_TEXT, content_y + SCALE1(16)); From eca99b3a6921db14d9b22dfeebfaed1a22a461b6 Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 17 Aug 2026 23:26:57 +0200 Subject: [PATCH 4/7] fix(parental): stop treating zero-length sessions as still running play_activity_get_play_time_since() folded play_time <= 0 into the same branch as play_time IS NULL (its "session still in progress" case), so a session that legitimately lasted 0 seconds (launch, immediately back out) got counted as still running and its contribution grew unbounded for the rest of the day. Confirmed on device: a single such row inflated "played today" to ~8 hours and would have blocked further launches even though the real daily budget wasn't spent, since --gate/--watch use this same aggregate. --- workspace/all/libgametimedb/gametimedb.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/workspace/all/libgametimedb/gametimedb.c b/workspace/all/libgametimedb/gametimedb.c index eb3261eba..26686bd2e 100644 --- a/workspace/all/libgametimedb/gametimedb.c +++ b/workspace/all/libgametimedb/gametimedb.c @@ -143,10 +143,13 @@ int play_activity_get_play_time_since(int since_epoch) int play_time = 0; // play_time is only written when a session is stopped, so the session that // is running right now still has it NULL -- fall back to the elapsed time - // since created_at for those, otherwise an in-progress session counts as 0. + // since created_at for that one. A *stopped* session can legitimately have + // play_time = 0 (launched then immediately backed out), so don't fold that + // into the same branch -- it would be misread as still running and grow + // unbounded for the rest of the day. char *sql = "SELECT COALESCE(SUM(" - " CASE WHEN play_time IS NULL OR play_time <= 0 " + " CASE WHEN play_time IS NULL " " THEN strftime('%s', 'now') - created_at " " ELSE play_time END" "), 0) FROM play_activity WHERE created_at >= ?;"; From 6f992a856f1707406a411701b961867e9d92d7ab Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Mon, 17 Aug 2026 23:40:54 +0200 Subject: [PATCH 5/7] fix(parental): give the home-screen budget bar a real stroked track GFX_blitPill always draws a complete rounded pill (both ends), it can't clip a partial one, so the previous fill was its own free-floating capsule redrawn flush over a solid black track -- invisible against the black screen background, giving no visible container and an oddly-rounded fill edge at partial values. Draw the track as a 1px stroke (outer white pill, black pill punched 1px inside it) and inset the fill 2px further inside that stroke instead. --- workspace/all/parental/parental.c | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c index 70d46c531..b557f1bcc 100644 --- a/workspace/all/parental/parental.c +++ b/workspace/all/parental/parental.c @@ -522,6 +522,12 @@ static void renderRow(const char *label, const char *value, int row, bool select // A track + fill bar, same width as renderRow. fraction is clamped to [0,1]; // the fill never drops below a full circle so it stays visible near empty. +// +// GFX_blitPill always draws a complete rounded pill (both ends), it doesn't +// clip a partial one -- so the track is drawn as a 1px stroke (outer pill, +// then a same-shaped black pill inset by 1px punched back on top of it) and +// the fill is a separate, smaller pill inset 2px further inside that stroke, +// rather than a fill pill drawn flush over a solid track. static void renderProgressBar(int y, float fraction) { int x = SCALE1(PADDING); @@ -531,10 +537,15 @@ static void renderProgressBar(int y, float fraction) if (fraction < 0) fraction = 0; if (fraction > 1) fraction = 1; - GFX_blitPill(ASSET_BLACK_PILL, screen, &(SDL_Rect){ x, y, w, h }); + int stroke = SCALE1(1); + int inset = stroke + SCALE1(2); // stroke + margin, from the outer edge to the fill + + GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x, y, w, h }); + GFX_blitPill(ASSET_BLACK_PILL, screen, &(SDL_Rect){ x + stroke, y + stroke, w - stroke * 2, h - stroke * 2 }); - int fill_w = (int)(w * fraction); - if (fill_w > 0) GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x, y, fill_w, h }); + int fill_max_w = w - inset * 2; + int fill_w = (int)(fill_max_w * fraction); + if (fill_w > 0) GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x + inset, y + inset, fill_w, h - inset * 2 }); } // How many rows fit between the title and the button hints. From 56a10c5470a5fb372f003e7b6894c4d13b24d197 Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Tue, 18 Aug 2026 16:45:44 +0200 Subject: [PATCH 6/7] fix(parental): plain-rectangle budget bar, and stop counting stale sessions live The rounded-pill bar went through a few iterations (unscaled GFX_blitPill caps crop into a wedge below PILL_SIZE height; naive whole-pill scaling squashes them into ellipses instead; even a proper isotropic circle-asset scale stayed too subtle to read at this bar's height) before settling on what was actually asked for: a plain rectangular 1px stroke with the fill inset 2px inside it, with extra horizontal margin so the stroke isn't flush against the screen edge. Also: the home screen re-polls play time every second, intended for a session "open elsewhere" -- but only one thing runs in the foreground at a time on this device, so nothing legitimate can still be open while Parental.pak itself is running. What was actually ticking was a stale play_activity row nextui.elf never got to close (that sweep only runs when its own launcher reinitializes, not when a Tools pak is opened directly). Sweep it once at startup so "Played today" is a real, static number. --- workspace/all/parental/parental.c | 33 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c index b557f1bcc..23aafa614 100644 --- a/workspace/all/parental/parental.c +++ b/workspace/all/parental/parental.c @@ -520,19 +520,18 @@ static void renderRow(const char *label, const char *value, int row, bool select } } -// A track + fill bar, same width as renderRow. fraction is clamped to [0,1]; -// the fill never drops below a full circle so it stays visible near empty. +// A track + fill bar: a plain rectangular 1px stroke (outer white rect, +// black rect inset 1px punched back on top of it) with the fill inset 2px +// further inside that stroke. fraction is clamped to [0,1]. // -// GFX_blitPill always draws a complete rounded pill (both ends), it doesn't -// clip a partial one -- so the track is drawn as a 1px stroke (outer pill, -// then a same-shaped black pill inset by 1px punched back on top of it) and -// the fill is a separate, smaller pill inset 2px further inside that stroke, -// rather than a fill pill drawn flush over a solid track. +// Narrower than renderRow's full-width pills -- right up against the +// screen edge (same margin as the rows) the stroke has no breathing room. static void renderProgressBar(int y, float fraction) { - int x = SCALE1(PADDING); - int w = screen->w - SCALE1(PADDING * 2); - int h = SCALE1(10); + int bar_margin = SCALE1(PADDING * 2); + int x = bar_margin; + int w = screen->w - bar_margin * 2; + int h = SCALE1(14); if (fraction < 0) fraction = 0; if (fraction > 1) fraction = 1; @@ -540,12 +539,12 @@ static void renderProgressBar(int y, float fraction) int stroke = SCALE1(1); int inset = stroke + SCALE1(2); // stroke + margin, from the outer edge to the fill - GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x, y, w, h }); - GFX_blitPill(ASSET_BLACK_PILL, screen, &(SDL_Rect){ x + stroke, y + stroke, w - stroke * 2, h - stroke * 2 }); + SDL_FillRect(screen, &(SDL_Rect){ x, y, w, h }, RGB_WHITE); + SDL_FillRect(screen, &(SDL_Rect){ x + stroke, y + stroke, w - stroke * 2, h - stroke * 2 }, RGB_BLACK); int fill_max_w = w - inset * 2; int fill_w = (int)(fill_max_w * fraction); - if (fill_w > 0) GFX_blitPill(ASSET_WHITE_PILL, screen, &(SDL_Rect){ x + inset, y + inset, fill_w, h - inset * 2 }); + if (fill_w > 0) SDL_FillRect(screen, &(SDL_Rect){ x + inset, y + inset, fill_w, h - inset * 2 }, RGB_WHITE); } // How many rows fit between the title and the button hints. @@ -652,6 +651,14 @@ int main(int argc, char *argv[]) int emu_selected = 0; int emu_top = 0; + // Only one thing runs in the foreground at a time on this device, so no + // game session can legitimately still be "open" while we're the ones + // running -- if one is, it's a row nextui.elf never got to close (that + // only happens when its own launcher reinitializes, not when a Tools + // pak like this one is opened directly). Sweep it so the total below is + // a real, static number instead of ticking up live from a stale row. + play_activity_stop_all(); + int played = secondsPlayedToday(); uint32_t refreshed_at = SDL_GetTicks(); From 02657f315d5485a65cb62f5b52c211212dc72a0e Mon Sep 17 00:00:00 2001 From: Dalexanco Date: Tue, 18 Aug 2026 23:22:48 +0200 Subject: [PATCH 7/7] feat(leds): blink LEDs + toast in the final minute before a forced stop Adds LIGHT_PROFILE_APP_WARNING (blinks the LEDs in the user's configured main color, api.c/api.h) and generalizes minarch's existing NOTIFY_PATH/ SIGUSR2 channel to support it: a message can now be followed by a "led=1" line to also push the profile override, popped on SIGUSR1 (stop). This is deliberately not parental-specific -- any external process already using NOTIFY_PATH to toast a message over the running game can opt into the LED blink the same way, the same way minarch itself performs the UI-affecting action rather than the external process reaching into LED state it doesn't own. Parental.pak's --watch now sends this at 60s remaining ("1 minute left"), in addition to the existing warn_minutes-out notice. --- workspace/all/common/api.c | 10 ++++++++++ workspace/all/common/api.h | 1 + workspace/all/minarch/minarch.c | 32 +++++++++++++++++++++++++------ workspace/all/parental/parental.c | 9 +++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index 19f3f5439..cd7081dd7 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -73,6 +73,7 @@ LightSettings lightsLowBattery[MAX_LIGHTS]; LightSettings lightsCriticalBattery[MAX_LIGHTS]; LightSettings lightsSleep[MAX_LIGHTS]; LightSettings lightsAmbient[MAX_LIGHTS]; +LightSettings lightsAppWarning[MAX_LIGHTS]; LightSettings (*lights)[MAX_LIGHTS] = NULL; #define PROFILE_OVERRIDE_SIZE 4 @@ -4478,6 +4479,9 @@ void LEDS_setProfile(int profile) new_lights = lightsAmbient; indicator = false; break; + case LIGHT_PROFILE_APP_WARNING: + new_lights = lightsAppWarning; + break; default: return; } @@ -4609,6 +4613,12 @@ void LEDS_initLeds() // LIGHT_PROFILE_AMBIENT // just to have sensible defaults, will be updated by GFX_setAmbientColor lightsAmbient[i] = lightsDefault[i]; + + // LIGHT_PROFILE_APP_WARNING + lightsAppWarning[i] = lightsDefault[i]; + lightsAppWarning[i].effect = 3; // blink + lightsAppWarning[i].color1 = CFG_getColor(COLOR_MAIN); + lightsAppWarning[i].cycles = -1; // infinite } // this might be called more than once (for reasons), so reset to consistent state diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index 828af8271..f6bd99e8f 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -595,6 +595,7 @@ enum LightProfile { LIGHT_PROFILE_CHARGING = 4, // derived from default LIGHT_PROFILE_SLEEP = 5, // sleep mode LIGHT_PROFILE_AMBIENT = 6, // ambient mode + LIGHT_PROFILE_APP_WARNING = 7, // any app warning the user of an imminent, forced action (blinks the main color) LIGHT_PROFILE_COUNT }; diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 3b4445b4d..75165da8c 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -114,13 +114,18 @@ static void Special_quit(void) { // // SIGUSR1: save and quit, as if the player had put the device to sleep and // then exited. SIGUSR2: show the message left in NOTIFY_PATH over the -// running game. Handlers only raise a flag: the work happens once per -// frame on the main thread in sigmon(), because neither -// Menu_beforeSleep() nor Notification_push() is async-signal-safe -// (and Notification_push() mutates its queue without a mutex). +// running game -- optionally followed by a "\nled=1" line, which +// also blinks the LEDs (LIGHT_PROFILE_APP_WARNING, main theme +// color) for as long as the game keeps running, since NOTIFY_PATH +// is meant for any external process, not just Parental.pak. +// Handlers only raise a flag: the work happens once per frame on +// the main thread in sigmon(), because neither Menu_beforeSleep() +// nor Notification_push() is async-signal-safe (and +// Notification_push() mutates its queue without a mutex). static volatile sig_atomic_t sig_stop_requested = 0; static volatile sig_atomic_t sig_notify_requested = 0; +static bool led_warning_active = false; static void sigHandler(int sig) { switch (sig) { @@ -134,9 +139,19 @@ static void sigmon(void) { if (sig_notify_requested) { sig_notify_requested = 0; if (exists(NOTIFY_PATH)) { - char msg[NOTIFICATION_MAX_MESSAGE]; - getFile(NOTIFY_PATH, msg, sizeof(msg)); + char raw[NOTIFICATION_MAX_MESSAGE + 16]; + getFile(NOTIFY_PATH, raw, sizeof(raw)); unlink(NOTIFY_PATH); + + char *directive = strstr(raw, "\nled=1"); + if (directive) { + *directive = '\0'; // cut the directive off before it reaches the toast + if (!led_warning_active) led_warning_active = LEDS_pushProfileOverride(LIGHT_PROFILE_APP_WARNING); + } + + char msg[NOTIFICATION_MAX_MESSAGE]; + strncpy(msg, raw, sizeof(msg) - 1); + msg[sizeof(msg) - 1] = '\0'; trimTrailingNewlines(msg); if (msg[0]) Notification_push(NOTIFICATION_SYSTEM, msg, NULL); } @@ -145,6 +160,11 @@ static void sigmon(void) { if (sig_stop_requested) { sig_stop_requested = 0; + if (led_warning_active) { + LEDS_popProfileOverride(LIGHT_PROFILE_APP_WARNING); + led_warning_active = false; + } + LOG_info("stopping on SIGUSR1...\n"); Menu_beforeSleep(); show_menu = 0; diff --git a/workspace/all/parental/parental.c b/workspace/all/parental/parental.c index 23aafa614..de2fb3459 100644 --- a/workspace/all/parental/parental.c +++ b/workspace/all/parental/parental.c @@ -362,6 +362,7 @@ static int modeWatch(void) putInt(PARENTAL_PID_PATH, (int)getpid()); int warned = 0; + int warned_final_minute = 0; while (1) { sleep(WATCH_INTERVAL_SEC); @@ -387,6 +388,14 @@ static int modeWatch(void) system("killall -USR2 minarch.elf"); warned = 1; } + + // last-minute heads up: same notify path, plus the led=1 directive so + // minarch also blinks (LIGHT_PROFILE_APP_WARNING) for the final stretch + if (!warned_final_minute && remaining <= 60) { + putFile(NOTIFY_PATH, "1 minute left\nled=1"); + system("killall -USR2 minarch.elf"); + warned_final_minute = 1; + } } unlink(PARENTAL_PID_PATH);