Adding Verbal Warnings and Time Quotas Configurable by Account for Forced Logouts in Programmatic MacOS Parental Controls

Building upon the original Restoring Forced Logouts Removed from MacOS Parental Controls, the original refinements, and the New Year updates to them, I have since added code to add the following features.

  1. Give two verbal warnings about (approximate) time remaining before forced logout; and
  2. Make the minutes allowed per day a configurable property which can be customized for individual accounts.

The first was added as a courtesy to our boys. The second was added to allow cutting back or eliminating hours for each boy individually if they didn’t complete their list of basic daily tasks to be done to be allowed the full amount of computer time for the following day, or to increase it for sick days and the like. Making it a property also allows changing it on the fly, without having to alter the code, and also eliminates some very inconvenient hard-coded values.

The properties were added to the end of the application.properties file. At the same time, I added a missing JPA hibernate dialect property that was needed to get the application to work properly with my MariaDB instance.

spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQLDialect
# Map properties
users.minutes.[kid-login-1]=60
users.minutes.[kid-login-2]=60

Here, [kid-login-1] and [kid-login-2] are the login ID’s for our boys. The default (for us) values of 60 minutes are set for both accounts.

These properties are mapped to a new ConfigProperties.java class in the model package for the application, which looks like the following.

package biz.noip.johnwatne.logtimer.model;

import java.util.Map;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "users")
public class ConfigProperties {
    private Map<String, Long> minutes;

    public Map<String, Long> getMinutes() {
        return minutes;
    }

    public void setMinutes(Map<String, Long> minutes) {
        this.minutes = minutes;
    }
}

We see that the “users” prefix in the “ConfigurationProperties” annotation for the class indicates that those properties in the application.properties file that start with “users.” specify values to use within this configuration class. The configuration class contains a single attribute, “minutes” which maps String values for login IDs to Long values for minutes per day allowed. This ties to the users.minutes.[username]=[minutes] properties in the file. The username part of the property is added as a key to the Map, and the value assigned to the property is set to the corresponding value in the Map.

With the new ConfigProperties class and corresponding properties added, the remaining changes were made to the LogtimerRunner class.

First, the ConfigProperties bean needed to be added as an attribute in LogtimerRunner as follows:

    @Autowired
    private ConfigProperties configProperties;

Then, the logoutIfKidLoggedInTooLong method was modified to

  1. replace the switch statement with hard-coded cases for each child’s name with an iteration of all properties defined in ConfigProperties.getMinutes(), and
  2. Add a new call to the standard MacOS “say” command to notify of time left when there are 10 or fewer minutes left until the forced logout.

The updated method is as follows.

    /**
     * Logs out the specified user if
     * <ol>
     * <li>they are a named child within the family, and</li>
     * <li>the number of minutes they have been logged in exceeds the maximum
     * limit.</li>
     * </ol>
     *
     * @param user
     *            the user whose login time is being checked.
     * @param minutes
     *            the number of minutes the user has been logged in.
     * @throws IOException
     *             if an I/O error occurs.
     * @throws InterruptedException
     *             if an thread is interrupted.
     */
    public void logoutIfKidLoggedInTooLong(final String user,
            final long minutes) throws IOException, InterruptedException {
        if (configProperties != null) {
            final Map<String, Long> map = configProperties.getMinutes();

            if (map != null) {
                Long maxMinutes = map.getOrDefault(user, null);

                if (maxMinutes != null) {
                    LOGGER.debug("maxMinutes for " + user + ": " + maxMinutes
                            + "; minutes: " + minutes);

                    if (minutes > Math.max(maxMinutes - 1, 0)) {
                        LOGGER.info("Logging out user " + user);
                        ProcessBuilder processBuilder = new ProcessBuilder(
                                "/bin/sh", "-c", "./logout-user.sh " + user);
                        processBuilder.directory(new File("/Users/John"));
                        Process process = processBuilder.start();

                        try (final BufferedReader reader =
                                new BufferedReader(new InputStreamReader(
                                        process.getInputStream()))) {
                            String line;

                            while ((line = reader.readLine()) != null) {
                                LOGGER.info(line);
                            }

                            LOGGER.debug(Integer.toString(process.waitFor()));
                        }
                    } else if (minutes > Math.max(maxMinutes - 11, 0)) {
                        final long minutesToGo = maxMinutes - minutes;
                        final String notification = "Logging out user " + user
                                + " in about " + minutesToGo + " minutes";
                        LOGGER.info(notification);
                        ProcessBuilder processBuilder = new ProcessBuilder(
                                "/bin/sh", "-c", "say '" + notification + "'");
                        Process process = processBuilder.start();
                        LOGGER.debug(Integer.toString(process.waitFor()));
                    }
                } else {
                    LOGGER.debug("Time limits not enforced for user " + user);
                }
            }
        } else {
            LOGGER.error("ERROR!! UNABLE TO READ CONFIGURATION PROPERTIES");
        }
    }

