diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8887b3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..18a43d7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.pythonPath": "/usr/bin/python", + "python.formatting.provider": "autopep8" +} \ No newline at end of file diff --git a/README.md b/README.md index e55a47a..e49ce79 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -# Important: SelfControl 3.0 Update -Auto-SelfControl will support SelfControl 3.0 in the coming release, which will be hopefully out in the next few days. - # Auto-SelfControl Small utility to schedule start and stop times of [SelfControl](http://selfcontrolapp.com). @@ -10,22 +7,62 @@ Auto-SelfControl helps you to create a weekly schedule for [SelfControl](http:// You can plan for every weekday if and when SelfControl should start and stop. -## Install -- [SelfControl](http://selfcontrolapp.com) is required and should be installed in the application directory (however, custom paths are also supported). -- Start SelfControl and backup your blacklist as it might get overridden by Auto-SelfControl. -- [Download Auto-SelfControl](../../archive/master.zip) and copy/extract it to a directory on your Mac (e.g. `~/auto-selfcontrol`). -- Edit the config.json (see [Configuration](#configuration) first). -- Open Terminal.app and navigate to the directory. (e.g. `cd ~/auto-selfcontrol`). -- Execute `/usr/bin/python setup.py install` to install the packages required to run Auto-SeltControl. -- Execute `sudo /usr/bin/python auto-selfcontrol.py` to install Auto-SelfControl with the block-schedule defined in [config.json](config.json). __Important:__ If you change [config.json](config.json) later, you have to call the installation command again or Auto-SelfControl might not start at the right time! +## Installation + +### With Homebrew + +The easiest way to install Auto-SelfControl is with [Homebrew](https://brew.sh/). Install Auto-SelfControl by running the following command in the Terminal: + + brew tap andreasgrill/utils + brew install auto-selfcontrol + +If you already have [SelfControl](http://selfcontrolapp.com), start it and **backup your blacklist** as it might get overridden by Auto-SelfControl. + +If you do not have [SelfControl](http://selfcontrolapp.com) already installed on your system, you can install it with [Homebrew Cask](https://caskroom.github.io/): + + brew cask install selfcontrol + +### Manual installation + +Download this repository to a directory on your system (e.g., `~/auto-selfcontrol/`). + + chmod +x auto-selfcontrol + +Run from this specific repository + + ./auto-selfcontrol + +Optionally create a symlink in your `/usr/local/bin` folder to access it from anywhere: + + sudo ln -s ./auto-selfcontrol /usr/local/bin/auto-selfcontrol + +## Usage + +Edit the time configuration (see [Configuration](#configuration)) first: + + auto-selfcontrol config + +When your block-schedule in [config.json](config.json) is ready, activate it by running: + + auto-selfcontrol activate + +__Important:__ If you change [config.json](config.json) later, you have to call the `auto-selfcontrol activate` command again or Auto-SelfControl will not take the modifications into account! ## Uninstall -- Delete the installation directory of Auto-SelfControl -- Execute the following command in the Terminal.app: -``` -sudo rm /Library/LaunchDaemons/com.parrot-bytes.auto-selfcontrol.plist -``` + +To remove the application (if installed with Homebrew): + + brew uninstall auto-selfcontrol + +Or, manually, by removing the directory where you installed the files. + + sudo unlink /usr/local/bin/auto-selfcontrol + rm -rf ~/auto-selfcontrol + +You also need to remove the automatic schedule by executing the following command in the Terminal: + + sudo rm /Library/LaunchDaemons/com.parrot-bytes.auto-selfcontrol.plist ## Configuration The following listing shows an example config.json file that blocks every Monday from 9am to 5.30pm and on every Tuesday from 10am to 4pm: @@ -55,7 +92,7 @@ The following listing shows an example config.json file that blocks every Monday ] } ``` -- _username_ should be the Mac OS X username. +- _username_ should be the macOS username. - _selfcontrol-path_ is the absolute path to [SelfControl](http://selfcontrolapp.com). - _host-blacklist_ contains the list of sites that should get blacklisted as a string array. Please note that the blacklist in SelfControl might get overridden and should be __backed up__ before using Auto-SelfControl. - _block-schedules_ contains a list of schedules when SelfControl should be started. @@ -103,8 +140,9 @@ The following listing shows another example that blocks twitter and reddit every ### ImportError: No module named Foundation -If you've installed Python using HomeBrew, you'll need to run Auto-SelfControl with the original Python installation from OS X: +If you've installed another version of Python (e.g., using Homebrew), you'll need to run Auto-SelfControl with the original Python installation from macOS: sudo /usr/bin/python auto-selfcontrol.py - -There are also other options, including installing `pyobjc` on your brewed Python (`pip install pyobjc`). [See this thread for alternative solutions](https://stackoverflow.com/questions/1614648/importerror-no-module-named-foundation#1616361). + +There are also other options, including installing `pyobjc` on your own Python version (`pip install pyobjc`). [See this thread for alternative solutions](https://stackoverflow.com/questions/1614648/importerror-no-module-named-foundation#1616361). + diff --git a/auto-selfcontrol b/auto-selfcontrol new file mode 100755 index 0000000..be76513 --- /dev/null +++ b/auto-selfcontrol @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Auto-SelfControl basic command-line interface + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Check if auto-selfcontrol was installed through brew and set the config.json location accordingly +if [[ $DIR == /usr/local/* ]]; then + mkdir -p /usr/local/etc/auto-selfcontrol || true + CONFIG_FILE="/usr/local/etc/auto-selfcontrol/config.json" +else + CONFIG_FILE="$DIR/config.json" +fi + +b=$(tput bold) +n=$(tput sgr0) +HELP_TEXT="Auto-SelfControl +Small utility to schedule start and stop times of SelfControl. + +Usage: ${b}$(basename "$0") ${n} + +where: + ${b}config${n} Open the schedule configuration file in a text + editor to set up weekly parameters + ${b}activate${n} Activate the automatic start/stop of SelfControl + according to schedules defined in configuration + ${b}help${n} Show this help message + +More instructions at https://github.com/andreasgrill/auto-selfcontrol" + +if [[ $1 ]]; then + case "$1" in + # Edit configuration file + config|edit|set|conf*) + # If no "config.json" found + if [[ ! -f $CONFIG_FILE ]]; then + curl -L -s "https://raw.githubusercontent.com/andreasgrill/auto-selfcontrol/master/config.json" -o $CONFIG_FILE + echo "Downloaded sample configuration in $CONFIG_FILE" + fi + echo "Opening $CONFIG_FILE" + # Opening with default editor set as $EDITOR + if [[ $EDITOR ]]; then + $EDITOR $CONFIG_FILE + # Or with default GUI text editor (txt files > Open with...) + else + open -t $CONFIG_FILE + fi + ;; + # Install plist config + activate|install) + sudo /usr/bin/python $DIR/auto-selfcontrol.py + exit 0 + ;; + -h|--help|help|*) + echo "$HELP_TEXT" + exit 0 + ;; + esac +else + echo "$HELP_TEXT" + exit 0 +fi diff --git a/auto-selfcontrol.py b/auto-selfcontrol.py index ee70fff..9b360d4 100755 --- a/auto-selfcontrol.py +++ b/auto-selfcontrol.py @@ -1,83 +1,206 @@ -#!/usr/bin/env python2.7 +#!/usr/bin/python import subprocess import os import json -import datetime -import syslog +import time +from datetime import datetime +import plistlib +import logging.handlers import traceback import sys +import re from Foundation import NSUserDefaults, CFPreferencesSetAppValue, CFPreferencesAppSynchronize, NSDate from pwd import getpwnam from optparse import OptionParser +SETTINGS_DIR = '/usr/local/etc/auto-selfcontrol' -def load_config(config_files): - """ loads json configuration files - the latter configs overwrite the previous configs - """ +# Configure global logger +LOGGER = logging.getLogger("Auto-SelfControl") +LOGGER.setLevel(logging.INFO) +handler = logging.handlers.SysLogHandler('/var/run/syslog') +handler.setFormatter(logging.Formatter( + '%(name)s: [%(levelname)s] %(message)s')) +LOGGER.addHandler(handler) + +class Api: + V2 = 2 + V3 = 3 + + +def load_config(path): + """Load a JSON configuration file""" config = dict() - for f in config_files: - try: - with open(f, 'rt') as cfg: - config.update(json.load(cfg)) - except ValueError as e: - exit_with_error("The json config file {configfile} is not correctly formatted." \ - "The following exception was raised:\n{exc}".format(configfile=f, exc=e)) + try: + with open(path, 'rt') as cfg: + config.update(json.load(cfg)) + except ValueError as exception: + exit_with_error("The JSON config file {configfile} is not correctly formatted." + "The following exception was raised:\ + \n{exc}".format(configfile=path, exc=exception)) return config -def run(config): - """ starts self-control with custom parameters, depending on the weekday and the config """ +def find_config(): + """Looks for the config.json and returns its path""" + local_config_file = "{path}/config.json".format( + path=os.path.dirname(os.path.realpath(__file__))) + global_config_file = "{path}/config.json".format( + path=SETTINGS_DIR) + + if os.path.exists(local_config_file): + return local_config_file + + if os.path.exists(global_config_file): + return global_config_file + + exit_with_error( + "There was no config file found, please create a config file.") + + +def detect_api(config): + """Return the supported API version of the SelfControl""" + try: + output = execSelfControl(config, ["--version"]) + m = re.search( + get_selfcontrol_out_pattern(r'(\d+)\.\d+(\.\d+)*'), output, re.MULTILINE) + if m is None: + exit_with_error("Could not parse SelfControl version response!") + if m and int(m.groups()[0]) >= Api.V3: + return Api.V3 + + exit_with_error("Unexpected version returned from SelfControl '{version}'".format( + version=m.groups()[0])) + except: + # SelfControl < 3.0.0 does not support the --version argument + return Api.V2 + + +def run(settings_dir): + """Load config and start SelfControl""" + run_config = "{path}/run_config.json".format(path=settings_dir) + if not os.path.exists(run_config): + exit_with_error( + "Run config file could not be found in installation location, please make sure that you have Auto-SelfControl activated/installed") + + config = load_config(run_config) + api = detect_api(config) + print("> Detected API v{version}".format(version=api)) + + if api is Api.V2: + run_api_v2(config) + elif api is Api.V3: + run_api_v3(config, settings_dir) + + +def run_api_v2(config): + """Start SelfControl (< 3.0) with custom parameters, depending on the weekday and the config""" - if check_if_running(config["username"]): - syslog.syslog(syslog.LOG_ALERT, "SelfControl is already running, ignore current execution of Auto-SelfControl.") + if check_if_running(Api.V2, config): + print "SelfControl is already running, exit" + LOGGER.error( + "SelfControl is already running, ignore current execution of Auto-SelfControl.") exit(2) try: - schedule = next(s for s in config["block-schedules"] if is_schedule_active(s)) + schedule = next( + s for s in config["block-schedules"] if is_schedule_active(s)) except StopIteration: - syslog.syslog(syslog.LOG_ALERT, "No schedule is active at the moment. Shutting down.") + print("No Schedule is active at the moment.") + LOGGER.warn( + "No schedule is active at the moment. Shutting down.") exit(0) - duration = get_duration_minutes(schedule["end-hour"], schedule["end-minute"]) + duration = get_duration_minutes( + schedule["end-hour"], schedule["end-minute"]) set_selfcontrol_setting("BlockDuration", duration, config["username"]) set_selfcontrol_setting("BlockAsWhitelist", 1 if schedule.get("block-as-whitelist", False) else 0, config["username"]) if schedule.get("host-blacklist", None) is not None: - set_selfcontrol_setting("HostBlacklist", schedule["host-blacklist"], config["username"]) + set_selfcontrol_setting( + "HostBlacklist", schedule["host-blacklist"], config["username"]) elif config.get("host-blacklist", None) is not None: - set_selfcontrol_setting("HostBlacklist", config["host-blacklist"], config["username"]) + set_selfcontrol_setting( + "HostBlacklist", config["host-blacklist"], config["username"]) # In legacy mode manually set the BlockStartedDate, this should not be required anymore in future versions # of SelfControl. if config.get("legacy-mode", True): - set_selfcontrol_setting("BlockStartedDate", NSDate.date(), config["username"]) + set_selfcontrol_setting( + "BlockStartedDate", NSDate.date(), config["username"]) # Start SelfControl - os.system("{path}/Contents/MacOS/org.eyebeam.SelfControl {userId} --install".format(path=config["selfcontrol-path"], userId=str(getpwnam(config["username"]).pw_uid))) + execSelfControl(config, ["--install"]) + + LOGGER.info( + "SelfControl started for {min} minute(s).".format(min=duration)) + + +def run_api_v3(config, settings_dir): + """Start SelfControl with custom parameters, depending on the weekday and the config""" + + if check_if_running(Api.V3, config): + print "SelfControl is already running, exit" + LOGGER.error( + "SelfControl is already running, ignore current execution of Auto-SelfControl.") + exit(2) - syslog.syslog(syslog.LOG_ALERT, "SelfControl started for {min} minute(s).".format(min=duration)) + try: + schedule = next( + s for s in config["block-schedules"] if is_schedule_active(s)) + except StopIteration: + print("No Schedule is active at the moment.") + LOGGER.warn("No schedule is active at the moment. Shutting down.") + exit(0) + block_end_date = get_end_date_of_schedule(schedule) + blocklist_path = "{settings}/blocklist".format(settings=settings_dir) -def check_if_running(username): - """ checks if self-control is already running. """ - defaults = get_selfcontrol_settings(username) - return defaults.has_key("BlockStartedDate") and not NSDate.distantFuture().isEqualToDate_(defaults["BlockStartedDate"]) + update_blocklist(blocklist_path, config, schedule) + + # Start SelfControl + execSelfControl(config, ["--install", blocklist_path, block_end_date]) + + LOGGER.info("SelfControl started until {end} minute(s).".format( + end=block_end_date)) + + +def get_selfcontrol_out_pattern(content_pattern): + """Returns a RegEx pattern that matches SelfControl's output with the provided content_pattern""" + return r'^.*org\.eyebeam\.SelfControl[^ ]+\s*' + content_pattern + r'\s*$' + + +def check_if_running(api, config): + """Check if SelfControl is already running.""" + if api is Api.V2: + username = config["username"] + defaults = get_selfcontrol_settings(username) + return defaults.has_key("BlockStartedDate") and not NSDate.distantFuture().isEqualToDate_(defaults["BlockStartedDate"]) + elif api is Api.V3: + output = execSelfControl(config, ["--is-running"]) + m = re.search( + get_selfcontrol_out_pattern(r'(NO|YES)'), output, re.MULTILINE) + if m is None: + exit_with_error("Could not detect if SelfControl is running.") + return m.groups()[0] != 'NO' + else: + raise Exception( + "Unknown API version {version} passed.".format(version=api)) def is_schedule_active(schedule): - """ checks if we are right now in the provided schedule or not """ - currenttime = datetime.datetime.today() - starttime = datetime.datetime(currenttime.year, currenttime.month, currenttime.day, schedule["start-hour"], - schedule["start-minute"]) - endtime = datetime.datetime(currenttime.year, currenttime.month, currenttime.day, schedule["end-hour"], - schedule["end-minute"]) + """Check if we are right now in the provided schedule or not.""" + currenttime = datetime.today() + starttime = datetime(currenttime.year, currenttime.month, currenttime.day, schedule["start-hour"], + schedule["start-minute"]) + endtime = datetime(currenttime.year, currenttime.month, currenttime.day, schedule["end-hour"], + schedule["end-minute"]) d = endtime - starttime for weekday in get_schedule_weekdays(schedule): @@ -100,20 +223,36 @@ def is_schedule_active(schedule): def get_duration_minutes(endhour, endminute): - """ returns the minutes left until the schedule's end-hour and end-minute are reached """ - currenttime = datetime.datetime.today() - endtime = datetime.datetime(currenttime.year, currenttime.month, currenttime.day, endhour, endminute) + """Return the minutes left until the schedule's end-hour and end-minute are reached.""" + currenttime = datetime.today() + endtime = datetime( + currenttime.year, currenttime.month, currenttime.day, endhour, endminute) d = endtime - currenttime return int(round(d.seconds / 60.0)) +def get_end_date_of_schedule(schedule): + """Return the end date of the provided schedule in ISO 8601 format""" + currenttime = datetime.today() + endtime = datetime( + currenttime.year, currenttime.month, currenttime.day, schedule['end-hour'], schedule['end-minute']) + # manually create ISO8601 string because of tz issues with Python2 + ts = time.time() + utc_offset = ((datetime.fromtimestamp( + ts) - datetime.utcfromtimestamp(ts)).total_seconds()) / 3600 + offset = str(int(utc_offset * 100)).zfill(4) + sign = "+" if utc_offset >= 0 else "-" + + return endtime.strftime("%Y.%m.%dT%H:%M:%S{sign}{offset}".format(sign=sign, offset=offset)) + + def get_schedule_weekdays(schedule): - """ returns a list of weekdays the specified schedule is active """ + """Return a list of weekdays the specified schedule is active.""" return [schedule["weekday"]] if schedule.get("weekday", None) is not None else range(1, 8) def set_selfcontrol_setting(key, value, username): - """ sets a single default setting of SelfControl for the provied username """ + """Set a single default setting of SelfControl for the provided username.""" NSUserDefaults.resetStandardUserDefaults() originalUID = os.geteuid() os.seteuid(getpwnam(username).pw_uid) @@ -124,7 +263,7 @@ def set_selfcontrol_setting(key, value, username): def get_selfcontrol_settings(username): - """ returns all default settings of SelfControl for the provided username """ + """Return all default settings of SelfControl for the provided username.""" NSUserDefaults.resetStandardUserDefaults() originalUID = os.geteuid() os.seteuid(getpwnam(username).pw_uid) @@ -138,7 +277,7 @@ def get_selfcontrol_settings(username): def get_launchscript(config): - """ returns the string of the launchscript """ + """Return the string of the launchscript.""" return ''' @@ -161,11 +300,10 @@ def get_launchscript(config): def get_launchscript_startintervals(config): - """ returns the string of the launchscript start intervals """ - entries = list() + """Return the string of the launchscript start intervals.""" for schedule in config["block-schedules"]: for weekday in get_schedule_weekdays(schedule): - yield (''' + yield ''' Weekday {weekday} Minute @@ -173,10 +311,20 @@ def get_launchscript_startintervals(config): Hour {starthour} - '''.format(weekday=weekday, startminute=schedule['start-minute'], starthour=schedule['start-hour'])) + '''.format(weekday=weekday, startminute=schedule['start-minute'], starthour=schedule['start-hour']) + +def execSelfControl(config, arguments): + user_id = str(getpwnam(config["username"]).pw_uid) + output = subprocess.check_output( + ["{path}/Contents/MacOS/org.eyebeam.SelfControl".format( + path=config["selfcontrol-path"]), user_id] + arguments, + stderr=subprocess.STDOUT + ) + return output -def install(config): + +def install(config, settings_dir): """ installs auto-selfcontrol """ print("> Start installation of Auto-SelfControl") @@ -195,6 +343,13 @@ def install(config): subprocess.call(["launchctl", "load", "-w", launchplist_path]) + print("> Save run configuration") + if not os.path.exists(settings_dir): + os.makedirs(settings_dir) + + with open("{dir}/run_config.json".format(dir=settings_dir), 'w') as fp: + fp.write(json.dumps(config)) + print("> Installed\n") @@ -204,17 +359,18 @@ def check_config(config): exit_with_error("No username specified in config.") if config["username"] not in get_osx_usernames(): exit_with_error( - "Username '{username}' unknown.\nPlease use your OSX username instead.\n" \ - "If you have trouble finding it, just enter the command 'whoami'\n" \ - "in your terminal.".format( - username=config["username"])) + "Username '{username}' unknown.\nPlease use your OSX username instead.\n" + "If you have trouble finding it, just enter the command 'whoami'\n" + "in your terminal.".format( + username=config["username"])) if not config.has_key("selfcontrol-path"): - exit_with_error("The setting 'selfcontrol-path' is required and must point to the location of SelfControl.") + exit_with_error( + "The setting 'selfcontrol-path' is required and must point to the location of SelfControl.") if not os.path.exists(config["selfcontrol-path"]): exit_with_error( - "The setting 'selfcontrol-path' does not point to the correct location of SelfControl. " \ - "Please make sure to use an absolute path and include the '.app' extension, " \ - "e.g. /Applications/SelfControl.app") + "The setting 'selfcontrol-path' does not point to the correct location of SelfControl. " + "Please make sure to use an absolute path and include the '.app' extension, " + "e.g. /Applications/SelfControl.app") if not config.has_key("block-schedules"): exit_with_error("The setting 'block-schedules' is required.") if len(config["block-schedules"]) == 0: @@ -224,7 +380,17 @@ def check_config(config): msg = "It is not recommended to directly use SelfControl's blacklist. Please use the 'host-blacklist' " \ "setting instead." print(msg) - syslog.syslog(syslog.LOG_WARNING, msg) + LOGGER.warn(msg) + + +def update_blocklist(blocklist_path, config, schedule): + """Save the blocklist with the current configuration""" + plist = { + "HostBlacklist": config["host-blacklist"], + "BlockAsWhitelist": schedule.get("block-as-whitelist", False) + } + with open(blocklist_path, 'wb') as fp: + plistlib.writePlist(plist, fp) def get_osx_usernames(): @@ -233,43 +399,49 @@ def get_osx_usernames(): def excepthook(excType, excValue, tb): - """ this function is called whenever an exception is not caught """ + """ This function is called whenever an exception is not caught. """ err = "Uncaught exception:\n{}\n{}\n{}".format(str(excType), excValue, "".join(traceback.format_exception(excType, excValue, tb))) - syslog.syslog(syslog.LOG_CRIT, err) + LOGGER.error(err) print(err) def exit_with_error(message): - syslog.syslog(syslog.LOG_CRIT, message) + LOGGER.error(message) print("ERROR:") print(message) exit(1) if __name__ == "__main__": - __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) sys.excepthook = excepthook - syslog.openlog("Auto-SelfControl") - if os.geteuid() != 0: - exit_with_error("Please make sure to run the script with elevated rights, such as:\nsudo python {file}".format( - file=os.path.realpath(__file__))) + exit_with_error("Please make sure to run the script with elevated \ + rights, such as:\nsudo python {file} \ + ".format(file=os.path.realpath(__file__))) - parser = OptionParser() - parser.add_option("-r", "--run", action="store_true", + PARSER = OptionParser() + PARSER.add_option("-r", "--run", action="store_true", dest="run", default=False) - (opts, args) = parser.parse_args() - config = load_config([os.path.join(__location__, "config.json")]) + (OPTS, ARGS) = PARSER.parse_args() - if opts.run: - run(config) + if OPTS.run: + run(SETTINGS_DIR) else: - check_config(config) - install(config) - if not check_if_running(config["username"]) and any(s for s in config["block-schedules"] if is_schedule_active(s)): + CONFIG_FILE = find_config() + CONFIG = load_config(CONFIG_FILE) + check_config(CONFIG) + + api = detect_api(CONFIG) + print("> Detected API v{version}".format(version=api)) + + install(CONFIG, SETTINGS_DIR) + schedule_is_active = any( + s for s in CONFIG["block-schedules"] if is_schedule_active(s)) + + if schedule_is_active and not check_if_running(api, CONFIG): print("> Active schedule found for SelfControl!") print("> Start SelfControl (this could take a few minutes)\n") - run(config) + run(SETTINGS_DIR) print("\n> SelfControl was started.\n")