From bd9e491e8f338907221aaf2d719fdad2f1f7b2e7 Mon Sep 17 00:00:00 2001 From: sulewicz Date: Mon, 1 Jun 2026 14:34:26 -0600 Subject: [PATCH] Screentime feature. --- Makefile | 2 + src/screenTime/Makefile | 16 + src/screenTime/screenTime.c | 509 ++++++++++++++++++ src/screenTime/screenTime.h | 49 ++ src/screenTime/screenTime_cli.c | 366 +++++++++++++ src/tweaks/Makefile | 12 +- src/tweaks/appstate.h | 4 +- src/tweaks/menus.h | 7 +- src/tweaks/screen_time_ui.h | 351 ++++++++++++ src/tweaks/tweaks.c | 3 + static/build/.tmp_update/runtime.sh | 72 ++- test/Makefile | 14 +- test/screenTime/Makefile | 16 + test/screenTime/test_screenTime.cpp | 318 +++++++++++ website/docs/01-features/index.md | 7 + .../01-included-in-onion/screen-time.md | 42 ++ .../07-apps/01-included-in-onion/tweaks.mdx | 49 +- 17 files changed, 1829 insertions(+), 8 deletions(-) create mode 100644 src/screenTime/Makefile create mode 100644 src/screenTime/screenTime.c create mode 100644 src/screenTime/screenTime.h create mode 100644 src/screenTime/screenTime_cli.c create mode 100644 src/tweaks/screen_time_ui.h create mode 100644 test/screenTime/Makefile create mode 100644 test/screenTime/test_screenTime.cpp create mode 100644 website/docs/07-apps/01-included-in-onion/screen-time.md diff --git a/Makefile b/Makefile index 6d00138a47..1cfb5b995d 100644 --- a/Makefile +++ b/Makefile @@ -121,6 +121,7 @@ core: $(CACHE)/.setup @cd $(SRC_DIR)/mainUiBatPerc && BUILD_DIR=$(BIN_DIR) make @cd $(SRC_DIR)/keymon && BUILD_DIR=$(BIN_DIR) make @cd $(SRC_DIR)/playActivity && BUILD_DIR=$(BIN_DIR) make + @cd $(SRC_DIR)/screenTime && BUILD_DIR=$(BIN_DIR) make @cd $(SRC_DIR)/themeSwitcher && BUILD_DIR=$(BIN_DIR) make @cd $(SRC_DIR)/tweaks && BUILD_DIR=$(BIN_DIR) make @cd $(SRC_DIR)/packageManager && BUILD_DIR=$(BIN_DIR) make @@ -273,6 +274,7 @@ test: external-libs @mkdir -p $(BUILD_TEST_DIR)/infoPanel_test_data && cd $(TEST_SRC_DIR) && BUILD_DIR=$(BUILD_TEST_DIR)/ make dev @cp -R $(TEST_SRC_DIR)/infoPanel_test_data $(BUILD_TEST_DIR)/ cd $(BUILD_TEST_DIR) && LD_LIBRARY_PATH=$(ROOT_DIR)/lib/ ./test + cd $(BUILD_TEST_DIR) && LD_LIBRARY_PATH=$(ROOT_DIR)/lib/ ./test_screenTime static-analysis: external-libs @cd $(ROOT_DIR) && cppcheck -I $(INCLUDE_DIR) --enable=all $(SRC_DIR) diff --git a/src/screenTime/Makefile b/src/screenTime/Makefile new file mode 100644 index 0000000000..e752a5dbf0 --- /dev/null +++ b/src/screenTime/Makefile @@ -0,0 +1,16 @@ +INCLUDE_UTILS = 0 +CFILES := ../common/utils/retroarch_cmd.c ../common/utils/udp.c +include ../common/config.mk + +TARGET = screenTime +CFLAGS := $(CFLAGS) -D_DEFAULT_SOURCE +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +SQLITE_LDFLAGS := $(shell xcrun --show-sdk-path)/usr/lib/libsqlite3.tbd +else +SQLITE_LDFLAGS := -lsqlite3 +endif +LDFLAGS := $(LDFLAGS) $(SQLITE_LDFLAGS) + +include ../common/commands.mk +include ../common/recipes.mk diff --git a/src/screenTime/screenTime.c b/src/screenTime/screenTime.c new file mode 100644 index 0000000000..a873dd8399 --- /dev/null +++ b/src/screenTime/screenTime.c @@ -0,0 +1,509 @@ +#include "screenTime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SCREEN_TIME_ENV_CONFIG_DIR "SCREEN_TIME_CONFIG_DIR" +#define SCREEN_TIME_ENV_ACTIVITY_DB "SCREEN_TIME_ACTIVITY_DB" +#define SCREEN_TIME_ENV_NOW "SCREEN_TIME_NOW" +#define SCREEN_TIME_ENV_BOOT_TIME "SCREEN_TIME_BOOT_TIME" +#define SCREEN_TIME_PIN_HASH_PREFIX "v1:" +#define SCREEN_TIME_PIN_HASH_SALT "OnionOS-screen-time" + +static void screen_time_config_path(char *out, size_t out_size, const char *name) +{ + snprintf(out, out_size, "%s/%s", screen_time_config_dir(), name); +} + +static bool read_int_file(const char *path, int *value) +{ + FILE *fp = fopen(path, "r"); + if (fp == NULL) + return false; + + int read_count = fscanf(fp, "%d", value); + fclose(fp); + return read_count == 1; +} + +static bool read_string_file(const char *path, char *out, size_t out_size) +{ + FILE *fp = fopen(path, "r"); + if (fp == NULL) + return false; + + if (fgets(out, (int)out_size, fp) == NULL) { + fclose(fp); + return false; + } + + out[strcspn(out, "\r\n")] = '\0'; + fclose(fp); + return true; +} + +static bool read_int64_file(const char *path, int64_t *value) +{ + char buffer[64]; + if (value == NULL || !read_string_file(path, buffer, sizeof(buffer))) + return false; + + char *end = NULL; + errno = 0; + long long parsed = strtoll(buffer, &end, 10); + if (errno != 0 || end == buffer) + return false; + + *value = parsed; + return true; +} + +static bool is_regular_file(const char *path) +{ + struct stat buffer; + return stat(path, &buffer) == 0 && S_ISREG(buffer.st_mode); +} + +static time_t screen_time_boot_time(void) +{ + const char *boot_time_str = getenv(SCREEN_TIME_ENV_BOOT_TIME); + if (boot_time_str != NULL && boot_time_str[0] != '\0') { + char *end = NULL; + errno = 0; + long long parsed = strtoll(boot_time_str, &end, 10); + if (errno == 0 && end != boot_time_str) + return (time_t)parsed; + } + + FILE *fp = fopen("/proc/stat", "r"); + if (fp == NULL) + return 0; + + char line[128]; + long long value = 0; + while (fgets(line, sizeof(line), fp) != NULL) { + if (sscanf(line, "btime %lld", &value) == 1) { + fclose(fp); + return (time_t)value; + } + } + + fclose(fp); + return 0; +} + +static int ensure_dir(const char *dir_path) +{ + if (dir_path == NULL || dir_path[0] == '\0') + return -1; + + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s", dir_path); + + size_t len = strlen(path); + if (len == 0) + return -1; + + if (path[len - 1] == '/') + path[len - 1] = '\0'; + + for (char *p = path + 1; *p != '\0'; p++) { + if (*p != '/') + continue; + + *p = '\0'; + if (mkdir(path, 0777) != 0 && errno != EEXIST) + return -1; + *p = '/'; + } + + if (mkdir(path, 0777) != 0 && errno != EEXIST) + return -1; + + return 0; +} + +static int write_int_file(const char *path, int value) +{ + if (ensure_dir(screen_time_config_dir()) != 0) + return -1; + + FILE *fp = fopen(path, "w+"); + if (fp == NULL) + return -1; + + fprintf(fp, "%d", value); + fflush(fp); + fsync(fileno(fp)); + fclose(fp); + return 0; +} + +static int write_string_file(const char *path, const char *value) +{ + if (ensure_dir(screen_time_config_dir()) != 0) + return -1; + + FILE *fp = fopen(path, "w+"); + if (fp == NULL) + return -1; + + fprintf(fp, "%s", value); + fflush(fp); + fsync(fileno(fp)); + fclose(fp); + return 0; +} + +static uint64_t fnv1a64_update(uint64_t hash, const char *value) +{ + const unsigned char *p = (const unsigned char *)value; + while (*p != '\0') { + hash ^= *p++; + hash *= 1099511628211ULL; + } + return hash; +} + +static void hash_pin(const char *pin, char *out, size_t out_size) +{ + uint64_t hash = 14695981039346656037ULL; + hash = fnv1a64_update(hash, SCREEN_TIME_PIN_HASH_SALT); + hash = fnv1a64_update(hash, ":"); + hash = fnv1a64_update(hash, pin == NULL ? "" : pin); + snprintf(out, out_size, SCREEN_TIME_PIN_HASH_PREFIX "%016llx", (unsigned long long)hash); +} + +const char *screen_time_config_dir(void) +{ + const char *config_dir = getenv(SCREEN_TIME_ENV_CONFIG_DIR); + return config_dir != NULL && config_dir[0] != '\0' ? config_dir : SCREEN_TIME_DEFAULT_CONFIG_DIR; +} + +const char *screen_time_activity_db_path(void) +{ + const char *db_path = getenv(SCREEN_TIME_ENV_ACTIVITY_DB); + return db_path != NULL && db_path[0] != '\0' ? db_path : SCREEN_TIME_DEFAULT_ACTIVITY_DB; +} + +time_t screen_time_now(void) +{ + const char *now_str = getenv(SCREEN_TIME_ENV_NOW); + if (now_str != NULL && now_str[0] != '\0') { + char *end = NULL; + errno = 0; + long long parsed = strtoll(now_str, &end, 10); + if (errno == 0 && end != now_str) + return (time_t)parsed; + } + + return time(NULL); +} + +void screen_time_today_bounds(time_t now, time_t *start_out, time_t *end_out) +{ + struct tm local_now; + localtime_r(&now, &local_now); + + local_now.tm_hour = 0; + local_now.tm_min = 0; + local_now.tm_sec = 0; + local_now.tm_isdst = -1; + time_t start = mktime(&local_now); + + local_now.tm_mday += 1; + local_now.tm_isdst = -1; + time_t end = mktime(&local_now); + + if (start_out != NULL) + *start_out = start; + if (end_out != NULL) + *end_out = end; +} + +void screen_time_date_string(time_t now, char *out, size_t out_size) +{ + struct tm local_now; + localtime_r(&now, &local_now); + strftime(out, out_size, "%Y-%m-%d", &local_now); +} + +int screen_time_load_settings(ScreenTimeSettings *settings) +{ + if (settings == NULL) + return -1; + + memset(settings, 0, sizeof(ScreenTimeSettings)); + + char path[PATH_MAX]; + int int_value = 0; + + screen_time_config_path(path, sizeof(path), "enabled"); + settings->enabled = read_int_file(path, &int_value) && int_value != 0; + + screen_time_config_path(path, sizeof(path), "dailyLimitMinutes"); + if (read_int_file(path, &settings->daily_limit_minutes) && settings->daily_limit_minutes < 0) + settings->daily_limit_minutes = 0; + + screen_time_config_path(path, sizeof(path), "extraDate"); + read_string_file(path, settings->extra_date, sizeof(settings->extra_date)); + + screen_time_config_path(path, sizeof(path), "extraMinutes"); + if (read_int_file(path, &settings->extra_minutes) && settings->extra_minutes < 0) + settings->extra_minutes = 0; + + screen_time_config_path(path, sizeof(path), "warningSeconds"); + if (read_int_file(path, &settings->warning_seconds) && settings->warning_seconds < 0) + settings->warning_seconds = 0; + + return 0; +} + +int screen_time_set_enabled(bool enabled) +{ + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "enabled"); + return write_int_file(path, enabled ? 1 : 0); +} + +int screen_time_set_daily_limit_minutes(int minutes) +{ + if (minutes < 0) + return -1; + + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "dailyLimitMinutes"); + return write_int_file(path, minutes); +} + +int screen_time_set_extra_minutes(int minutes) +{ + if (minutes < 0) + return -1; + + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(screen_time_now(), today, sizeof(today)); + + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "extraDate"); + if (write_string_file(path, today) != 0) + return -1; + + screen_time_config_path(path, sizeof(path), "extraMinutes"); + return write_int_file(path, minutes); +} + +int screen_time_add_extra_minutes(int minutes) +{ + if (minutes < 0) + return -1; + + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + return -1; + + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(screen_time_now(), today, sizeof(today)); + + int extra_minutes = settings.extra_minutes; + if (strncmp(settings.extra_date, today, sizeof(settings.extra_date)) != 0) + extra_minutes = 0; + extra_minutes += minutes; + + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "extraDate"); + if (write_string_file(path, today) != 0) + return -1; + + screen_time_config_path(path, sizeof(path), "extraMinutes"); + return write_int_file(path, extra_minutes); +} + +int screen_time_clear_extra_minutes(void) +{ + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(screen_time_now(), today, sizeof(today)); + + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "extraDate"); + if (write_string_file(path, today) != 0) + return -1; + + screen_time_config_path(path, sizeof(path), "extraMinutes"); + return write_int_file(path, 0); +} + +int screen_time_debug_set_remaining_seconds(int seconds) +{ + if (seconds < 0) + return -1; + + char path[PATH_MAX]; + char value[64]; + screen_time_config_path(path, sizeof(path), "debugExpireAt"); + snprintf(value, sizeof(value), "%lld", (long long)screen_time_now() + seconds); + return write_string_file(path, value); +} + +int screen_time_debug_clear_remaining(void) +{ + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "debugExpireAt"); + unlink(path); + return 0; +} + +bool screen_time_pin_configured(void) +{ + char path[PATH_MAX]; + char stored_hash[64]; + screen_time_config_path(path, sizeof(path), "pinHash"); + return read_string_file(path, stored_hash, sizeof(stored_hash)) && stored_hash[0] != '\0'; +} + +int screen_time_set_pin(const char *pin) +{ + char path[PATH_MAX]; + screen_time_config_path(path, sizeof(path), "pinHash"); + + if (pin == NULL || pin[0] == '\0') { + unlink(path); + return 0; + } + + char hash[64]; + hash_pin(pin, hash, sizeof(hash)); + return write_string_file(path, hash); +} + +bool screen_time_verify_pin(const char *pin) +{ + char path[PATH_MAX]; + char stored_hash[64]; + screen_time_config_path(path, sizeof(path), "pinHash"); + if (!read_string_file(path, stored_hash, sizeof(stored_hash)) || stored_hash[0] == '\0') + return true; + + char candidate_hash[64]; + hash_pin(pin, candidate_hash, sizeof(candidate_hash)); + return strcmp(stored_hash, candidate_hash) == 0; +} + +int screen_time_get_usage_seconds(const char *db_path, time_t now, int64_t *used_seconds) +{ + if (used_seconds == NULL) + return -1; + + *used_seconds = 0; + + if (db_path == NULL || !is_regular_file(db_path)) + return 0; + + time_t day_start; + time_t day_end; + screen_time_today_bounds(now, &day_start, &day_end); + time_t boot_time = screen_time_boot_time(); + + sqlite3 *db = NULL; + int rc = sqlite3_open(db_path, &db); + if (rc != SQLITE_OK) { + sqlite3_close(db); + return -1; + } + + const char *sql = + "SELECT created_at, " + " CASE WHEN play_time IS NULL THEN ?1 - created_at ELSE play_time END AS duration " + "FROM play_activity " + "WHERE created_at < ?2 " + " AND (created_at + play_time > ?3 " + " OR (play_time IS NULL AND (?4 = 0 OR created_at >= ?4)));"; + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + sqlite3_close(db); + return -1; + } + + sqlite3_bind_int64(stmt, 1, (sqlite3_int64)now); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)day_end); + sqlite3_bind_int64(stmt, 3, (sqlite3_int64)day_start); + sqlite3_bind_int64(stmt, 4, (sqlite3_int64)boot_time); + + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + int64_t session_start = sqlite3_column_int64(stmt, 0); + int64_t duration = sqlite3_column_int64(stmt, 1); + if (duration <= 0) + continue; + + int64_t session_end = session_start + duration; + int64_t overlap_start = session_start > (int64_t)day_start ? session_start : (int64_t)day_start; + int64_t overlap_end = session_end < (int64_t)day_end ? session_end : (int64_t)day_end; + if (overlap_end > overlap_start) + *used_seconds += overlap_end - overlap_start; + } + + sqlite3_finalize(stmt); + sqlite3_close(db); + + return rc == SQLITE_DONE ? 0 : -1; +} + +int screen_time_get_status(ScreenTimeStatus *status) +{ + if (status == NULL) + return -1; + + memset(status, 0, sizeof(ScreenTimeStatus)); + + time_t now = screen_time_now(); + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + return -1; + + int64_t used_seconds = 0; + if (screen_time_get_usage_seconds(screen_time_activity_db_path(), now, &used_seconds) != 0) + return -1; + + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(now, today, sizeof(today)); + + int extra_minutes = 0; + if (strncmp(settings.extra_date, today, sizeof(settings.extra_date)) == 0) + extra_minutes = settings.extra_minutes; + + status->enabled = settings.enabled && settings.daily_limit_minutes > 0; + status->used_seconds = used_seconds; + status->limit_seconds = (int64_t)settings.daily_limit_minutes * 60; + status->extra_seconds = (int64_t)extra_minutes * 60; + status->remaining_seconds = status->limit_seconds + status->extra_seconds - used_seconds; + if (!status->enabled) + status->remaining_seconds = INT64_MAX; + else { + char path[PATH_MAX]; + int64_t debug_expire_at = 0; + screen_time_config_path(path, sizeof(path), "debugExpireAt"); + if (read_int64_file(path, &debug_expire_at)) { + status->remaining_seconds = debug_expire_at - (int64_t)now; + if (status->remaining_seconds <= 0) + screen_time_debug_clear_remaining(); + } + } + + return 0; +} + +bool screen_time_launch_allowed(ScreenTimeStatus *status) +{ + if (status == NULL) + return true; + return !status->enabled || status->remaining_seconds > 0; +} diff --git a/src/screenTime/screenTime.h b/src/screenTime/screenTime.h new file mode 100644 index 0000000000..42a828fa0a --- /dev/null +++ b/src/screenTime/screenTime.h @@ -0,0 +1,49 @@ +#ifndef SCREEN_TIME_H +#define SCREEN_TIME_H + +#include +#include +#include + +#define SCREEN_TIME_DEFAULT_CONFIG_DIR "/mnt/SDCARD/.tmp_update/config/screenTime" +#define SCREEN_TIME_DEFAULT_ACTIVITY_DB "/mnt/SDCARD/Saves/CurrentProfile/play_activity/play_activity_db.sqlite" +#define SCREEN_TIME_DATE_LEN 11 + +typedef struct ScreenTimeSettings { + bool enabled; + int daily_limit_minutes; + int extra_minutes; + int warning_seconds; + char extra_date[SCREEN_TIME_DATE_LEN]; +} ScreenTimeSettings; + +typedef struct ScreenTimeStatus { + bool enabled; + int64_t used_seconds; + int64_t limit_seconds; + int64_t extra_seconds; + int64_t remaining_seconds; +} ScreenTimeStatus; + +const char *screen_time_config_dir(void); +const char *screen_time_activity_db_path(void); +time_t screen_time_now(void); +void screen_time_today_bounds(time_t now, time_t *start_out, time_t *end_out); +void screen_time_date_string(time_t now, char *out, size_t out_size); + +int screen_time_load_settings(ScreenTimeSettings *settings); +int screen_time_set_enabled(bool enabled); +int screen_time_set_daily_limit_minutes(int minutes); +int screen_time_set_extra_minutes(int minutes); +int screen_time_add_extra_minutes(int minutes); +int screen_time_clear_extra_minutes(void); +int screen_time_debug_set_remaining_seconds(int seconds); +int screen_time_debug_clear_remaining(void); +bool screen_time_pin_configured(void); +int screen_time_set_pin(const char *pin); +bool screen_time_verify_pin(const char *pin); +int screen_time_get_usage_seconds(const char *db_path, time_t now, int64_t *used_seconds); +int screen_time_get_status(ScreenTimeStatus *status); +bool screen_time_launch_allowed(ScreenTimeStatus *status); + +#endif // SCREEN_TIME_H diff --git a/src/screenTime/screenTime_cli.c b/src/screenTime/screenTime_cli.c new file mode 100644 index 0000000000..538ddb7853 --- /dev/null +++ b/src/screenTime/screenTime_cli.c @@ -0,0 +1,366 @@ +#include "screenTime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/retroarch_cmd.h" + +#define SCREEN_TIME_LIMIT_FLAG "/tmp/screen_time_limit_hit" +#define SCREEN_TIME_MONITOR_ENV_POLL_SECONDS "SCREEN_TIME_MONITOR_POLL_SECONDS" +#define SCREEN_TIME_MONITOR_ENV_ONCE "SCREEN_TIME_MONITOR_ONCE" +#define SCREEN_TIME_MONITOR_ENV_DRY_RUN "SCREEN_TIME_MONITOR_DRY_RUN" +#define SCREEN_TIME_MONITOR_DEFAULT_POLL_SECONDS 15 +#define SCREEN_TIME_MONITOR_MIN_POLL_SECONDS 1 +#define SCREEN_TIME_MONITOR_MAX_POLL_SECONDS 300 + +static void print_usage(void) +{ + printf("Usage: screenTime status\n" + " screenTime check [rom_path]\n" + " screenTime monitor [rom_path] [launcher_pid]\n" + " screenTime add-extra [minutes] [pin]\n" + " screenTime clear-extra [pin]\n" + " screenTime debug-set-remaining [seconds] [pin]\n" + " screenTime debug-clear [pin]\n" + " screenTime set-pin [new_pin] [current_pin]\n" + " screenTime clear-pin [current_pin]\n" + " screenTime verify-pin [pin]\n"); +} + +static bool env_enabled(const char *name) +{ + const char *value = getenv(name); + return value != NULL && value[0] != '\0' && strcmp(value, "0") != 0; +} + +static int monitor_poll_seconds(void) +{ + const char *value = getenv(SCREEN_TIME_MONITOR_ENV_POLL_SECONDS); + if (value == NULL || value[0] == '\0') + return SCREEN_TIME_MONITOR_DEFAULT_POLL_SECONDS; + + int seconds = atoi(value); + if (seconds < SCREEN_TIME_MONITOR_MIN_POLL_SECONDS) + return SCREEN_TIME_MONITOR_MIN_POLL_SECONDS; + if (seconds > SCREEN_TIME_MONITOR_MAX_POLL_SECONDS) + return SCREEN_TIME_MONITOR_MAX_POLL_SECONDS; + return seconds; +} + +static void touch_limit_flag(void) +{ + FILE *fp = fopen(SCREEN_TIME_LIMIT_FLAG, "w"); + if (fp == NULL) + return; + + fputs("1", fp); + fclose(fp); +} + +static bool parse_pid(const char *value, pid_t *pid_out) +{ + if (value == NULL || value[0] == '\0' || pid_out == NULL) + return false; + + char *end = NULL; + errno = 0; + long parsed = strtol(value, &end, 10); + if (errno != 0 || end == value || parsed <= 0) + return false; + + *pid_out = (pid_t)parsed; + return true; +} + +static bool is_pid_dir(const struct dirent *entry) +{ + for (const char *p = entry->d_name; *p != '\0'; p++) { + if (!isdigit((unsigned char)*p)) + return false; + } + return true; +} + +static bool process_parent_pid(pid_t pid, pid_t *ppid_out) +{ + char path[64]; + char stat_line[512]; + snprintf(path, sizeof(path), "/proc/%ld/stat", (long)pid); + + FILE *fp = fopen(path, "r"); + if (fp == NULL) + return false; + + bool found = fgets(stat_line, sizeof(stat_line), fp) != NULL; + fclose(fp); + if (!found) + return false; + + char *close_paren = strrchr(stat_line, ')'); + if (close_paren == NULL) + return false; + + char state = '\0'; + long ppid = 0; + if (sscanf(close_paren + 2, "%c %ld", &state, &ppid) != 2) + return false; + + *ppid_out = (pid_t)ppid; + return true; +} + +static void signal_process_tree(pid_t root_pid, int sig) +{ + DIR *proc = opendir("/proc"); + if (proc != NULL) { + struct dirent *entry; + while ((entry = readdir(proc)) != NULL) { + if (!is_pid_dir(entry)) + continue; + + pid_t pid = (pid_t)strtol(entry->d_name, NULL, 10); + pid_t ppid = 0; + if (pid > 0 && process_parent_pid(pid, &ppid) && ppid == root_pid) + signal_process_tree(pid, sig); + } + closedir(proc); + } + + kill(root_pid, sig); +} + +static bool process_is_running(pid_t pid) +{ + return pid > 0 && kill(pid, 0) == 0; +} + +static bool retroarch_is_running(void) +{ + return system("pidof retroarch > /dev/null") == 0; +} + +static bool limit_target_is_running(pid_t launcher_pid) +{ + return process_is_running(launcher_pid) || retroarch_is_running(); +} + +static bool wait_for_limit_target_exit(pid_t launcher_pid, int timeout_seconds) +{ + for (int i = 0; i < timeout_seconds * 10; i++) { + if (!limit_target_is_running(launcher_pid)) + return true; + usleep(100000); + } + + return !limit_target_is_running(launcher_pid); +} + +static void enforce_limit(const char *rom_path, pid_t launcher_pid) +{ + touch_limit_flag(); + fprintf(stderr, "Screen time limit reached"); + if (rom_path != NULL && rom_path[0] != '\0') + fprintf(stderr, ": %s", rom_path); + fprintf(stderr, "\n"); + + if (env_enabled(SCREEN_TIME_MONITOR_ENV_DRY_RUN)) + return; + + system("infoPanel --title \"Screen time\" --message \"Daily screen time limit reached.\" --auto &"); + + retroarch_quit(); + if (wait_for_limit_target_exit(launcher_pid, 5)) + return; + + if (launcher_pid > 0) + signal_process_tree(launcher_pid, SIGTERM); + else + system("killall -TERM retroarch"); + + if (!wait_for_limit_target_exit(launcher_pid, 5)) { + system("touch /tmp/.forceKillRetroarch"); + if (launcher_pid > 0) + signal_process_tree(launcher_pid, SIGKILL); + system("pidof retroarch > /dev/null && killall -9 retroarch"); + } +} + +static int print_status(void) +{ + ScreenTimeStatus status; + if (screen_time_get_status(&status) != 0) { + fprintf(stderr, "Error: unable to read screen time status\n"); + return EXIT_FAILURE; + } + + printf("enabled=%d\n", status.enabled ? 1 : 0); + printf("usedSeconds=%" PRId64 "\n", status.used_seconds); + printf("limitSeconds=%" PRId64 "\n", status.limit_seconds); + printf("extraSeconds=%" PRId64 "\n", status.extra_seconds); + if (status.remaining_seconds == INT64_MAX) + printf("remainingSeconds=unlimited\n"); + else + printf("remainingSeconds=%" PRId64 "\n", status.remaining_seconds); + + return EXIT_SUCCESS; +} + +static int check_launch(void) +{ + ScreenTimeStatus status; + if (screen_time_get_status(&status) != 0) { + fprintf(stderr, "Error: unable to read screen time status\n"); + return EXIT_FAILURE; + } + + if (screen_time_launch_allowed(&status)) { + printf("allowed\n"); + return EXIT_SUCCESS; + } + + printf("blocked: daily screen time limit reached\n"); + return 2; +} + +static bool authorize_pin_arg(int argc, char *argv[], int pin_index) +{ + if (!screen_time_pin_configured()) + return true; + + if (argc <= pin_index || !screen_time_verify_pin(argv[pin_index])) { + fprintf(stderr, "Error: invalid or missing PIN\n"); + return false; + } + + return true; +} + +static int monitor_limit(const char *rom_path, const char *launcher_pid_arg) +{ + int poll_seconds = monitor_poll_seconds(); + bool once = env_enabled(SCREEN_TIME_MONITOR_ENV_ONCE); + pid_t launcher_pid = 0; + parse_pid(launcher_pid_arg, &launcher_pid); + + while (true) { + ScreenTimeStatus status; + if (screen_time_get_status(&status) == 0) { + if (!screen_time_launch_allowed(&status)) { + enforce_limit(rom_path, launcher_pid); + return 2; + } + } + else { + fprintf(stderr, "Warning: unable to read screen time status\n"); + } + + if (once) + return EXIT_SUCCESS; + + sleep((unsigned int)poll_seconds); + } +} + +int main(int argc, char *argv[]) +{ + if (argc < 2) { + print_usage(); + return EXIT_SUCCESS; + } + + if (strcmp(argv[1], "status") == 0) + return print_status(); + + if (strcmp(argv[1], "check") == 0) + return check_launch(); + + if (strcmp(argv[1], "monitor") == 0) + return monitor_limit(argc >= 3 ? argv[2] : "", argc >= 4 ? argv[3] : ""); + + if (strcmp(argv[1], "add-extra") == 0) { + if (argc < 3) { + fprintf(stderr, "Error: missing minutes argument\n"); + return EXIT_FAILURE; + } + if (!authorize_pin_arg(argc, argv, 3)) + return 2; + int minutes = atoi(argv[2]); + if (screen_time_add_extra_minutes(minutes) != 0) { + fprintf(stderr, "Error: unable to add extra time\n"); + return EXIT_FAILURE; + } + return print_status(); + } + + if (strcmp(argv[1], "clear-extra") == 0) { + if (!authorize_pin_arg(argc, argv, 2)) + return 2; + if (screen_time_clear_extra_minutes() != 0) { + fprintf(stderr, "Error: unable to clear extra time\n"); + return EXIT_FAILURE; + } + return print_status(); + } + + if (strcmp(argv[1], "debug-set-remaining") == 0) { + if (argc < 3) { + fprintf(stderr, "Error: missing seconds argument\n"); + return EXIT_FAILURE; + } + if (!authorize_pin_arg(argc, argv, 3)) + return 2; + int seconds = atoi(argv[2]); + if (screen_time_debug_set_remaining_seconds(seconds) != 0) { + fprintf(stderr, "Error: unable to set debug remaining time\n"); + return EXIT_FAILURE; + } + return print_status(); + } + + if (strcmp(argv[1], "debug-clear") == 0) { + if (!authorize_pin_arg(argc, argv, 2)) + return 2; + if (screen_time_debug_clear_remaining() != 0) { + fprintf(stderr, "Error: unable to clear debug remaining time\n"); + return EXIT_FAILURE; + } + return print_status(); + } + + if (strcmp(argv[1], "set-pin") == 0) { + if (argc < 3) { + fprintf(stderr, "Error: missing pin argument\n"); + return EXIT_FAILURE; + } + if (!authorize_pin_arg(argc, argv, 3)) + return 2; + return screen_time_set_pin(argv[2]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } + + if (strcmp(argv[1], "clear-pin") == 0) { + if (!authorize_pin_arg(argc, argv, 2)) + return 2; + return screen_time_set_pin("") == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } + + if (strcmp(argv[1], "verify-pin") == 0) { + if (argc < 3) { + fprintf(stderr, "Error: missing pin argument\n"); + return EXIT_FAILURE; + } + return screen_time_verify_pin(argv[2]) ? EXIT_SUCCESS : 2; + } + + fprintf(stderr, "Error: invalid argument '%s'\n", argv[1]); + print_usage(); + return EXIT_FAILURE; +} diff --git a/src/tweaks/Makefile b/src/tweaks/Makefile index 0dcf608c34..a7d6a8041e 100644 --- a/src/tweaks/Makefile +++ b/src/tweaks/Makefile @@ -1,10 +1,20 @@ INCLUDE_SHMVAR=1 INCLUDE_CJSON=1 +CFILES := ../screenTime/screenTime.c include ../common/config.mk TARGET = tweaks CFLAGS := $(CFLAGS) -D_DEFAULT_SOURCE -DHAS_AUDIO -LDFLAGS := $(LDFLAGS) -lSDL -lSDL_ttf -lSDL_image -lSDL_mixer -lSDL_rotozoom -pthread -lkbinput +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +SQLITE_LDFLAGS := $(shell xcrun --show-sdk-path)/usr/lib/libsqlite3.tbd +else +SQLITE_LDFLAGS := -lsqlite3 +endif +LDFLAGS := $(LDFLAGS) $(SQLITE_LDFLAGS) -lSDL -lSDL_ttf -lSDL_image -lSDL_mixer -lSDL_rotozoom -pthread -lkbinput include ../common/commands.mk include ../common/recipes.mk + +./tweaks.o: $(wildcard ./*.h) ../screenTime/screenTime.h +../screenTime/screenTime.o: ../screenTime/screenTime.h diff --git a/src/tweaks/appstate.h b/src/tweaks/appstate.h index d8a93a0075..1b05801c99 100644 --- a/src/tweaks/appstate.h +++ b/src/tweaks/appstate.h @@ -57,6 +57,7 @@ void menu_icons_free_all(void) static List _menu_main; static List _menu_system; +static List _menu_screen_time; static List _menu_date_time; static List _menu_system_display; static List _menu_user_blue_light; @@ -79,6 +80,7 @@ void menu_free_all(void) { list_free(&_menu_main); list_free(&_menu_system); + list_free(&_menu_screen_time); list_free(&_menu_date_time); list_free(&_menu_system_display); list_free(&_menu_system_startup); @@ -130,4 +132,4 @@ static void sigHandler(int sig) } } -#endif // TWEAKS_APPSTATE_H__ \ No newline at end of file +#endif // TWEAKS_APPSTATE_H__ diff --git a/src/tweaks/menus.h b/src/tweaks/menus.h index 768a4b9909..996f9b1007 100644 --- a/src/tweaks/menus.h +++ b/src/tweaks/menus.h @@ -23,6 +23,7 @@ #include "./icons.h" #include "./network.h" #include "./reset.h" +#include "./screen_time_ui.h" #include "./tools.h" #include "./values.h" @@ -182,7 +183,7 @@ void menu_datetime(void *_) void menu_system(void *_) { if (!_menu_system._created) { - _menu_system = list_createWithTitle(6, LIST_SMALL, "System"); + _menu_system = list_createWithTitle(7, LIST_SMALL, "System"); list_addItem(&_menu_system, (ListItem){ .label = "Startup...", @@ -195,6 +196,10 @@ void menu_system(void *_) (ListItem){ .label = "Date and time...", .action = menu_datetime}); + list_addItem(&_menu_system, + (ListItem){ + .label = "Screen time...", + .action = menu_screenTime}); list_addItemWithInfoNote(&_menu_system, (ListItem){ .label = "Low battery warning", diff --git a/src/tweaks/screen_time_ui.h b/src/tweaks/screen_time_ui.h new file mode 100644 index 0000000000..9f71c58896 --- /dev/null +++ b/src/tweaks/screen_time_ui.h @@ -0,0 +1,351 @@ +#ifndef TWEAKS_SCREEN_TIME_UI_H__ +#define TWEAKS_SCREEN_TIME_UI_H__ + +#include +#include +#include + +#include "components/kbinput_wrapper.h" +#include "components/list.h" + +#include "../screenTime/screenTime.h" +#include "./appstate.h" +#include "./info_dialog.h" + +static const int SCREEN_TIME_DAILY_LIMITS[] = {0, 15, 30, 45, 60, 90, 120, 180, 240, 300, 360}; +static const int SCREEN_TIME_EXTRA_OPTIONS[] = {0, 5, 10, 15, 30, 45, 60}; +#define SCREEN_TIME_DURATION_LABEL_LEN 32 + +static bool screen_time_settings_unlocked = false; + +static int screen_time_value_for_minutes(const int *values, int count, int minutes) +{ + for (int i = 0; i < count; i++) { + if (values[i] == minutes) + return i; + } + + return 0; +} + +static void screen_time_format_minutes(int64_t minutes, char *out_label, size_t out_label_size) +{ + if (minutes <= 0) { + snprintf(out_label, out_label_size, "Off"); + return; + } + + int64_t hours = minutes / 60; + int64_t mins = minutes % 60; + if (hours == 0) + snprintf(out_label, out_label_size, "%lldm", (long long)mins); + else if (mins == 0) + snprintf(out_label, out_label_size, "%lldh", (long long)hours); + else + snprintf(out_label, out_label_size, "%lldh %lldm", (long long)hours, (long long)mins); +} + +static void screen_time_format_duration(int64_t seconds, char *out_label, size_t out_label_size) +{ + if (seconds <= 0) { + snprintf(out_label, out_label_size, "0m"); + return; + } + + int64_t minutes = (seconds + 59) / 60; + screen_time_format_minutes(minutes, out_label, out_label_size); +} + +static void screen_time_status_labels(char *used_label, char *remaining_label) +{ + ScreenTimeStatus status; + if (screen_time_get_status(&status) != 0) { + strcpy(used_label, "Today used: unavailable"); + strcpy(remaining_label, "Time remaining: unavailable"); + return; + } + + char duration[SCREEN_TIME_DURATION_LABEL_LEN]; + screen_time_format_duration(status.used_seconds, duration, sizeof(duration)); + snprintf(used_label, STR_MAX, "Today used: %s", duration); + + if (!status.enabled) { + strcpy(remaining_label, "Time remaining: unlimited"); + return; + } + + screen_time_format_duration(status.remaining_seconds, duration, sizeof(duration)); + snprintf(remaining_label, STR_MAX, "Time remaining: %s", duration); +} + +static void screen_time_refresh_menu(void) +{ + if (!_menu_screen_time._created) + return; + + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + memset(&settings, 0, sizeof(settings)); + + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(screen_time_now(), today, sizeof(today)); + int extra_minutes = strncmp(settings.extra_date, today, sizeof(settings.extra_date)) == 0 ? settings.extra_minutes : 0; + + bool pin_configured = screen_time_pin_configured(); + screen_time_settings_unlocked = !pin_configured || screen_time_settings_unlocked; + + strcpy(_menu_screen_time.items[0].label, screen_time_settings_unlocked ? "Settings unlocked" : "Unlock settings..."); + _menu_screen_time.items[0].disabled = !pin_configured; + if (_menu_screen_time.items[0].disabled && _menu_screen_time.active_pos == 0) + list_scrollTo(&_menu_screen_time, 1); + + _menu_screen_time.items[1].value = settings.enabled ? 1 : 0; + _menu_screen_time.items[2].value = screen_time_value_for_minutes( + SCREEN_TIME_DAILY_LIMITS, + sizeof(SCREEN_TIME_DAILY_LIMITS) / sizeof(SCREEN_TIME_DAILY_LIMITS[0]), + settings.daily_limit_minutes); + _menu_screen_time.items[3].value = screen_time_value_for_minutes( + SCREEN_TIME_EXTRA_OPTIONS, + sizeof(SCREEN_TIME_EXTRA_OPTIONS) / sizeof(SCREEN_TIME_EXTRA_OPTIONS[0]), + extra_minutes); + + for (int i = 1; i < 4; i++) + _menu_screen_time.items[i]._reset_value = _menu_screen_time.items[i].value; + + strcpy(_menu_screen_time.items[4].label, pin_configured ? "PIN: Change..." : "PIN: Set..."); + screen_time_status_labels(_menu_screen_time.items[5].label, _menu_screen_time.items[6].label); + list_changed = true; +} + +static void screen_time_lock_settings(void) +{ + screen_time_settings_unlocked = false; +} + +static const char *screen_time_request_pin(const char *title) +{ + const char *pin = launch_keyboard("", title); + all_changed = true; + return pin; +} + +static bool screen_time_unlock_settings(void) +{ + if (!screen_time_pin_configured()) + return true; + + const char *pin = screen_time_request_pin("Screen time PIN"); + if (pin != NULL && screen_time_verify_pin(pin)) { + screen_time_settings_unlocked = true; + return true; + } + + __showInfoDialog("Screen time", "Incorrect PIN."); + return false; +} + +static bool screen_time_require_unlocked(void) +{ + if (!screen_time_pin_configured() || screen_time_settings_unlocked) + return true; + + __showInfoDialog("Screen time", "Unlock settings first."); + return false; +} + +static void action_screenTimeUnlock(void *pt) +{ + if (screen_time_settings_unlocked) { + __showInfoDialog("Screen time", "Settings already unlocked."); + screen_time_refresh_menu(); + return; + } + + if (screen_time_unlock_settings()) + __showInfoDialog("Screen time", "Settings unlocked."); + + screen_time_refresh_menu(); +} + +static void formatter_screenTimeDailyLimit(void *pt, char *out_label) +{ + ListItem *item = (ListItem *)pt; + int count = sizeof(SCREEN_TIME_DAILY_LIMITS) / sizeof(SCREEN_TIME_DAILY_LIMITS[0]); + int value = item->value < count ? item->value : 0; + screen_time_format_minutes(SCREEN_TIME_DAILY_LIMITS[value], out_label, STR_MAX); +} + +static void formatter_screenTimeExtra(void *pt, char *out_label) +{ + ListItem *item = (ListItem *)pt; + int count = sizeof(SCREEN_TIME_EXTRA_OPTIONS) / sizeof(SCREEN_TIME_EXTRA_OPTIONS[0]); + int value = item->value < count ? item->value : 0; + int minutes = SCREEN_TIME_EXTRA_OPTIONS[value]; + if (minutes == 0) + strcpy(out_label, "Off"); + else + sprintf(out_label, "+%dm", minutes); +} + +static void action_screenTimeEnabled(void *pt) +{ + ListItem *item = (ListItem *)pt; + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + memset(&settings, 0, sizeof(settings)); + if (settings.enabled && item->value == 0 && !screen_time_require_unlocked()) { + screen_time_refresh_menu(); + return; + } + + screen_time_set_enabled(item->value == 1); + screen_time_refresh_menu(); +} + +static void action_screenTimeDailyLimit(void *pt) +{ + ListItem *item = (ListItem *)pt; + int count = sizeof(SCREEN_TIME_DAILY_LIMITS) / sizeof(SCREEN_TIME_DAILY_LIMITS[0]); + int value = item->value < count ? item->value : 0; + int minutes = SCREEN_TIME_DAILY_LIMITS[value]; + + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + memset(&settings, 0, sizeof(settings)); + bool disables_limit = settings.daily_limit_minutes > 0 && minutes == 0; + bool increases_limit = minutes > settings.daily_limit_minutes; + if ((disables_limit || increases_limit) && !screen_time_require_unlocked()) { + screen_time_refresh_menu(); + return; + } + + screen_time_set_daily_limit_minutes(minutes); + screen_time_refresh_menu(); +} + +static void action_screenTimeExtra(void *pt) +{ + ListItem *item = (ListItem *)pt; + int count = sizeof(SCREEN_TIME_EXTRA_OPTIONS) / sizeof(SCREEN_TIME_EXTRA_OPTIONS[0]); + int value = item->value < count ? item->value : 0; + int minutes = SCREEN_TIME_EXTRA_OPTIONS[value]; + + ScreenTimeSettings settings; + if (screen_time_load_settings(&settings) != 0) + memset(&settings, 0, sizeof(settings)); + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(screen_time_now(), today, sizeof(today)); + int extra_minutes = strncmp(settings.extra_date, today, sizeof(settings.extra_date)) == 0 ? settings.extra_minutes : 0; + if (minutes > extra_minutes && !screen_time_require_unlocked()) { + screen_time_refresh_menu(); + return; + } + + screen_time_set_extra_minutes(minutes); + screen_time_refresh_menu(); +} + +static void action_screenTimePin(void *pt) +{ + if (screen_time_pin_configured() && !screen_time_require_unlocked()) { + screen_time_refresh_menu(); + return; + } + + bool pin_was_configured = screen_time_pin_configured(); + const char *pin = screen_time_request_pin(pin_was_configured ? "New screen time PIN" : "Set screen time PIN"); + if (pin == NULL) { + screen_time_refresh_menu(); + return; + } + + char new_pin[STR_MAX]; + strncpy(new_pin, pin, STR_MAX - 1); + new_pin[STR_MAX - 1] = '\0'; + + if (new_pin[0] != '\0') { + const char *confirmed_pin = screen_time_request_pin("Confirm screen time PIN"); + if (confirmed_pin == NULL) { + screen_time_refresh_menu(); + return; + } + + if (strcmp(new_pin, confirmed_pin) != 0) { + __showInfoDialog("Screen time", "PINs do not match."); + screen_time_refresh_menu(); + return; + } + } + else if (!pin_was_configured) { + __showInfoDialog("Screen time", "PIN not set."); + screen_time_refresh_menu(); + return; + } + + screen_time_set_pin(new_pin); + screen_time_settings_unlocked = new_pin[0] != '\0'; + __showInfoDialog("Screen time", new_pin[0] == '\0' ? "PIN cleared." : "PIN saved."); + screen_time_refresh_menu(); +} + +void menu_screenTime(void *_) +{ + screen_time_lock_settings(); + + if (!_menu_screen_time._created) { + _menu_screen_time = list_createWithTitle(7, LIST_SMALL, "Screen time"); + list_addItemWithInfoNote(&_menu_screen_time, + (ListItem){ + .label = "Unlock settings...", + .action = action_screenTimeUnlock}, + "Unlock protected settings until you\n" + "leave this Screen Time menu."); + list_addItemWithInfoNote(&_menu_screen_time, + (ListItem){ + .label = "State", + .item_type = TOGGLE, + .value = 0, + .action = action_screenTimeEnabled}, + "Enable or disable daily play time limits."); + list_addItemWithInfoNote(&_menu_screen_time, + (ListItem){ + .label = "Daily limit", + .item_type = MULTIVALUE, + .value_max = sizeof(SCREEN_TIME_DAILY_LIMITS) / sizeof(SCREEN_TIME_DAILY_LIMITS[0]) - 1, + .value_formatter = formatter_screenTimeDailyLimit, + .action = action_screenTimeDailyLimit}, + "Set the total play time allowed today."); + list_addItemWithInfoNote(&_menu_screen_time, + (ListItem){ + .label = "Extra time today", + .item_type = MULTIVALUE, + .value_max = sizeof(SCREEN_TIME_EXTRA_OPTIONS) / sizeof(SCREEN_TIME_EXTRA_OPTIONS[0]) - 1, + .value_formatter = formatter_screenTimeExtra, + .action = action_screenTimeExtra}, + "Grant temporary extra time for today."); + list_addItemWithInfoNote(&_menu_screen_time, + (ListItem){ + .label = "PIN: Set...", + .action = action_screenTimePin}, + "Set, change, or clear the PIN used\n" + "to protect screen time changes."); + list_addItem(&_menu_screen_time, + (ListItem){ + .label = "Today used: ...", + .disabled = 1, + .show_opaque = 1, + .action = NULL}); + list_addItem(&_menu_screen_time, + (ListItem){ + .label = "Time remaining: ...", + .disabled = 1, + .show_opaque = 1, + .action = NULL}); + } + + screen_time_refresh_menu(); + menu_stack[++menu_level] = &_menu_screen_time; + header_changed = true; +} + +#endif // TWEAKS_SCREEN_TIME_UI_H__ diff --git a/src/tweaks/tweaks.c b/src/tweaks/tweaks.c index 2d034e45c0..61f5e7deba 100644 --- a/src/tweaks/tweaks.c +++ b/src/tweaks/tweaks.c @@ -153,6 +153,9 @@ int main(int argc, char *argv[]) if (menu_level == 0) quit = true; else { + if (isMenu(&_menu_screen_time)) + screen_time_lock_settings(); + menu_stack[menu_level] = NULL; menu_level--; header_changed = true; diff --git a/static/build/.tmp_update/runtime.sh b/static/build/.tmp_update/runtime.sh index d291e73c84..2b7cdfbcc3 100644 --- a/static/build/.tmp_update/runtime.sh +++ b/static/build/.tmp_update/runtime.sh @@ -279,6 +279,52 @@ check_is_game() { echo "$1" | grep -q "retroarch/cores" || echo "$1" | grep -q "/../../Roms/" || echo "$1" | grep -q "/mnt/SDCARD/Roms/" } +screen_time_check_launch() { + rompath="$1" + + if ! command -v screenTime > /dev/null 2>&1; then + log "screenTime not found, allowing launch" + return 0 + fi + + screen_time_output=$(screenTime check "$rompath" 2>&1) + screen_time_rc=$? + log "screenTime check: $screen_time_output" + + # screenTime exits 2 only when policy explicitly blocks launch. + # Other non-zero statuses indicate an internal error and should not strand the user. + if [ $screen_time_rc -eq 2 ]; then + return 1 + fi + + return 0 +} + +screen_time_start_monitor() { + rompath="$1" + launcher_pid="$2" + + screen_time_monitor_pid="" + rm -f /tmp/screen_time_limit_hit 2> /dev/null + + if ! command -v screenTime > /dev/null 2>&1; then + return 0 + fi + + screenTime monitor "$rompath" "$launcher_pid" >> /tmp/screenTime.log 2>&1 & + screen_time_monitor_pid=$! + log "screenTime monitor started: $screen_time_monitor_pid" +} + +screen_time_stop_monitor() { + if [ -n "$screen_time_monitor_pid" ]; then + kill "$screen_time_monitor_pid" 2> /dev/null + wait "$screen_time_monitor_pid" 2> /dev/null + log "screenTime monitor stopped: $screen_time_monitor_pid" + screen_time_monitor_pid="" + fi +} + change_resolution() { res_x="" res_y="" @@ -358,6 +404,15 @@ launch_game() { fi if [ $is_game -eq 1 ]; then + if ! screen_time_check_launch "$rompath"; then + log "Screen time blocked launch: $rompath" + infoPanel --title "Screen time" --message "Daily screen time limit reached." --auto + rm -f /tmp/quick_switch 2> /dev/null + rm -f /tmp/force_auto_load_state 2> /dev/null + retval=0 + return + fi + if [ -f "$launch_script" ] && cat "$launch_script" | grep -q '.retroarch/cores'; then # Override core if needed override_game_core "$romcfgpath" "$launch_script" @@ -413,9 +468,19 @@ launch_game() { cd /mnt/SDCARD/RetroArch force_retroarch_cfg - # make the cmd_to_run shell env aware of the new timezone - TZ="$TZ_VALUE" $sysdir/cmd_to_run.sh - retval=$? + if [ $is_game -eq 1 ]; then + # make the cmd_to_run shell env aware of the new timezone + TZ="$TZ_VALUE" $sysdir/cmd_to_run.sh & + game_pid=$! + screen_time_start_monitor "$rompath" "$game_pid" + wait "$game_pid" + retval=$? + screen_time_stop_monitor + else + # make the cmd_to_run shell env aware of the new timezone + TZ="$TZ_VALUE" $sysdir/cmd_to_run.sh + retval=$? + fi if [ -f /tmp/new_res_available ]; then # Restore resolution @@ -554,6 +619,7 @@ launch_game_postprocess() { # TIMER END + SHUTDOWN CHECK if [ $is_game -eq 1 ]; then cd $sysdir + screen_time_stop_monitor playActivity stop "$rompath" # Remove appended configs diff --git a/test/Makefile b/test/Makefile index 7d13b1aa0c..2404312d9d 100644 --- a/test/Makefile +++ b/test/Makefile @@ -7,4 +7,16 @@ TARGET = test LDFLAGS := $(LDFLAGS) -L../lib -s -lSDL_image -lSDL -lSDL_rotozoom -lgtest -lgtest_main -lpthread include ../src/common/commands.mk -include ../src/common/recipes.mk \ No newline at end of file +include ../src/common/recipes.mk + +$(TARGET): screen-time-tests + +.PHONY: screen-time-tests clean-screen-time-tests + +screen-time-tests: + @$(MAKE) -C screenTime + +clean: clean-screen-time-tests + +clean-screen-time-tests: + @$(MAKE) -C screenTime clean diff --git a/test/screenTime/Makefile b/test/screenTime/Makefile new file mode 100644 index 0000000000..0d69a07857 --- /dev/null +++ b/test/screenTime/Makefile @@ -0,0 +1,16 @@ +TEST = 1 +INCLUDE_UTILS = 0 +CFILES := ../../src/screenTime/screenTime.c +include ../../src/common/config.mk + +TARGET = test_screenTime +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +SQLITE_LDFLAGS := $(shell xcrun --show-sdk-path)/usr/lib/libsqlite3.tbd +else +SQLITE_LDFLAGS := -lsqlite3 +endif +LDFLAGS := $(LDFLAGS) $(SQLITE_LDFLAGS) -lgtest -lgtest_main -lpthread + +include ../../src/common/commands.mk +include ../../src/common/recipes.mk diff --git a/test/screenTime/test_screenTime.cpp b/test/screenTime/test_screenTime.cpp new file mode 100644 index 0000000000..bb50356fe6 --- /dev/null +++ b/test/screenTime/test_screenTime.cpp @@ -0,0 +1,318 @@ +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "../../src/screenTime/screenTime.h" +} + +class TempDir { + public: + TempDir() + { + char tmpl[] = "/tmp/onion-screen-time-test-XXXXXX"; + char *created = mkdtemp(tmpl); + path_ = created == nullptr ? "" : created; + } + + ~TempDir() + { + if (!path_.empty()) { + std::string cmd = "rm -rf \"" + path_ + "\""; + system(cmd.c_str()); + } + } + + const std::string &path() const { return path_; } + + private: + std::string path_; +}; + +class ScopedEnv { + public: + ScopedEnv(const char *name, const std::string &value) + : name_(name) + { + const char *previous = getenv(name); + if (previous != nullptr) { + had_previous_ = true; + previous_ = previous; + } + + setenv(name_.c_str(), value.c_str(), 1); + } + + ~ScopedEnv() + { + if (had_previous_) + setenv(name_.c_str(), previous_.c_str(), 1); + else + unsetenv(name_.c_str()); + } + + private: + std::string name_; + bool had_previous_ = false; + std::string previous_; +}; + +static void execSql(sqlite3 *db, const char *sql) +{ + char *err = nullptr; + ASSERT_EQ(sqlite3_exec(db, sql, nullptr, nullptr, &err), SQLITE_OK) << (err == nullptr ? "" : err); + sqlite3_free(err); +} + +static void insertActivity(sqlite3 *db, int64_t created_at, const char *play_time) +{ + char sql[256]; + snprintf(sql, sizeof(sql), + "INSERT INTO play_activity(rom_id, play_time, created_at, updated_at) " + "VALUES(1, %s, %lld, %s);", + play_time, (long long)created_at, play_time); + execSql(db, sql); +} + +static std::string createActivityDb(const std::string &dir) +{ + std::string dbPath = dir + "/play_activity_db.sqlite"; + sqlite3 *db = nullptr; + EXPECT_EQ(sqlite3_open(dbPath.c_str(), &db), SQLITE_OK); + execSql(db, + "CREATE TABLE play_activity(" + "rom_id INTEGER, " + "play_time INTEGER, " + "created_at INTEGER, " + "updated_at INTEGER);"); + sqlite3_close(db); + return dbPath; +} + +TEST(test_screenTime, missingDatabaseHasZeroUsage) +{ + int64_t used = -1; + ASSERT_EQ(screen_time_get_usage_seconds("/tmp/does-not-exist-screen-time.sqlite", 1700000000, &used), 0); + ASSERT_EQ(used, 0); +} + +TEST(test_screenTime, dailyUsageCountsOnlyTodayOverlap) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string dbPath = createActivityDb(dir.path()); + + time_t now = 1700000000; + time_t dayStart = 0; + time_t dayEnd = 0; + screen_time_today_bounds(now, &dayStart, &dayEnd); + + sqlite3 *db = nullptr; + ASSERT_EQ(sqlite3_open(dbPath.c_str(), &db), SQLITE_OK); + + insertActivity(db, dayStart + 3600, "600"); + insertActivity(db, dayStart - 600, "1200"); + insertActivity(db, dayEnd - 300, "900"); + insertActivity(db, dayStart - 7200, "3600"); + insertActivity(db, dayStart + 5000, "-10"); + insertActivity(db, now - 120, "NULL"); + + sqlite3_close(db); + + ScopedEnv bootTime("SCREEN_TIME_BOOT_TIME", std::to_string((long long)dayStart)); + + int64_t used = 0; + ASSERT_EQ(screen_time_get_usage_seconds(dbPath.c_str(), now, &used), 0); + ASSERT_EQ(used, 1620); +} + +TEST(test_screenTime, activeRowsBeforeBootAreIgnored) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string dbPath = createActivityDb(dir.path()); + + time_t now = 1700000000; + time_t dayStart = 0; + time_t dayEnd = 0; + screen_time_today_bounds(now, &dayStart, &dayEnd); + + sqlite3 *db = nullptr; + ASSERT_EQ(sqlite3_open(dbPath.c_str(), &db), SQLITE_OK); + insertActivity(db, dayStart + 100, "NULL"); + insertActivity(db, now - 120, "NULL"); + sqlite3_close(db); + + ScopedEnv bootTime("SCREEN_TIME_BOOT_TIME", std::to_string((long long)(now - 300))); + + int64_t used = 0; + ASSERT_EQ(screen_time_get_usage_seconds(dbPath.c_str(), now, &used), 0); + ASSERT_EQ(used, 120); +} + +TEST(test_screenTime, dailyBoundsResolveDstAtMidnight) +{ + time_t now = 1710072000; + time_t dayStart = 0; + time_t dayEnd = 0; + + screen_time_today_bounds(now, &dayStart, &dayEnd); + + struct tm localStart; + localtime_r(&dayStart, &localStart); + ASSERT_EQ(localStart.tm_hour, 0); + ASSERT_EQ(localStart.tm_min, 0); + ASSERT_EQ(localStart.tm_sec, 0); + ASSERT_GT(dayEnd, dayStart); +} + +TEST(test_screenTime, statusIncludesMatchingDayExtraTime) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string dbPath = createActivityDb(dir.path()); + + time_t now = 1700000000; + time_t dayStart = 0; + time_t dayEnd = 0; + screen_time_today_bounds(now, &dayStart, &dayEnd); + + sqlite3 *db = nullptr; + ASSERT_EQ(sqlite3_open(dbPath.c_str(), &db), SQLITE_OK); + insertActivity(db, dayStart + 60, "600"); + sqlite3_close(db); + + std::string configDir = dir.path() + "/config"; + ASSERT_EQ(mkdir(configDir.c_str(), 0777), 0); + + std::string nowStr = std::to_string((long long)now); + setenv("SCREEN_TIME_ACTIVITY_DB", dbPath.c_str(), 1); + setenv("SCREEN_TIME_CONFIG_DIR", configDir.c_str(), 1); + setenv("SCREEN_TIME_NOW", nowStr.c_str(), 1); + + FILE *fp = fopen((configDir + "/enabled").c_str(), "w"); + ASSERT_NE(fp, nullptr); + fputs("1", fp); + fclose(fp); + + fp = fopen((configDir + "/dailyLimitMinutes").c_str(), "w"); + ASSERT_NE(fp, nullptr); + fputs("10", fp); + fclose(fp); + + ASSERT_EQ(screen_time_add_extra_minutes(5), 0); + + ScreenTimeStatus status; + ASSERT_EQ(screen_time_get_status(&status), 0); + ASSERT_TRUE(status.enabled); + ASSERT_EQ(status.used_seconds, 600); + ASSERT_EQ(status.limit_seconds, 600); + ASSERT_EQ(status.extra_seconds, 300); + ASSERT_EQ(status.remaining_seconds, 300); + ASSERT_TRUE(screen_time_launch_allowed(&status)); + + unsetenv("SCREEN_TIME_ACTIVITY_DB"); + unsetenv("SCREEN_TIME_CONFIG_DIR"); + unsetenv("SCREEN_TIME_NOW"); +} + +TEST(test_screenTime, settingsSettersPersistValues) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string configDir = dir.path() + "/config"; + + time_t now = 1700000000; + std::string nowStr = std::to_string((long long)now); + setenv("SCREEN_TIME_CONFIG_DIR", configDir.c_str(), 1); + setenv("SCREEN_TIME_NOW", nowStr.c_str(), 1); + + ASSERT_EQ(screen_time_set_enabled(true), 0); + ASSERT_EQ(screen_time_set_daily_limit_minutes(45), 0); + ASSERT_EQ(screen_time_set_extra_minutes(15), 0); + + ScreenTimeSettings settings; + ASSERT_EQ(screen_time_load_settings(&settings), 0); + ASSERT_TRUE(settings.enabled); + ASSERT_EQ(settings.daily_limit_minutes, 45); + ASSERT_EQ(settings.extra_minutes, 15); + + char today[SCREEN_TIME_DATE_LEN]; + screen_time_date_string(now, today, sizeof(today)); + ASSERT_STREQ(settings.extra_date, today); + + unsetenv("SCREEN_TIME_CONFIG_DIR"); + unsetenv("SCREEN_TIME_NOW"); +} + +TEST(test_screenTime, debugRemainingOverrideCountsDownWhenEnabled) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string dbPath = createActivityDb(dir.path()); + std::string configDir = dir.path() + "/config"; + + time_t now = 1700000000; + std::string nowStr = std::to_string((long long)now); + setenv("SCREEN_TIME_ACTIVITY_DB", dbPath.c_str(), 1); + setenv("SCREEN_TIME_CONFIG_DIR", configDir.c_str(), 1); + setenv("SCREEN_TIME_NOW", nowStr.c_str(), 1); + + ASSERT_EQ(screen_time_set_enabled(true), 0); + ASSERT_EQ(screen_time_set_daily_limit_minutes(60), 0); + ASSERT_EQ(screen_time_debug_set_remaining_seconds(15), 0); + + ScreenTimeStatus status; + ASSERT_EQ(screen_time_get_status(&status), 0); + ASSERT_TRUE(status.enabled); + ASSERT_EQ(status.remaining_seconds, 15); + ASSERT_TRUE(screen_time_launch_allowed(&status)); + + nowStr = std::to_string((long long)(now + 15)); + setenv("SCREEN_TIME_NOW", nowStr.c_str(), 1); + ASSERT_EQ(screen_time_get_status(&status), 0); + ASSERT_EQ(status.remaining_seconds, 0); + ASSERT_FALSE(screen_time_launch_allowed(&status)); + ASSERT_NE(access((configDir + "/debugExpireAt").c_str(), F_OK), 0); + + ASSERT_EQ(screen_time_get_status(&status), 0); + ASSERT_EQ(status.remaining_seconds, 3600); + + unsetenv("SCREEN_TIME_ACTIVITY_DB"); + unsetenv("SCREEN_TIME_CONFIG_DIR"); + unsetenv("SCREEN_TIME_NOW"); +} + +TEST(test_screenTime, pinIsHashedAndVerified) +{ + TempDir dir; + ASSERT_FALSE(dir.path().empty()); + std::string configDir = dir.path() + "/config"; + setenv("SCREEN_TIME_CONFIG_DIR", configDir.c_str(), 1); + + ASSERT_FALSE(screen_time_pin_configured()); + ASSERT_TRUE(screen_time_verify_pin("1234")); + + ASSERT_EQ(screen_time_set_pin("parent passphrase"), 0); + ASSERT_TRUE(screen_time_pin_configured()); + ASSERT_TRUE(screen_time_verify_pin("parent passphrase")); + ASSERT_FALSE(screen_time_verify_pin("0000")); + + FILE *fp = fopen((configDir + "/pinHash").c_str(), "r"); + ASSERT_NE(fp, nullptr); + char stored[64] = {0}; + ASSERT_NE(fgets(stored, sizeof(stored), fp), nullptr); + fclose(fp); + ASSERT_STRNE(stored, "parent passphrase"); + + ASSERT_EQ(screen_time_set_pin(""), 0); + ASSERT_FALSE(screen_time_pin_configured()); + + unsetenv("SCREEN_TIME_CONFIG_DIR"); +} diff --git a/website/docs/01-features/index.md b/website/docs/01-features/index.md index e599a9a959..37de93f316 100644 --- a/website/docs/01-features/index.md +++ b/website/docs/01-features/index.md @@ -16,6 +16,7 @@ description: Overview of the most important features + @@ -116,6 +117,12 @@ Thanks to **Activity Tracker** app you can : - Share your playtimes by taking a screenshot (press MENU+POWER - screenshot is saved in `Screenshots` folder). ::: +## Screen Time + +:::note Screen Time overview +**Screen Time** lets you set daily game time limits from Tweaks, view today's usage, add temporary extra time, and protect changes with a PIN. +::: + ## Blue light filter :::note Blue Light Filter diff --git a/website/docs/07-apps/01-included-in-onion/screen-time.md b/website/docs/07-apps/01-included-in-onion/screen-time.md new file mode 100644 index 0000000000..f7a998c28b --- /dev/null +++ b/website/docs/07-apps/01-included-in-onion/screen-time.md @@ -0,0 +1,42 @@ +--- +slug: /apps/screen-time +description: Set daily play time limits +--- + +# Screen Time +

