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
- 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 - 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.
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:
- Change my terminal (Alacritty) theme
- Change my Emacs theme
- Change some yabai & spacebar colour properties
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