Headspace v2.0: Simple and Headless

I use Hammerspoon to enhance my OSX with custom UI and automations, while also creating "monotasking" deep work environments to keep me focused.
Headspace V2 is a simple Hammerspoon script that responds to URL schemes and blocks applications based on file-system tags or names.
Inspiration
My experiments with Raycast revealed a mighty cool feature: Raycast lets you send text as input to a Shortcut. This little UX affordance was the last domino for a long-promised project: Headspace v2.
I've been dreaming for a long while about breaking apart Headspace. A few of you have kindly reached out, interested in the concept of managing your focus… but discovering quickly that if you can't program, use another automation tool, or don't want to do everything I do, it didn't fit. 🤷
I eventually realized that the only thing that Headspace did that other OSX automation tools didn't was block applications from launching. I decided to rewrite it without the UI, without the Toggl tracking, autolayout, or script automation features to focus on app blocking and leave the rest to you. By surfacing the controls of Headspace as URL schemes you could control your focus from whatever application you already use!
Testing Tags
The remaining question: how to give the end-user control over the blocklist without writing code? After some musing I realized that MacOS already has a system for creating collections: Filesystem Tags. Could that work? I tagged my favorite terminal emulator with coding
, and threw this into the Hammerspoon console:
> hs.inspect(hs.fs.tagsGet(hs.application.find("Kitty"):path()))
2023-02-16 15:16:03: -- Loading extension: inspect
{ "coding" }
Let's go! 👏
There is a catch… if you download from the Mac App Store, you have to put in an admin password to tag that application. Worse, you can't tag any built-in mac applications. Booh. I tried to fight with SIP, but it didn't help at all. After some frustration I settled on simply allowing the user to also block by name… and we were off to the races! 🏇
Refactoring
I started by stripping out toggl, the UI chooser element, autolayout, and even the concept of Spaces. I wanted to focus entirely on the goal of blocking applications.
I re-used the hs.application.watcher
and hs.notify
(alert for letting you know something had been blocked,) but added to the m:start
function some listeners for URL events:
-- Module:start() is a convention for starting a Spoon
function m:start()
-- m.watcher fires every time an application event occurs
m.watcher = hs.application.watcher.new(function(appName, event, hsapp)
-- but we only care about applications launching!
if event == hs.application.watcher.launched then
-- this conditional is the *soul* of the program.
if not m.allowed(hsapp) then
-- show a nag alert
hs.alert(
"🛑: " .. hsapp:name(),
moduleStyle
)
hsapp:kill()
end
end
end):start()
-- configure the URL events and tie them to the right callbacks.
hs.urlevent.bind("setBlacklist", m.setBlacklist)
hs.urlevent.bind("setWhitelist", m.setWhitelist)
hs.urlevent.bind("stopHeadspace", m.stopHeadspace)
return self
end
There are corresponding tear-down functions in the m:stop()
method.
I decided to greatly simplify the interface by simplifying the data-model: the rules now only support either a whitelist or a blacklist, but not both. They both are stored in hs.settings.set("headspace")
, so setting a new one wipes out the old. Much easier!
For example:
function m.setWhitelist(_eventName, params)
local list = {}
-- parse out the tags and apps, potential params to the URL.
if params["tags"] then
list["tags"] = fn.split(params["tags"], ",")
end
if params["apps"] then
list["apps"] = fn.split(params["apps"], ",")
end
-- set the settings variable to the parsed table
hs.settings.set("headspace", { ["whitelist"] = list })
-- listen for a special parameter and (if true) kill apps that match
-- the rule based on the settings variable we just saved.
if params["kill"] then
m.killBlockedDockedApps()
end
end
The tricky part is defining m.allowed(app)
, the soul of the application.
To be honest, I wound up in guess-driven development for a good long while. If my re-reading of my sleep deprived commit history is any indication, I neglected to set the base condition for whitelist to be "all apps are blocked" and blacklist's base condition as "all apps are allowed." Rookie mistake.
Here's where I landed after some refactoring:
m.allowed = function(app)
-- get the tags on the filesystem for this app
local tags = hs.fs.tagsGet(app:path())
local name = app:name()
-- if it has the tag `whitelisted`, allow!
if tags and fn.contains(tags, "whitelisted") then
return true
end
if m.getWhitelist() then
-- assume the app **is blocked** by tag and name
local appAllowed = false
local tagAllowed = false
if m.getWhitelist().apps then
-- if the apps list contains this app's name, allow!
appAllowed = fn.contains(m.getWhitelist().apps, name)
end
if m.getWhitelist().tags and tags then
-- if there's an intersection between application tags and rule tags, allow!
tagAllowed = fn.some(m.getWhitelist().tags, function(tag)
return fn.contains(tags, tag)
end)
end
-- whitelist allows if the tag is allowed or the name is allowed.
return tagAllowed or appAllowed
end
if m.getBlacklist() then
-- assume that this app is **not blocked** by tag or name
local appBlocked = false
local tagBlocked = false
-- if the rule contains application names
if m.getBlacklist().apps then
-- if the name matches, block!
appBlocked = fn.contains(m.getBlacklist().apps, name)
end
if m.getBlacklist().tags and tags then
-- if there's an intersection, block!
tagBlocked = fn.some(m.getBlacklist().tags, function(tag)
return fn.contains(tags, tag)
end)
end
-- if the app is not blocked by name or tags… true.
return (not appBlocked) and (not tagBlocked)
end
return true
end
Phew! That was definitely some logic I should have slept on and revisited in the morning… and indeed that's what happened. I could not see it after midnight. Funny how that works. Anyway… it works now.
After using this for a while, I realized a critical part of changing my "space" was the clearing of distractions on each step. Before, I only killed applications specified in a configuration table… which kept Hammerspoon from naively kill -9
ing Finder, Hammerspoon… well everything. I re-implemented the kill
parameter using a simple heuristic: kill applications that match rules and have a dock icon.
m.dockedAndNotFinder = function(app)
return app:bundleID() ~= "com.apple.finder" and app:kind() == 1
end
m.killBlockedDockedApps = function()
local dockedAndBlocked =
fn.filter(hs.application.runningApplications(), function(app)
return m.dockedAndNotFinder(app) and not m.allowed(app)
end)
fn.each(dockedAndBlocked, function(app) app:kill() end)
end
…
Man it feels good to check off this project…
All together (and after a lot of debugging of previous versions of m.allowed(app)!
) it's been working fabulously. I have been driving Headspace through the Open URLs
action in Shortcuts, but you could type it into a browser, launch using Bunch, or however you automate your life1.
The latest version is available on github, let me know what you think of the concept or the code. 🙏
-
You can also drive it manually through
hs.ipc
: you could call ↩require("hs.ipc") -- install somewhere your user has control and on its path hs.ipc.cliInstall("/opt/homebrew")
And then whenever you want to use it, you could call it directly from a bash/shell script:
hs -c "Spoon.Headspace.setBlacklist(nil, {["tags"] = "communication,distraction"})"
Changelog
-
2023-02-17 14:55:55 -0600Change link in tldr
-
2023-02-17 08:33:46 -0600post article
-
2023-02-16 22:15:35 -0600Remove focus tag
-
2023-02-16 16:35:55 -0600Add code tag
-
2023-02-16 16:34:58 -0600Schedule draft for tomorrow's post