Author Avatar Image
Alexander Reelsen

Backend developer, productivity fan, likes the JVM, full text search, distributed databases & systems

Creating a productive osx environment - Hammerspoon
Nov 8, 2016
9 minutes read

This post will introduce hammerspoon, an automation framework for macOS.

If you are interested in more osx productivity blog posts, also check out my initial blog post on osx productivity.

What is Hammerspoon

We can just quote the hammerspoon website here:

One of the advantages of hammerspoon is the possibility to get rid of many small helper tools, that you have installed anyway and let hammerspoon do the work.

First things first, I apologize to every lua writer in advance - how the following code snippets look like. Definitely not my first language.

Installation & configuration

Either download the latest release from the official website or run brew cask install hammerspoon, if you are using cask.

You can start Hammerspoon, configure it to be an application or reside in your dock and start creating a configuration file for. This configuration file is put in ~/.hammerspoon/init.lua.

So, what can I do with it?

Short version: Anything, merely limited by your imagination and existing workflows.

Long version: Lets check the documentation as well as the Getting Started Guide.

They are dozens of things you can do, reacting to USB events, network connects, binding arbitrary keyboard shortcuts are the simple things. But you can also have custom menu bars in your dock, implement custom hammerspoon:// URLS, networking checks, execute OSA/apple scripts, run your own webserver and much more.

Window resizing with keyboard shortcuts

My first trying out was the resizing of windows based on keyboard shortcuts. Known tools to do this are Spectacle, ShiftIt or SizeUp - some of them with some more functionality, that I honestly don’t need.

--[[ function factory that takes the multipliers of screen width
and height to produce the window's x pos, y pos, width, and height ]]
function baseMove(x, y, w, h)
    return function()
        local win = hs.window.focusedWindow()
        local f = win:frame()
        local screen = win:screen()
        local max = screen:frame()

        -- add max.x so it stays on the same screen, works with my second screen
        f.x = max.w * x + max.x
        f.y = max.h * y
        f.w = max.w * w
        f.h = max.h * h
        win:setFrame(f, 0)
    end
end

-- feature spectacle/another window sizing apps
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'Left', baseMove(0, 0, 0.5, 1))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'Right', baseMove(0.5, 0, 0.5, 1))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'Down', baseMove(0, 0.5, 1, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'Up', baseMove(0, 0, 1, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, '1', baseMove(0, 0, 0.5, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, '2', baseMove(0.5, 0, 0.5, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, '3', baseMove(0, 0.5, 0.5, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, '4', baseMove(0.5, 0.5, 0.5, 0.5))
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'M', hs.grid.maximizeWindow)

As you can see a fair share of keys are bound here. Using Ctrl+Alt+Cmd and one of the cursor keys ensures the window always takes up half of the screen and the arrow decides which half should be taken. Left Arrow means left half, upper arrow means upper half. One of my workscreen on my external monitor is usually half the browser and half a terminal window.

Using Ctrl+Alt+Cmd and one of the numbers configures the window to be one fourth of the monitor size and allows your to configure everything from top-left to bottom-right.

Last but not least, one of the most common combinations is Ctrl+Alt+Cmd+M to maximize the window.

Improvement hint: One could remember the original size when hitting Ctrl+Alt+Cmd+M for a window and thus switch between fullscreen and the old window size instead of always going full screen.

Locking the screen

This is a no-brainer. I need to lock my screen when the notebook is somewhere in an office, whenever I go away from it. So let’s use a keyboard shortcut.

-- lock screen shortcut
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'L', function() hs.caffeinate.startScreensaver() end)

More key bindings

In order to quickly jump to much-used applications, some shortcuts might be useful as well. This prevents tabbing through the open applications. I use this mainly for iTerm2 and IntelliJ - so it is hard to switch, as the Cmd+Tab is really hard wired in my brain.

-- quick jump to important applications
hs.grid.setMargins({0, 0})
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'J', function () hs.application.launchOrFocus("Intellij IDEA") end)
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'C', function () hs.application.launchOrFocus("Google Chrome") end)
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'S', function () hs.application.launchOrFocus("Slack") end)
-- even though the app is named iTerm2, iterm is the correct name
hs.hotkey.bind({'ctrl', 'alt', 'cmd'}, 'K', function () hs.application.launchOrFocus("iTerm") end)

