Hammerspoon - OSX automation in Lua for the win

For those who don't know, Hammerspoon is a fantastic automation tool for OS X.  It's basically a bridge between the operating system and the Lua scripting language. With its set of extensions (which you can write your own, by the way) you can automate pretty much anything.

You can write Lua code that interacts with OS X APIs for applications, windows, mouse pointers, filesystem objects, audio devices, batteries, screens, low-level keyboard/mouse events, clipboards, location services, wifi, and more.

Here's the current script i'm using on my development laptop. It currently does the following:

  • Auto reloads whenever i change the configuration script;
  • Sets up a menu bar button so i can control whether my laptop goes to sleep or is continuously awake. If you know the app Caffeine, you know what i'm saying;
  • Monitors my Wifi connection and warns me when i lose connection or connect to a different SSID;
  • Monitors my battery and warns me when i get to 10 and 5%.
  • Work in progress: Monitor google.com and bing.com from times to times to check if i have Internet connection.

The great thing about it is its simplicity. Even if you don't know Lua, you'll find it very easy to write simple watchers and callbacks.

Give it a try! :)

init.lua

-------------------------------------------------------------------------------------
-- Auto Reloading when config changes
-------------------------------------------------------------------------------------
function reload_config(files)
  -- Kill bat watcher
  if batWatcher then
    batWatcher:stop()
  end
  -- Kill wifi watcher
  if wifiWatcher then
    wifiWatcher:stop()
  end
  -- Kill caffeine
  if caffeine then
    caffeine:delete()
  end
  -- Reload config
  hs.reload()
end
hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reload_config):start()
hs.alert.show("Config reloaded")

-------------------------------------------------------------------------------------
-- Keep display on or allow going to sleep
-------------------------------------------------------------------------------------
local caffeine = hs.menubar.new()
function setCaffeineDisplay(state)
    if state then
        caffeine:setTitle("AWAKE")
    else
        caffeine:setTitle("SLEEPY")
    end
end
function caffeineClicked()
    setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
end
if caffeine then
    caffeine:setClickCallback(caffeineClicked)
    setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
end

-------------------------------------------------------------------------------------
-- Network connection and disconnection
-------------------------------------------------------------------------------------
local wifiWatcher = nil
function ssidChangedCallback()
    newSSID = hs.wifi.currentNetwork()
    if newSSID then
      hs.alert.show("Network connected: " .. newSSID)
    else
      hs.alert.show("Network lost :(")
    end
end
wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
wifiWatcher:start()

-------------------------------------------------------------------------------------
-- Battery Low warnings
-------------------------------------------------------------------------------------
local batWatcher = nil
local lastBatVal = hs.battery.percentage()
function batPercentageChangedCallback()
  currentPercent = hs.battery.percentage()
  if currentPercent == 10 and lastBatVal > 10 then
    hs.alert.show("Getting low on juice...")
  end
  if currentPercent == 5 and lastBatVal > 5 then
    hs.alert.show("Captain, she can't take any more!")
  end
  lastBatVal = currentPercent
end
batWatcher = hs.battery.watcher.new(batPercentageChangedCallback)
batWatcher:start()

--status, data, headers = hs.http.get("http://example.com")
--hs.alert.show(status)
--hs.alert.show(data)

Problem starting Sidekiq in development

If you ever get this error:

can't link outside actor context

Followed by something like:

    /Library/Ruby/Gems/2.0.0/gems/celluloid-0.16.0/lib/celluloid.rb:176:in `new_link'
    /Library/Ruby/Gems/2.0.0/gems/sidekiq-3.3.4/lib/sidekiq/launcher.rb:21:in `initialize'
    /Library/Ruby/Gems/2.0.0/gems/sidekiq-3.3.4/lib/sidekiq/cli.rb:81:in `new'
    /Library/Ruby/Gems/2.0.0/gems/sidekiq-3.3.4/lib/sidekiq/cli.rb:81:in `run'
    /Library/Ruby/Gems/2.0.0/gems/sidekiq-3.3.4/bin/sidekiq:8:in `<top (required)>'
    /Library/Ruby/Gems/2.0.0/bin/sidekiq:23:in `load'
    /Library/Ruby/Gems/2.0.0/bin/sidekiq:23:in `<main>'

It's mostly likely somehow related with either ZenTest or another testing framework. 

In my case, i was adding the ZenTest gem both in the development and test groups of my Gemfile. Moving it away from the development group solved the problem.

AES encryption in Ruby and Decryption in Java

This one is precious, as it took me a long time to figure out. As a side-note, Java apparently only supports 128bit AES.

Here's the Ruby code:

def encrypt(string, pwd)
    salt = OpenSSL::Random.random_bytes(16)

    # prepare cipher for encryption
    e = OpenSSL::Cipher.new('AES-128-CBC')
    e.encrypt

    # next, generate a PKCS5-based string for your key + initialization vector
    key_iv = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pwd, salt, 1024, e.key_len+e.iv_len)
    key = key_iv[0, e.key_len]
    iv  = key_iv[e.key_len, e.iv_len]

    # now set the key and iv for the encrypting cipher
    e.key = key
    e.iv  = iv

    # encrypt the data!
    encrypted = '' << e.update(string) << e.final
    [encrypted, iv, salt].map {|v| ::Base64.strict_encode64(v)}.join("--")   
 end

And the Java part:

public static String decrypt(String encrypted, String pwd) throws Exception {

        String[] parts = encrypted.split("--");
        if (parts.length != 3) return null;

        byte[] encryptedData = Base64.decodeBase64(parts[0]);
        byte[] iv = Base64.decodeBase64(parts[1]);
        byte[] salt = Base64.decodeBase64(parts[2]);

        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
        KeySpec spec = new PBEKeySpec(pwd.toCharArray(), salt, 1024, 128);
        SecretKey tmp = factory.generateSecret(spec);
        SecretKey aesKey = new SecretKeySpec(tmp.getEncoded(), "AES");

        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, aesKey, new IvParameterSpec(iv));

        byte[] result = cipher.doFinal(encryptedData);
        return new String(result, "UTF-8");
}

Kill process by fuzzy name

Sometimes you want to kill a process but you either don’t know the exact name of the executable file, or you know part of the command arguments. killall is not your friend in those situations. Here’s a quick script to grep on part of a process name or arguments and kill it.


Full script (put it in a path included folder and change it’s permissions to executable +x):

#!/bin/bash
ps aux | grep $1 | grep -v grep | awk '{print $2}' | xargs kill -9