Calum MacRae /home/cmacrae

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

| 836 words | nix darwin

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.

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 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
            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)"
            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
            alacrittySwitchTheme() {
              if [[  $MODE == "dark"  ]]; then
            	  cp -f $DIR/alacritty.yml $DIR/live.yml
              elif [[  $MODE == "light"  ]]; then
            	  cp -f $DIR/light.yml $DIR/live.yml
            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"
            emacsSwitchTheme $@
            spacebarSwitchTheme $@
            alacrittySwitchTheme $@
            yabaiSwitchTheme $@

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


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

  homeDir = builtins.getEnv("HOME")


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" "">
<plist version="1.0">

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

Unless otherwise noted, the content of this site is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License (CC BY-NC-SA 4.0).