-- reload config
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "R", function()
  hs.reload()
  hs.notify.new({title="Hammerspoon config reloaded", informativeText="Manually via keyboard shortcut"}):send()
end)

Also, there is one keyboard shortcut, that reloads my hammerspoon configuration and shows a notification when this happens. If you hit the keyboard combination and the window does not show, I know there is a mistake in my configuration - which means it would be time to open up the hammerspoon console and see what happened.

Improvement hint: You could also implement a filewatcher for the .hammerspoon directory and reload the configuration based on that. I usually want full control over that myself, as editing several files might be complex with that.

Changing Wireless Networks

What should happen if your network changes? I do have one network that I consider secure (and maybe even that is a mistake given the flood of devices in it), and that’s the one at home. If I am logging into a different network, I want a check to run, that controls if there are any applications listening on external IPs.

Note: This example uses osquery, a nice tool to use SQL-style queries to gather system information. You should make sure you install it from the website and not via brew, so you have the latest version installed.

local trustedSSID = 'YOUR SSID GOES HERE'

-- function copied from http://lua-users.org/wiki/LuaCsv
function ParseCSVLine (line,sep)
  local res = {}
  local pos = 1
  sep = sep or ','
  while true do
    local c = string.sub(line,pos,pos)
    if (c == "") then break end
    if (c == '"') then
      -- quoted value (ignore separator within)
      local txt = ""
      repeat
        local startp,endp = string.find(line,'^%b""',pos)
        txt = txt..string.sub(line,startp+1,endp-1)
        pos = endp + 1
        c = string.sub(line,pos,pos)
        if (c == '"') then txt = txt..'"' end
        -- check first char AFTER quoted string, if it is another
        -- quoted string without separator, then append it
        -- this is the way to "escape" the quote char in a quote. example:
        --   value1,"blub""blip""boing",value3  will result in blub"blip"boing  for the middle
      until (c ~= '"')
      table.insert(res,txt)
      assert(c == sep or c == "")
      pos = pos + 1
    else
      -- no quotes used, just look for the first separator
      local startp,endp = string.find(line,sep,pos)
      if (startp) then
        table.insert(res,string.sub(line,pos,startp-1))
        pos = endp + 1
      else
        -- no separator found -> use rest of string and terminate
        table.insert(res,string.sub(line,pos))
        break
      end
    end
  end
  return res
end


function getBoundProcesses()
  -- returns sth like
  -- username|name|path|pid|address|port
  -- alr|java|/Library/Java/JavaVirtualMachines/jdk1.8.0_111.jdk/Contents/Home/bin/java|29640|192.168.178.178|9300
  -- alr|java|/Library/Java/JavaVirtualMachines/jdk1.8.0_111.jdk/Contents/Home/bin/java|29640|192.168.178.178|9200
  local query = [[/usr/local/bin/osqueryi --header=false --csv "SELECT username, name, path, p.pid, address,port FROM listening_ports l, processes p, users u WHERE l.pid=p.pid  AND u.uid=p.uid  AND address != '0.0.0.0' AND address != '127.0.0.1' AND port > 0;"]]
  local handle = io.popen(query)

  msg = {}
  while true do
    local line = handle:read("*line")
    if line == nil then break end
    local x = ParseCSVLine(line, '|')
    table.insert(msg, string.format("%s[%s][%s:%s]", x[2], x[4], x[5], x[6]))
  end

  handle:close()
  return table.concat(msg, ', ')
end