I also fixed an off-by-one error in checking the count of login processes for each user in the following method. The value of getCountOfLoginProcessesForUser(persistedLogin.getUsername()) is now checked if < 1, rather than < 2.

    /**
     * Sets the logout time for users no longer logged in, if not already set,
     * to the specified logout time.
     *
     * @throws IOException
     *             if an I/O error occurs.
     */
    private void setLogoutTimesForUsersNotLoggedIn() throws IOException {
        // Do final pass, setting logout times for users no longer logged in.
        for (Logins persistedLogin : loginsRepository.findAll()) {
            if ((persistedLogin.getLogout() == null)
                    && (getCountOfLoginProcessesForUser(
                            persistedLogin.getUsername()) < 1)) {
                // User logged out but not yet indicated as such, so set logout
                // time to now.
                LOGGER.info("*** " + persistedLogin.getUsername()
                        + " logged out; setting logout to now");
                persistedLogin.setLogout(LocalDateTime.now());
                loginsRepository.save(persistedLogin);
                LOGGER.info("*** Record updated: " + persistedLogin);
            }
        }
    }

Finally, to reduce the amount of logging by eliminating info-level logging I had used during initial development, I changed the calculation of lines read from the BufferedReader for the results of the call to get the count of login processes for user in the method getCountOfLoginProcessesForUser(final String user). I removed the logging of each line of output by simplifying

lines = reader.lines().peek(e -> LOGGER.info(e)).count();

to

lines = reader.lines().count();

I hope there might be others who might find these changes to add flexibility to the program helpful. I may consider adding this to GitHub at some point.

Further New Year Updates for Forced Logouts in Programmatic MacOS Parental Controls

Early this January, 2022, Sara and I noticed that it seemed like the boys could stay on the computer much longer than what the forced time quotas my refined program enforced. Investigating the code revealed something I should have anticipated, having been part of a “Y2K readiness team” earlier in my career.

The problem was that my “UserLoginTime” objects were obtained given the output from the bash shell’s “who” command, which only shows the login time for users using a two-digit month and two-digit day, but no year. When I constructed UserLoginTimes that were holdovers from old logins at the end of December 2021, the code was assuming that it was December of the current year – nearly a year in  the future. So, when checking whether the total time online for the boys was greater than the specified value, the code was subtracting a future date and time from the current date and time, resulting in a negative “elapsed time”, thus never timing out.

The original code for the constructor, with the error of always assuming the login date was in the current year, was as follows.

 

    /**
     * Constructs a UserLoginTime for the information in the passed line from
     * &quot;who&quot; output.
     *
     * @param line
     *            a line of output from the MacOS / BSD &quot;who&quot; command.
     */
    public UserLoginTime(final String line) {
        final LocalDateTime now = LocalDateTime.now();
        final int year = now.getYear();
        final String[] split = line.split("[\\s]+");
        this.setUser(split[0]);
        this.setTty(split[1]);
        final StringBuilder builder = new StringBuilder();
        builder.append(year);

        for (int i = 2; (i < Math.min(split.length, 5)); i++) {
            builder.append(" ");
            builder.append(split[i]);
        }

        this.setLoginTime(
                LocalDateTime.parse(builder.toString(), DATE_TIME_FORMATTER));
    }

