MD>_

Hammerspoon Bridge for Companion

This is the example script from the post MacOS 27 broke my old VICREO setup. It is a cleaned-up version of what runs on my Macs: same structure, a shorter list of actions, no real token. Like the original, it was written entirely by Claude Code with Fable 5.1.

How to use it

If you already have a ~/.hammerspoon/init.lua, copy it somewhere safe first. If you are migrating Companion buttons, export your Companion config before changing anything.

  1. Install Hammerspoon (brew install --cask hammerspoon), start it, and allow it under System Settings, Privacy & Security, Accessibility.
  2. Paste the script below into ~/.hammerspoon/init.lua.
  3. Replace SECRET with your own token. Letters and digits only, for example from openssl rand -hex 24. The bridge refuses to start while the placeholder is still there.
  4. Reload: Hammerspoon menu bar icon, Reload Config.
  5. In Companion, create two custom variables and set their startup value: bridge_url = http://127.0.0.1:8001/hammerspoon and bridge_token = your token.
  6. On a button, add the action http: GET with the URL $(custom:bridge_url)/keynote_next?token=$(custom:bridge_token).

Test it from a terminal before touching Companion. A wrong token must answer 403, an unknown route 404:

curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/hammerspoon/keynote_next?token=wrong'

To add your own button, add one line to the routes table and reload. The first time an action controls another app (Terminal, Finder), macOS asks for permission on the Mac's main display.

The script