{frontMatter.description}

+ +## Presentation + +Screen Time lets you set a daily play time limit for games launched through Onion. When the limit is reached, Onion blocks new game launches and closes active gameplay so the device returns to MainUI. + +Screen Time uses Activity Tracker play sessions to calculate today's usage, so time spent in standby or in the GameSwitcher overlay is not counted while Activity Tracker is paused. + +## Usage + +Screen Time is configured from **Apps** > **Tweaks** > **System** > **Screen time...**. + +Available settings: + +- `Unlock settings`: unlock protected settings until you leave the Screen Time menu +- `State`: enable or disable daily play time limits +- `Daily limit`: choose the total game time allowed today +- `Extra time today`: grant temporary extra time for the current day +- `PIN`: set, change, or clear the PIN used to protect Screen Time changes +- `Today used`: show the game time counted for the current day +- `Time remaining`: show the remaining time before the limit is reached + +When a PIN is configured, use `Unlock settings` once before disabling Screen Time, increasing the daily limit, adding extra time, or changing the PIN. Settings lock again when you leave the Screen Time menu. + +When setting a PIN, Onion asks you to enter it twice before saving it. + +## Limits + +Screen Time applies to game launches handled by Onion's runtime. It does not limit general access to the SD card, shell, file transfer services, or other ways of modifying Onion files. + +Someone with physical SD-card access or shell access can still remove or change Screen Time settings. Treat that level of access as administrator access. + +## Related + +- [Tweaks](./tweaks) +- [Activity Tracker](./activity-tracker) diff --git a/website/docs/07-apps/01-included-in-onion/tweaks.mdx b/website/docs/07-apps/01-included-in-onion/tweaks.mdx index b36b6ede51..dfe406c132 100644 --- a/website/docs/07-apps/01-included-in-onion/tweaks.mdx +++ b/website/docs/07-apps/01-included-in-onion/tweaks.mdx @@ -19,7 +19,7 @@ Tweaks is the backbone of Onion's configuration and personalization! With Tweaks ### features -- **System settings:** Startup behavior, auto-save and exit, vibration +- **System settings:** Startup behavior, auto-save and exit, screen time limits, vibration - **Custom shortcuts:** Single/long/double press MENU, and launch apps or tools via X or Y in MainUI - *Known limitation:* Some apps can't be launched this way (for now only Music Player / GMU is known not to support this) - **User interface:** Show/hide recents/expert tabs, theme overrides @@ -118,6 +118,53 @@ It can be MainUI, [GameSwitcher](./game-switcher), [Retroarch](./retroarch) or [
+### Screen time... + +
+ +#### Unlock settings + +
Unlock protected Screen Time settings until you leave the Screen Time menu. This option appears when a PIN is configured.
+
+
+ +#### State + +
Enable or disable daily play time limits for games launched through Onion.
+
+
+ +#### Daily limit + +
Set the total game time allowed today. New game launches are blocked when the daily limit is reached.
+
+
+ +#### Extra time today + +
Grant temporary extra time for the current day.
+
+
+ +#### PIN + +
Set, change, or clear the digits-only PIN used to protect Screen Time changes. Onion asks you to enter a new PIN twice before saving it.
+
+
+ +#### Today used + +
Shows the game time counted for the current day.
+
+
+ +#### Time remaining + +
Shows the remaining time before the daily limit is reached.
+
+
+
+ ### Low battery warning
Show a red battery icon warning in the top right corner when the battery is at or below this value.