To fix the problem, I simply subtract a year from the originally calculated login date, by adding the following to the end of the constructor, after the initial call to setLoginTime(…) that previously ended the constructor code, as follows:

        // Check for change of year.
        final LocalDateTime originalLoginTime = this.getLoginTime();

        if (originalLoginTime.isAfter(now)) {
            LOGGER.warn("originalLoginTime: "
                    + originalLoginTime.format(DATE_TIME_FORMATTER));
            this.setLoginTime(originalLoginTime.minusYears(1L));
            LOGGER.warn("adjusted login time: "
                    + this.getLoginTime().format(DATE_TIME_FORMATTER));
        }

I suppose that, if I wanted to cover all possibilities, I would use a while loop and keep subtracting a year from the login time until the result of calling it’s “isAfter(now)” method was false. However, the kids’ login accounts would never be logged in for a span covering more than two calendar years. With power outages, OS updates, and reboots either to clear problems or to boot up an older version of MacOS to allow the boys to play an old 32-bit game, the computer wouldn’t stay online that long anyway.

One final refinement I needed to make was to handle the case where my program was unable to connect to the database, in which case it was throwing Exceptions upon startup, and never doing any kind of check. I added some code to the main application to call an alternate method if an Exception was thrown when starting up the application, by adding the Exception handling block shown below, added to the LogtimerApp’s main method.

    public static void main(final String[] args) {
        try {
            SpringApplication.run(LogtimerApp.class, args);
        } catch (final Exception e) {
            LOGGER.error(
                    "Unable to run LogtimerApp application; attempt to check logins without using database.",
                    e);
            LogtimerRunner.checkCurrentLoginsOnly();
        }
    }

I then added the following code to LogtimerRunner, recycling code I had used on the early version of the program that did not store history in the database, requiring the first refinement.

    /**
     * Fallback method to check only the currently logged in sessions, to be
     * called when unable to obtain a database connection on startup.
     */
    public static void checkCurrentLoginsOnly() {
        LOGGER.info("*** checkCurrentLoginsOnly ***");
        LogtimerRunner runner = new LogtimerRunner();

        try {
            Map<String, List<UserLoginTime>> userLogins;
            userLogins = runner.getUserLogins();

            if (userLogins != null) {
                LogtimerRunner.checkCurrentLogins(userLogins);
            }
        } catch (final Exception e) {
            LOGGER.error("Error checking current logins", e);
        }
    }

    /**
     * Check the given Map of users to Lists of user login times and log out
     * those who have exceeded their quota.
     *
     * @param userLogins
     *            a Map of Lists of login times for each user.
     */
    public static void checkCurrentLogins(
            final Map<String, List<UserLoginTime>> userLogins) {
        LOGGER.debug("*** User login lists ****");
        final LocalDateTime now = LocalDateTime.now();

        for (final Entry<String, List<UserLoginTime>> entry : userLogins
                .entrySet()) {
            final String user = entry.getKey();
            final List<UserLoginTime> loginsForUser = entry.getValue();
            final UserLoginTime lastLoginForUser =
                    Collections.max(loginsForUser);
            LOGGER.info("*** Maximum login: " + lastLoginForUser);
            final LocalDateTime fromTemp =
                    LocalDateTime.from(lastLoginForUser.getLoginTime());
            final long minutes = fromTemp.until(now, ChronoUnit.MINUTES);
            LOGGER.debug("Elapsed time: " + minutes + " minutes");

            try {
                (new LogtimerRunner()).logoutIfKidLoggedInTooLong(user,
                        minutes);
            } catch (final IOException e) {
                LOGGER.error("I/O error", e);
            } catch (final InterruptedException e) {
                LOGGER.warn("Thread interrupted", e);
            }
        }
    }

I hope readers may find this information helpful, either if using this program, or to offer some ideas if they find themselves running into similar problems with other programs they maintain.