Keeping Up Appearances: Non-native macOS Light/Dark Mode

By Calum MacRae

May 3, 2020

I like the auto light/dark mode feature of macOS, it's nice to have your system change in the evening as the sun sets - less strain on the eyes if a night of hacking is ahead. But I spend most of my time either in a terminal or in Emacs, so don't really see much of a difference.

I was wondering if I could somehow invoke arbitrary commands when the system changed, that way I could run whatever I need to change from a light theme to a dark theme in other applications.

Turns out there are two system behaviours we can exploit to achieve this

  1. Running defaults read -g AppleInterfaceStyle when dark mode is active will return a zero exit code, and a non-zero exit code when it's not
  2. When the mode changes, the system modifies the ~/Library/Preferences/.GlobalPreferences.plist file

So with these we've got a mode indicator and an event trigger. These can be strung together with a checking function and a filesystem event watcher. If that sounds hacky to you, you're absolutely right.

Here's what it looks like

The solution

I actually spent a couple days writing a little service in Go that would achieve this. In fact, a large portion of the work had already been done, so I just spent time cleaning it up and making it a reliable tool to execute shell commands/scripts, with an acompanying Nix service module and overlay. I even took the oportunity to draw a nice logo for it to sit nicely in the pimped out, badge laden README.

But alas, the project was very short lived. Someone immediately opened an issue on the repo pointing out that rather than using a dedicated program to watch for events, launchd actually has a WatchPaths parameter. It takes an array of strings representing filesystem paths. When a change is observed for one of those paths, any execution directives in the manifest are evaluated.

Although I was a little disheartened to have a couple days effort disipate in a mist of uselessnes, I was in fact delighted. This'd be really easy to implement in my system configuration with Nix.

Here's the Nix expression I'm using to achieve the following:

launchd.user.agents.nighthook = {
  serviceConfig = {
    Label = "ae.cmacr.nighthook";
    WatchPaths = [ "${homeDir}/Library/Preferences/.GlobalPreferences.plist" ];
    EnvironmentVariables = {
      PATH = (replaceStrings ["$HOME"] [homeDir] config.environment.systemPath);
    };
    ProgramArguments =  [
      ''${pkgs.writeShellScript "nighthook-action" ''
            if defaults read -g AppleInterfaceStyle &>/dev/null ; then
              MODE="dark"
            else
              MODE="light"
            fi
            
            emacsSwitchTheme () {
              if pgrep -q Emacs; then
            	  if [[  $MODE == "dark"  ]]; then
            	      emacsclient --eval "(cm/switch-theme 'doom-one)"
            	  elif [[  $MODE == "light"  ]]; then
            	      emacsclient --eval "(cm/switch-theme 'doom-solarized-light)"
            	  fi
              fi
            }
            
            spacebarSwitchTheme() {
              if pgrep -q spacebar; then
            	  if [[  $MODE == "dark"  ]]; then
            	      spacebar -m config background_color 0xff202020
            	      spacebar -m config foreground_color 0xffa8a8a8
            	  elif [[  $MODE == "light"  ]]; then
            	      spacebar -m config background_color 0xffeee8d5
            	      spacebar -m config foreground_color 0xff073642
            	  fi
              fi 
            }
            
            
            alacrittySwitchTheme() {
              DIR=/Users/cmacrae/.config/alacritty
              if [[  $MODE == "dark"  ]]; then
            	  cp -f $DIR/alacritty.yml $DIR/live.yml
              elif [[  $MODE == "light"  ]]; then
            	  cp -f $DIR/light.yml $DIR/live.yml
              fi
            }
      
            yabaiSwitchTheme() {
              if [[  $MODE == "dark"  ]]; then
                yabai -m config active_window_border_color "0xff5c7e81"
                yabai -m config normal_window_border_color "0xff505050"
                yabai -m config insert_window_border_color "0xffd75f5f"
              elif [[  $MODE == "light"  ]]; then
                yabai -m config active_window_border_color "0xff2aa198"
                yabai -m config normal_window_border_color "0xff839496 "
                yabai -m config insert_window_border_color "0xffdc322f"
              fi
            }
            
            emacsSwitchTheme $@
            spacebarSwitchTheme $@
            alacrittySwitchTheme $@
            yabaiSwitchTheme $@
      ''}''
    ];
  };
};

This expression yields a ae.cmacr.nighthook launchd user agent service. Let's go over a couple pieces.

homeDir

This is simply a convenience variable I use throughout my configuration, who's result is my home directory path (as you might've guessed…)

let
  homeDir = builtins.getEnv("HOME")

EnvironmentVariables.PATH

In order to be able to execute scripts from this LaunchAgent, using various binaries installed on my system, I needed to populate the PATH variable. Easy enough to simply use my user's shell PATH with config.environment.systemPath, but that uses $HOME as part of its value. When the launchd manifest is evaluated, it won't expand variables. So, we can use builtins.replaceStrings to interpolate the homeDir value.

replaceStrings's signature looks like:

replaceStrings ["replace me"] ["with this"] "replace me in this string"

ProgramArguments with pkgs.writeShellScript

A common convention for launcd manifests is to use ProgramArguments for any execution directives. Even if you're simply calling a single executable path, with no arguments. Its value is an <array> of <string> objects. I wanted to invoke a script, so I used the pkgs.writeShellScript function to write the script contents in place. The resulting script will be written to the Nix store, and its path will be interpolated into our inline string, contained in our list.

The resulting manifest

Using the above expression in my configuration produces ~/Library/LaunchAgents/ae.cmacr.nighthook.plist which looks like:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/Users/cmacrae/.nix-profile/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin</string>
    </dict>
    <key>Label</key>
    <string>ae.cmacr.nighthook</string>
    <key>ProgramArguments</key>
    <array>
      <string>/nix/store/zmq3xxhvx88ncngnf9kw4wg5n0h6r2y4-nighthook-action</string>
    </array>
    <key>WatchPaths</key>
    <array>
      <string>/Users/cmacrae/Library/Preferences/.GlobalPreferences.plist</string>
    </array>
  </dict>
</plist>

So, there you have it! Until next time, happy hacking :)

Posted on:
May 3, 2020
Length:
4 minute read, 847 words
Tags:
darwin nix
See Also:
A Nix overlay for Emacs 27 with the 'emacs-mac' patches
spacebar module in nix-darwin now generally available
yabai module in nix-darwin now generally available