function ssidChangedCallback()
  local currentSSID = hs.wifi.currentNetwork()
  if trustedSSID ~= currentSSID and currentSSID ~= nil then
    msg = getBoundProcesses()
    if msg ~= "" then
      hs.notify.new({title="Open ports/pids on insecure network " .. currentSSID .. "!", informativeText=msg}):send()
    end
  end
end

local wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
wifiWatcher:start()

Let’s ignore the ParseCSVLine function and look at the getBoundProcesses() function. This one executes a query that returns which applications listen on external interfaces. If the network is not the homework and there are processes listening, a notification is shown.

Reacting on USB events

Another neat functionality is the ability to listen for USB connect/disconnect events - which is also another way to find out if you are in the office. I do have an external US layout keyboard, where as my mac has a german layout - US keyboard layout is so much better for programming.

So, all this snippet does, is creating an USB watcher, that checks if my keyboard was added or removed and switches the keyboard layout using a command line tool called keyboardSwitcher

function configureKeyboard(data)
  local keyboardSwitcher = '/usr/local/bin/keyboardSwitcher'
  local isKeyboardAffected = data.vendorID == 1452 and data.productID == 4102
  --logger.df("eventType %s, pname %s, vname %s, vId %s, pId %s, keyboardAffected %s", data.eventType, data.productName, data.vendorName, data.vendorID, data.productID, isKeyboardAffected)
  if isKeyboardAffected and data.eventType == "added" then
    os.execute(keyboardSwitcher .. ' select U.S.')
  end
  if isKeyboardAffected and data.eventType == "removed" then
    os.execute(keyboardSwitcher .. ' select German')
  end
end

local keyboardWatcher = hs.usb.watcher.new(configureKeyboard)
keyboardWatcher:start()

You can use the logger statement to find out what you vendor and product ids are.

Mute on sleep

You don’t want to be surprised when you open up your notebook and the music is blasting. So, just make sure, everything is muted on wake up.

function muteOnWake(eventType)
  if (eventType == hs.caffeinate.watcher.systemDidWake) then
    local output = hs.audiodevice.defaultOutputDevice()
    output:setMuted(true)
  end
end
caffeinateWatcher = hs.caffeinate.watcher.new(muteOnWake)
caffeinateWatcher:start()

Automatically clicking through application menus

Want to have keyboard shortcuts for menus in apps that do not allow any configuration? No problem!

The following example starts the Hammerspoon console by virtually clicking it!

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "E", function()
  -- emulates a click
  hs.application.get("Hammerspoon"):selectMenuItem("Console...")
  hs.application.launchOrFocus("Hammerspoon")
end)

Replacing flycut as a copy/paste manager

Flycut is a great tool which retains your copy/paste history and allows you to go back a few entries by hitting cmd+shift+v and using the cursor keys to go through the history. However hammerspoon brings all the functionality to replace flycut with a small script, that in the end looks like this

I basically copied this from the oh-my-hammerspoon repo, more specifically the clipboard.lua file.

Once you hit the configured key to show the history, a hammerspoon hs.chooser is shown (which looks similar to spotlight or alfred), allowing you to select and even search in the history.

Further ideas

So, those were the first things I added when I found out about hammerspoon, but I see so many more things. Also, there float some more hammerspoon configurations around, when google or search them on github.

Here are some more ideas:

  • Automatic brew/osx upgrades (well, could be done via cron, right?)
  • Continuous network checker using hs.network.reachability
  • Replace flux using hs.redshift
  • One of the worst things in OSX is the animated switching of spaces, when switching applications across spaces. With macOS Sierra this can be reduced by setting System Preferences > Accessibility > Display > Reduce motion, but not completely removed. There is an unofficial extension called spaces however, which allows one to directly switch a space without any animation. After switching a space you could easily focus an application without having to see any animation. The spaces package is available on github.

Got more fancy setups? Drop me an email or ping me on twitter.


Back to posts