Build your own custom protocol handler for MacOS

(, en)

I keep my personal wiki/knowledge base in Markdown. Usually it is enough to edit the files with a text editor. I also like to have my wiki as static pages served locally. There are plenty of solutions out there, such as Hugo or MkDocs. However, the transition from editor to webpage to editor is not seamless and I decided to do something about it.

Most of the time I use NeoVim to edit the Markdown pages and adding a simple command or key mapping to open the current page in the browser is straight forward. But going the other way, from webpage to editor, is not as straight forward as I first thought. I’m on MacOS and I thought: why not build a custom URL handler. My wiki is called “zettel”, so zettel://zk/0001 would be a nice and simple approach to open the wiki page, based on a URL, in an editor.

There are two solutions. It can be solved either simple, but dirty, or complicated, but clean.

The dirty way: HammerSpoon and GreaseMonkey (or ViolentMonkey)

The approach is simple: HammerSpoon has native support for URL events on MacOS. The URLs need to look like


and you can write a small handler function to deal with this. That works fine on MacOS, but perhaps, at some point in the future I want to switch to Linux. Linux has no HammerSpoon and I don’t want to be stuck with a URL format that stats with hammerspoon://. Can I not just transform the URLs once I open the wiki page in the browser? Turns out: yes, I can. I added the ViolentMonkey extension to Chrome (for Firefox I used GreaseMonkey). The script is very simple:

// ==UserScript==
// @name     zettel2hammer
// @version  1
// @grant    none
// @match http://localhost:*/*
// @match http://localhost/*
// ==/UserScript==

const links = document.body.querySelectorAll("a[href^=zettel]")
links.forEach((l) => {
  l.href = l.href.replace("zettel://zk/", "hammerspoon://zk?ref=")

This script transforms all links of the form zettel://<wikiName>/<pageID> to hammerspoon://<wikiName>?ref=<pageID>. Pretty handy. The only thing that is missing is the handler function. I added

-- zk is <wikiName>
-- ref is the <pageID>
hs.urlevent.bind("zk", function(eventName, params)
  local ref = params["ref"]
  if ref ~= nil and string.find(ref, "^%w+$") then
    local nvimBin = os.getenv("HOME").."/bin/nvim"
    local zkPath = os.getenv("HOME")..string.format("/zk/docs/", ref)

    os.execute("open -n -a Alacritty --args -e "..nvimBin.." "..zkPath)

to my ~/.hammerspoon/init.lua.

Note that I dont use hs.execute, but os.execute. Somehow hs.execute is blocking, which means that HammerSpoon is blocked till I exit NeoVim.

To open NeoVim, linked my nvim binary to ~/bin/nvim (simple way of staying platform and package manager independent) and I installed Alacritty - A cross-platform, OpenGL terminal emulator for having faster startup times.

Custom URL handlers create a potential attack surface. Make sure that you do a sanity check for the parameters (I use only ref params with conform to ^%w+$). More info in the official Apple Developer Documentation.

The clean way: building your own Swift UI URL handler app

(I still have to write this up, but the code is already available)