-- ~/.hammerspoon/init.lua -- Hammerspoon HTTP bridge for Bitfocus Companion (replaces VICREO Listener). -- Companion button action: http: GET $(custom:bridge_url)/<route>?token=$(custom:bridge_token) -- with bridge_url = http://127.0.0.1:8001/hammerspoon require("hs.ipc") -- lets you run `hs -c 'hs.reload()'` from a terminal -- Letters and digits only: Companion percent-encodes anything else in variables. -- Generate one with: openssl rand -hex 24 local SECRET = "REPLACE_WITH_YOUR_OWN_48_CHARACTER_TOKEN" local PORT = 8001 local logPath = os.getenv("HOME") .. "/.hammerspoon/bridge.log" local function log(text) local f = io.open(logPath, "a") if f then f:write(os.date("%Y-%m-%d %H:%M:%S") .. " " .. text .. "\n") f:close() end end -- AppleScript runs as a child process. hs.osascript is synchronous and would freeze -- the whole bridge while a macOS permission dialog is waiting. local function appleScript(script, done) local task = hs.task.new("/usr/bin/osascript", function(code, _, err) if code ~= 0 then log("applescript failed: " .. (err or "")) end done() end, {"-e", script}) if not task:start() then log("osascript did not start"); done() end end -- Sends a key to one app by process, like VICREO's "send keypress to process". -- Works even when another window is in front. local function keyToApp(bundleID, mods, key) local app = hs.application.get(bundleID) if not app then log("not running: " .. bundleID); return end hs.eventtap.keyStroke(mods, key, 50000, app) end -- The only things a button can ask for. Nothing from the request reaches a shell. local routes = { -- launch apps and open URLs app_keynote = function() hs.application.launchOrFocusByBundleID("com.apple.iWork.Keynote") end, app_spotify = function() hs.application.launchOrFocusByBundleID("com.spotify.client") end, url_youtube = function() hs.urlevent.openURLWithBundle("https://www.youtube.com/", "com.google.Chrome") end, -- keys delivered to a specific app keynote_next = function() keyToApp("com.apple.iWork.Keynote", {}, "right") end, keynote_prev = function() keyToApp("com.apple.iWork.Keynote", {}, "left") end, -- audio mute_toggle = function() local d = hs.audiodevice.defaultOutputDevice(); d:setOutputMuted(not d:outputMuted()) end, vol_50 = function() hs.audiodevice.defaultOutputDevice():setVolume(50) end, -- system lock = function() hs.caffeinate.lockScreen() end, ssh_server = function(done) appleScript('tell application "Terminal"\nactivate\ndo script "ssh myserver"\nend tell', done) end, } -- Routes that finish later and call done() themselves. The queue waits for them. local async = { ssh_server = true } -- One queue: an action runs only after the previous one has finished, including -- asynchronous ones, so two presses in quick succession cannot overlap. local queue, busy, current, watchdog = {}, false, 0, nil local function pump() if busy or #queue == 0 then return end busy = true current = current + 1 local id, name = current, table.remove(queue, 1) local function done() if not busy or id ~= current then return end -- count each action once if watchdog then watchdog:stop() end busy = false hs.timer.doAfter(0, pump) end -- safety net: one stuck action must never block every button watchdog = hs.timer.doAfter(20, function() log("timeout: " .. name); done() end) local ok, err = pcall(routes[name], done) if not ok then log("error in " .. name .. ": " .. tostring(err)); done(); return end if not async[name] then done() end end local function urlDecode(s) return (s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)) end local function parseQuery(qs) local params = {} for k, v in (qs or ""):gmatch("([^&=]+)=([^&=]+)") do params[k] = urlDecode(v) end return params end local lastFired = {} local function callback(method, path) local headers = {["Content-Type"] = "application/json"} local ok, body, status = pcall(function() local route, query = path:match("^([^?]*)%??(.*)$") if parseQuery(query).token ~= SECRET then log(method .. " " .. route .. " -> 403") -- log the route, never the token return '{"status":"forbidden"}', 403 end local name = route:match("^/hammerspoon/([%w_]+)$") if not name or not routes[name] then log(method .. " " .. route .. " -> 404") return '{"status":"not found"}', 404 end -- Companion retries slow requests; ignore a repeat of the same route within 0.8 s local now = hs.timer.secondsSinceEpoch() if lastFired[name] and now - lastFired[name] < 0.8 then return '{"status":"debounced"}', 200 end lastFired[name] = now log(method .. " " .. route .. " -> queued") -- Answer first, act afterwards. Otherwise Companion times out and presses again. hs.timer.doAfter(0, function() table.insert(queue, name); pump() end) return '{"status":"ok"}', 200 end) if ok then return body, status, headers end log("callback error: " .. tostring(body)) return '{"status":"error"}', 500, headers end local server local function start() -- Never run with the published placeholder or a token Companion would mangle. if SECRET:find("REPLACE_WITH", 1, true) or not SECRET:match("^%w+$") or #SECRET < 32 then log("NOT started: set SECRET to your own token (letters and digits, 32 or more)") hs.alert.show("Companion bridge NOT started: set your own SECRET in init.lua") return end if server then server:stop() end server = hs.httpserver.new(false) -- false = plain HTTP, no Bonjour advertising server:setInterface("127.0.0.1") -- only Companion on this Mac can reach it server:setPort(PORT) server:setCallback(callback) server:start() log("bridge started on 127.0.0.1:" .. PORT) end start() -- The listener can silently lose its socket after sleep: rebind on wake, -- and check every five minutes as a safety net. bridgeWakeWatcher = hs.caffeinate.watcher.new(function(event) if event == hs.caffeinate.watcher.systemDidWake then start() end end) bridgeWakeWatcher:start() bridgeHealthTimer = hs.timer.doEvery(300, function() local out = hs.execute("lsof -iTCP:" .. PORT .. " -sTCP:LISTEN -n 2>/dev/null") if not out or out == "" then log("port not listening, restarting"); start() end end)

What each part does

routes is the whole security model. A button can only name an entry in this table. Adding a button means adding a line here, never sending a command from Companion.

keyToApp passes the app as the last argument of hs.eventtap.keyStroke. That posts the key to that app's process, so a Keynote button still works when another window has focus.

The queue runs one action at a time. Asynchronous actions like the AppleScript one are listed in async and release the queue only when they are finished, with a 20-second safety net. For multi-step actions with delays (open an app, wait, press a shortcut), you would additionally check hs.application.frontmostApplication() before typing, so keys never land in the wrong window.

What is deliberately not in this example: a route that types text and presses Enter into whatever window is in front. It is handy for confirming prompts, and it is also exactly the button that approves the wrong thing when the wrong window has focus. If you add one, make it check the frontmost app first and keep it off pages you press in a hurry.

callback checks the token, then the route, answers Companion immediately and only then queues the action. Companion's HTTP client retries requests that take too long, and each retry would otherwise be a real second press.

start binds to 127.0.0.1, so only software on the same Mac can call the bridge. The wake watcher and the five-minute health check rebind the port if macOS drops it after sleep.

The Hammerspoon documentation for everything used here: hs.httpserver, hs.eventtap, hs.application, hs.task.

Verifying this page

This page is OpenPGP-signed. To check that the text above is unchanged and really came from me, follow the three steps on Verify authenticity and use hammerspoon-bridge as the slug.