little-ff: open windows in the current workspace
Launching the browser activates it, which moves the aerospace focus to
the workspace where the browser already has windows, so new windows
were born there instead of where the URL was opened from. Capture the
workspace first, then move the window back.
The aerospace calls go through hs.task, not hs.execute. Opening a URL
activates Hammerspoon, and aerospace answers an activation by querying
the app over the accessibility API; a blocking call deadlocks the two
until the 3 second AX timeout in aerospace fires, once per call.
is-ff-focused also indexed a nil focusedWindow, killing the waitUntil
timer so the window was never placed or resized.
Enabling hs.ipc makes the hs CLI available, which is how the timing
above was measured.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/.hammerspoon/init.fnl b/.hammerspoon/init.fnl
index 3acffd59..fb532d45 100644
--- a/.hammerspoon/init.fnl
+++ b/.hammerspoon/init.fnl
@@ -14,6 +14,9 @@
: urlevent
: window} hs)
+;; Enables the `hs` CLI, which drives this config from a shell.
+(require :hs.ipc)
+
(local log (logger.new :init :info))
; (set logger.defaultLogLevel :info)
diff --git a/.hammerspoon/little-ff.fnl b/.hammerspoon/little-ff.fnl
index 0db66892..4263991e 100644
--- a/.hammerspoon/little-ff.fnl
+++ b/.hammerspoon/little-ff.fnl
@@ -1,8 +1,10 @@
-(local {: with-ax-hotfix} (require :utils))
+(local {: chomp : with-ax-hotfix} (require :utils))
(local log (hs.logger.new :little-ff :info))
-(local ff {:bin "/Applications/Firefox\\ Developer\\ Edition.app/Contents/MacOS/firefox"
+(local aerospace :/opt/homebrew/bin/aerospace)
+
+(local ff {:bin "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox"
:bundle-id :org.mozilla.firefoxdeveloperedition
:name "Firefox Developer Addition"})
@@ -13,17 +15,46 @@
(local browser glide)
(λ is-ff-focused []
+ ;; focusedWindow returns nil when nothing is focused, which happens often
+ ;; enough while polling for a window that is still being created.
(let [win (hs.window.focusedWindow)
- bundle-id (-> win
- (: :application)
- (: :bundleID))]
- (if (= bundle-id browser.bundle-id)
- win)))
+ app (when win (win:application))
+ bundle-id (when app (app:bundleID))]
+ (when (= bundle-id browser.bundle-id)
+ win)))
(λ check-ff [cb]
(let [win (is-ff-focused)]
(if win (cb win) (log:w "Firefox not focused"))))
+;; Never run these synchronously. Opening a URL activates Hammerspoon, and
+;; aerospace answers an activation by querying the app over the accessibility
+;; API. A blocking hs.execute deadlocks the two — aerospace waits on
+;; Hammerspoon's main thread while Hammerspoon waits on aerospace — until
+;; aerospace's 3 second AX timeout fires, once per call.
+(λ aerospace-run [args cb]
+ (: (hs.task.new aerospace (fn [rc out err]
+ (if (= rc 0)
+ (cb (chomp out))
+ (do
+ (log:w :aerospace (table.concat args " ")
+ "failed:"
+ (chomp (if (= err "") out err)))
+ (cb nil))))
+ args) :start))
+
+(λ focused-workspace [cb]
+ "Calls `cb` with the name of the focused aerospace workspace, or nil"
+ (aerospace-run [:list-workspaces :--focused] cb))
+
+(λ move-to-workspace [win workspace cb]
+ (aerospace-run [:move-node-to-workspace
+ :--focus-follows-window
+ :--window-id
+ (tostring (win:id))
+ "--"
+ workspace] cb))
+
(λ resize-little-ff []
(check-ff (fn [win]
(let [hide-sidebar #(hs.eventtap.keyStroke [:ctrl] :z)]
@@ -31,14 +62,44 @@
#(win:moveToUnit "[20,10,80,90]"))
(hide-sidebar)))))
+(λ place-little-ff [workspace]
+ "Moves the new window back to `workspace`, then sizes it.
+
+ Launching the browser activates the app, which pulls aerospace over to
+ whatever workspace the browser already lives on, so the new window is born
+ there rather than where the URL was opened from."
+ (check-ff (fn [win]
+ (let [before (win:screen)
+ ;; moveToUnit sizes against the window's current screen, so
+ ;; give the window a beat when the move crossed monitors.
+ finish (fn []
+ (if (= (: (win:screen) :id) (before:id))
+ (resize-little-ff)
+ (hs.timer.doAfter 0.1 resize-little-ff)))]
+ (if workspace
+ (move-to-workspace win workspace finish)
+ (finish))))))
+
+;; A cold browser can take several seconds to put the new window on screen.
+;; Give up eventually so a failed launch doesn't leave a timer polling forever.
+(local window-timeout 10)
+
(λ wait-for-little-ff [cb]
(let [timer (hs.timer.waitUntil is-ff-focused cb 0.05)]
- (hs.timer.doAfter 1 #(timer:stop))))
+ (hs.timer.doAfter window-timeout
+ #(when (timer:running)
+ (log:w "Gave up waiting for the window after"
+ window-timeout :seconds)
+ (timer:stop)))))
(λ open [url]
- (let [cmd (table.concat [browser.bin :--new-window (.. "\"" url "\"")] " ")]
- (log:d "Executing:" cmd)
- (hs.execute cmd)
- (wait-for-little-ff resize-little-ff)))
+ ;; Read the workspace before launching: activating the browser moves
+ ;; aerospace's focus to wherever the browser already has windows.
+ (focused-workspace (fn [workspace]
+ (let [args [:--new-window url]]
+ (log:d "Launching:" browser.bin
+ (table.concat args " "))
+ (: (hs.task.new browser.bin nil args) :start)
+ (wait-for-little-ff #(place-little-ff workspace))))))
{: open : is-ff-focused}