Create custom macOS menu bar apps using Hammerspoon

The lasting appeal of Macs to software engineers is:

I’m a system tweaker by nature but, for the most part, I’m at peace with the limitations of macOS because I know that they exist to protect us from our more careless instincts.

Still, the inability to easily customize the menu bar gnaws at me. It’s the one interface element that remains constant as you navigate across applications. Yet somehow it’s both underutilized (lots of empty space, especially on big monitors) and overutilized (needlessly cluttered by tasteless “updaters” and other daemons). Thankfully, you can now reorder menu bar apps (command + drag) and you can often remove the ones you don’t want. But why can’t I easily create my own?

If you search the web for “custom menu bar app” or something like it, you’ll probably be pointed towards BitBar—which comes tantalizingly close to delivering on its promise to “put the output from any script or program in your Mac OS X Menu Bar.” Unfortunately, it’s basically abandonware and has two big issues that render it unusable for me: (1) the menu bars you create are maddeningly misaligned by one pixel and (2) it’s incompatible with menu bar hiders like Bartender and Dozer.

I kept searching, but everything I found was either hopelessly broken or entirely too opinionated. I just wanted to create a simple Slack integration into the menu bar (more on that later), but it seemed my only recourse was to build an entire app from scratch.

Enter the hammer. And the spoon

I had run across Hammerspoon more than a few times while stalking other people’s dotfiles, but I had pigeonholed it as a simple automation tool for launching applications and tiling windows. In reality it is immensely powerful and feels wonderfully native, with a rich API that leverages the power of the Lua scripting language.

The first thing you might notice when checking out Hammerspoon is that its documentation and GitHub page, while thorough, are pretty buttoned-up. You won’t find much example code and screenshots are basically non-existent. Don’t let that deter you, though—dive into the docs and you’ll quickly be inspired to create your first “spoon.” Plus, learning a new language is always fun and Lua is a quick study.

The interruption metric

That brings us to why I wanted to create a custom menu bar app. This post isn’t about Slack, but if you use Slack at work you probably have a love/hate relationship with it: it’s the fun and immediacy of a chat room—shoved down your throat all day, every day. Like many of us, I’ve realized that the only way to truly focus at work is to close Slack.

Still, it’s important for me to have some sense of when I might be blocking the work of my colleagues. An indicator that shows a count of my unread mentions and DMs strikes the right balance, at least for me, between being able to focus and knowing I’m able to be interrupted if there’s something important I need to attend to. Of course, Slack’s API is rich enough that you can probably assemble your own “interruption metric” based on how you and your team use Slack.

The task is beginning to take shape: I want a simple indicator in my menu bar that shows the number of unread mentions and DMs I have. When I click on it, it should open Slack. That’s it.

Building a Slack notifier for the menu bar

I’d previously implemented this using a shell script, so I had a pretty good starting point. First we create a function to fetch the necessary counts from Slack’s API:

local slackToken = 'xoxp-xxxx'
local fetchUrl = 'https://slack.com/api/users.counts?token=' .. slackToken

local function fetchData()
    hs.http.asyncGet(fetchUrl, nil, callback)
end

Pretty simple. You’ll notice we’re passing a callback to fetchData, which will be responsible for doing something with the response JSON. Let’s write that:

local function callback(status, body)
    -- errors get a status of -1
    if status < 0 then
        return
    end

    -- parse json response
    local json = hs.json.decode(body)
    local count = 0

    -- loop through channels and add up mention_count
    for _, channel in pairs(json.channels) do
        count = count + channel.mention_count
    end

    -- loop through dms and add up dm_count
    for _, dm in pairs(json.ims) do
        count = count + dm.dm_count
    end

    -- update the menu bar
    updateCount(count)
end

Hammerspoon provides a ton of utility functions on the hs global variable, so anytime you feel a bit stuck, browse the docs and you’ll probably find what you need. For instance, there’s a JSON parser available (hs.json.decode) that will convert the JSON response string into a Lua table. From there, we can generate a count with a couple of quick loops. (Lua is a pretty lean language, so if you find yourself wanting a little extra help, I can recommend the penlight library—pl.pretty is especially useful.)

At the end of this function, we call updateCount, which does the work of updating the menu bar. Let’s do that.

local function updateCount(count)
    if count > 0 then
        menu:setTitle(count)
    else
        menu:setTitle('')
    end
end

Ok, that seemed easy. But what is menu? That must be complicated.

local menu = hs.menubar.new()

Oh. This is the payoff of Hammerspoon’s API. With some simple scripting, we’ve created a highly personalized, native-looking integration into the menu bar. Now we need a timer to periodically refresh the menu bar.

local timer = hs.timer.new(60, fetchData)
timer:start()

Boom. But wait, we can make this even better: The best menu bar apps have nice icons and ours should, too. Hammerspoon of course provides a setIcon method on menubar to help us provide an image icon, so we’ll just grab a Slack icon from the web and—whoa, what’s this???

imageData … can be one of the following …
– A string beginning with ASCII: which signifies that the rest of the string is interpreted as a special form of ASCII diagram, which will be rendered to an image and used as the icon.

Ok, drop everything, we’re doing this. Check out this craziness:

An example of how to create vector art from ASCII characters

With simple ASCII character progression, we can define vector art! Let’s make a Slack icon (the old one, of course).

The Slack icon created with ASCII characters

Amazingly, we can use this plain text as our menu bar icon:

local iconAscii = [[ASCII:
............
............
....AD......
..F.....PQ..
..I.........
..........G.
..........H.
.K..........
.N..........
.........L..
..BC.....M..
......SR....
............
............
]]

local menu = hs.menubar.new():setIcon(iconAscii)

And now we have our menu bar app, giving us a Slack unread count, updated every 60 seconds. (I have no idea what Slack’s API rate limits are, but this seems to work.)

The finished product

The last piece is opening Slack when we click on the menu bar. Again, Hammerspoon’s API makes this easy:

local function onClick()
    hs.application.launchOrFocus('Slack')
end

local menu = hs.menubar.new():setClickCallback(onClick):setIcon(iconAscii)

Finish line! Naturally, I’ve glossed over some glue code required to piece all this together, so please take a look at my dotfiles for a working example. Hopefully this has given you more than a few ideas about how to take back your menu bar and make it more useful in your day-to-day work.