hammerspoon: Hammerspoon hangs spradically when entering hyper mode and displaying a modal window

Hi,

I am using Hammerspoon for a few years now and I have a very hard to track down issue. Every once in a while (a week or a few days or so) my script hangs without any error messages in the Hammerspoon Console.

I am using macOS 12.6.8. I am using my script to quick launch applications and as a window manager. It works like that:

Keyboard shortcut Description
<kbd>option</kbd>+<kbd>W</kbd> enter hyper mode and display a modal window

In hyper mode:

Keyboard shortcut Description
<kbd>W</kbd> close hypermode
Arrows control window position
Other keys quick launch applications or control windows in other ways

I use that script extensively every day, but only every few days or weeks when I open hyper mode, the modal shows up, but the script does not react to key strokes in hyper mode. There is no error message or any other message in the Hammerspoon Console when I press a key.

Reload config stops the script and closes the modal window, but the issue persists when entering hyper mode again. By trial and error over the years I realized that the issue gets resolved when I put my MacBook to sleep and log in again. Then again the issue comes back sporadically. I guess it is some timing issue / race condition.

I hope someone has an idea how to track down the issue when it occurs the next time. The good thing is, as soon as it appears I can reproduce it, by reloading the script until I login or reboot the next time.

Since I have no idea which part of the script is relevant for the bug, I post the full script in here:

local browser = "org.mozilla.firefox"
local editor = "com.sublimetext.4"
local fileManager = "com.apple.finder"
local passwordManager = "org.keepassxc.keepassxc"
local terminal = "org.alacritty"
local youtube = "/Applications/Firefox.app/Contents/MacOS/firefox -new-tab https://www.youtube.com &"

hs.window.animationDuration = 0

local windowGap = 4

local hyperStyle = {
  fadeInDuration = 0,
  fillColor = { white = 1, alpha = 2 / 3 },
  radius = 24,
  strokeColor = { red = 19 / 255, green = 182 / 255, blue = 133 / 255, alpha = 1},
  strokeWidth = 16,
  textColor = { white = 0.125 },
  textSize = 48,
}

local hyper = hs.hotkey.modal.new("option", "W")

local hyperAlerts

function hyper:entered()
  hyperAlerts = {}
  for i, screen in pairs(hs.screen.allScreens()) do
    alert = hs.alert("Hyper Mode ✈", hyperStyle, screen, "")
    hyperAlerts[i] = alert
  end
end

function hyper:exited()
  for i, alert in pairs(hyperAlerts) do
    hs.alert.closeSpecific(alert, 0.25)
  end
end

hyper:bind("", "B", function()
  hs.application.launchOrFocusByBundleID(browser)
  hyper:exit()
end)

hyper:bind("", "E", function()
  hs.application.launchOrFocusByBundleID(editor)
  hyper:exit()
end)

hyper:bind("", "F", function()
  hs.application.launchOrFocusByBundleID(fileManager)
  hyper:exit()
end)

hyper:bind("", "K", function()
  hs.application.launchOrFocusByBundleID(passwordManager)
  hyper:exit()
end)

hyper:bind("", "R", function() hs.reload() end)

hyper:bind("", "T", function()
  hs.application.launchOrFocusByBundleID(terminal)
  hyper:exit()
end)

hyper:bind("", "W", function() hyper:exit() end)

hyper:bind("", "Y", function()
  os.execute(youtube)
  hyper:exit()
end)

local windowPositions = {
    UP=1,
    RIGHT=2,
    DOWN=3,
    LEFT=4
}

local windowPosition = Nil

-- Move window to bottom of screen if it has a fixed ratio
function correctBottomPosition()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  if windowFrame.h < (screenFrame.h - windowGap) / 2 then
    windowFrame.y = screenFrame.y + screenFrame.h - windowFrame.h
    window:setFrame(windowFrame)
  end
end

-- Move window to right of screen if it has a fixed ratio
function correctRightPosition()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  if windowFrame.w < (screenFrame.w - windowGap) / 2 then
    windowFrame.x = screenFrame.x + screenFrame.w - windowFrame.w
    window:setFrame(windowFrame)
  end
end

hyper:bind("", "Left", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x
  windowFrame.w = (screenFrame.w - windowGap) / 2
  if windowPosition ~= windowPositions.UP and windowPosition ~= windowPositions.DOWN then
    windowFrame.y = screenFrame.y
    windowFrame.h = screenFrame.h
  end
  window:setFrame(windowFrame)
  if windowPosition == windowPositions.DOWN then
    correctBottomPosition()
  end
  windowPosition = windowPositions.LEFT
end)

hyper:bind("", "Right", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x + (screenFrame.w + windowGap) / 2
  windowFrame.w = (screenFrame.w - windowGap) / 2
  if windowPosition ~= windowPositions.UP and windowPosition ~= windowPositions.DOWN then
    windowFrame.y = screenFrame.y
    windowFrame.h = screenFrame.h
    window:setFrame(windowFrame)
  else
    window:setFrame(windowFrame)
    correctRightPosition()
  end
  if windowPosition == windowPositions.DOWN then
    correctBottomPosition()
  end
  windowPosition = windowPositions.RIGHT
end)

hyper:bind("", "Up", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.y = screenFrame.y
  windowFrame.h = (screenFrame.h - windowGap) / 2
  if windowPosition ~= windowPositions.LEFT and windowPosition ~= windowPositions.RIGHT then
    windowFrame.x = screenFrame.x
    windowFrame.w = screenFrame.w
  end
  window:setFrame(windowFrame)
  if windowPosition == windowPositions.RIGHT then
    correctRightPosition()
  end
  windowPosition = windowPositions.UP
end)

hyper:bind("", "Down", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.y = screenFrame.y + (screenFrame.h + windowGap) / 2
  windowFrame.h = (screenFrame.h - windowGap) / 2
  if windowPosition ~= windowPositions.LEFT and windowPosition ~= windowPositions.RIGHT then
    windowFrame.x = screenFrame.x
    windowFrame.w = screenFrame.w
    window:setFrame(windowFrame)
  else
    window:setFrame(windowFrame)
    correctBottomPosition()
  end
  if windowPosition == windowPositions.RIGHT then
    correctRightPosition()
  end
  windowPosition = windowPositions.DOWN
end)

hyper:bind("", "D", function()
  local window = hs.window.focusedWindow()
  window:moveToScreen(window:screen():next())
end)

hyper:bind("", "Space", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

hyper:bind("", "C", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x + screenFrame.w / 6
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w * 2 / 3
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

hyper:bind("", "Tab", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x + (screenFrame.w - windowFrame.w) / 2
  windowFrame.y = screenFrame.y + (screenFrame.h - windowFrame.h) / 2
  window:setFrame(windowFrame)
end)

hyper:bind("", "1", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w * 2 / 3
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

hyper:bind("", "2", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x + screenFrame.w * 2 / 3
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w * 1 / 3
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

hyper:bind("", "3", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w * 1 / 3
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

hyper:bind("", "4", function()
  local window = hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screenFrame = window:screen():frame()
  windowFrame.x = screenFrame.x + screenFrame.w * 1 / 3
  windowFrame.y = screenFrame.y
  windowFrame.w = screenFrame.w * 2 / 3
  windowFrame.h = screenFrame.h
  window:setFrame(windowFrame)
end)

About this issue

  • Original URL
  • State: open
  • Created 8 months ago
  • Comments: 16 (1 by maintainers)

Most upvoted comments

@Rhys-T Do you have the code for this workaround? My browser turns secure input mode on for password fields, and this greatly hinders me, as I have my clipboard manager open using a hyper keybinding.

I haven’t fully tested this, because on my system SIM doesn’t interfere with hs.hotkey, but I’ve tested the basic idea of having Hammerspoon steal focus to make another app turn off SIM, and it seems to work for both Firefox password fields and iTerm. In theory it should be something like this:

This version is based on the hyper mode code that @tobx originally posted.
local realCurrentWindow
local maxSIMWaitTime <const> = 0.25 -- seconds
local focusStealingWebview = hs.webview.new{x=0, y=0, w=0, h=0}
local isSecureInputEnabled = hs.eventtap.isSecureInputEnabled
function hyper:entered()
  hyperAlerts = {}
  
  realCurrentWindow = hs.window.focusedWindow()
  if isSecureInputEnabled() then
    focusStealingWebview:show():hswindow():focus()
    
    -- Whichever app is enabling SIM might not disable it immediately.
    -- Watch for SIM to shut off, giving up after `maxSIMWaitTime` seconds.
    local endTime = hs.timer.absoluteTime() + maxSIMWaitTime*1000000000 -- convert to nanoseconds
    while isSecureInputEnabled() and hs.timer.absoluteTime() < endTime do
      -- Normally I try to avoid hs.timer.usleep, because it basically hangs Hammerspoon.
      -- But for really short periods like this, it's probably cleaner than rewriting with timers or coroutines.
      hs.timer.usleep(1000)
    end
    
    if isSecureInputEnabled() then
      -- Still in Secure Input Mode - give up and show alerts about it.
      local secureInputInfo = hs.execute[[ps -c -o pid=,command= -p $(ioreg -l -w 0 | grep -Eo '"kCGSSessionSecureInputPID"=[0-9]+' | cut -d= -f2 | sort | uniq]]
      
      local msg = "⚠️ Secure Input is on. Hyper Mode commands might not work.\nEnabled by:\n"..secureInputInfo
      msg = msg:gsub('loginwindow', 'unknown (supposedly loginwindow)')
      msg = msg:gsub('^%s*(.-)%s*$', '%1')
      
      print(msg) -- leave a copy of the message in the console, so you can still see it after the alert goes away
      for i, screen in pairs(hs.screen.allScreens()) do
        hyperAlerts['Secure Input '..i] = hs.alert(msg, hyperStyle, screen, "")
      end
    end
  end

  -- The rest of this is the alert code from the original post:
  for i, screen in pairs(hs.screen.allScreens()) do
    alert = hs.alert("Hyper Mode ✈", hyperStyle, screen, "")
    hyperAlerts[i] = alert
  end
end

function hyper:exited()
  for i, alert in pairs(hyperAlerts) do
    hs.alert.closeSpecific(alert, 0.25)
  end
  if realCurrentWindow then
    realCurrentWindow:focus()
    realCurrentWindow = nil
  end
  focusStealingWebview:hide()
end

-- Then in any functions that manipulate the current window, use `realCurrentWindow` instead of `hs.window.focusedWindow()`.
This version is based on @NightMachinery's version of the hyper mode config + the canvas-based indicator from #3586.
local realCurrentWindow
local maxSIMWaitTime <const> = 0.25 -- seconds
local focusStealingWebview = hs.webview.new{x=0, y=0, w=0, h=0}
local hyperSIMAlerts
local isSecureInputEnabled = hs.eventtap.isSecureInputEnabled
function hyper:entered()
    hyper_modality.entered_p = true
    -- I have not yet added the redis updaters for purple_modality.
    redisActivateMode("hyper_modality")
    
    realCurrentWindow = hs.window.focusedWindow()
    if isSecureInputEnabled() then
        focusStealingWebview:show():hswindow():focus()
        
        -- Whichever app is enabling SIM might not disable it immediately.
        -- Watch for SIM to shut off, giving up after `maxSIMWaitTime` seconds.
        local endTime = hs.timer.absoluteTime() + maxSIMWaitTime*1000000000 -- convert to nanoseconds
        while isSecureInputEnabled() and hs.timer.absoluteTime() < endTime do
            -- Normally I try to avoid hs.timer.usleep, because it basically hangs Hammerspoon.
            -- But for really short periods like this, it's probably cleaner than rewriting with timers or coroutines.
            hs.timer.usleep(1000)
        end
        
        if isSecureInputEnabled() then
            -- Still in Secure Input Mode - give up and show alerts about it.
            local secureInputInfo = hs.execute[[ps -c -o pid=,command= -p $(ioreg -l -w 0 | grep -Eo '"kCGSSessionSecureInputPID"=[0-9]+' | cut -d= -f2 | sort | uniq]]
            
            local msg = "⚠️ Secure Input is on. Hyper Mode commands might not work.\nEnabled by:\n"..secureInputInfo
            msg = msg:gsub('loginwindow', 'unknown (supposedly loginwindow)')
            msg = msg:gsub('^%s*(.-)%s*$', '%1')
            
            print(msg) -- leave a copy of the message in the console, so you can still see it after the alert goes away
            hyperSIMAlerts = {}
            for i, screen in pairs(hs.screen.allScreens()) do
                hyperSIMAlerts[i] = hs.alert(msg, screen, "")
            end
        end
    end
    
    hyperModeIndicator:show()
end

function hyper:exited()
    hyper_modality.entered_p = false
    hyper_modality.exit_on_release_p = false
    
    hyperModeIndicator:hide()
    
    if hyperSIMAlerts then
        for i, alert in pairs(hyperSIMAlerts) do
            hs.alert.closeSpecific(alert, 0.25)
        end
    end
    
    if realCurrentWindow then
        realCurrentWindow:focus()
        realCurrentWindow = nil
    end
    focusStealingWebview:hide()
    
    redisDeactivateMode("hyper_modality")
end

-- Then in any functions that manipulate the current window, use `realCurrentWindow` instead of `hs.window.focusedWindow()`.

If hyper:entered() detects that SIM is on, it will grab focus using the webview, then wait up to 1⁄4 second for SIM to turn off. (If it’s still on at that point, it gives up and shows a warning, attempting to tell you what app has SIM enabled.) hyper:exited() then puts focus back in the original window.

Any hyper mode commands that manipulate the current window will need to be modified to use realCurrentWindow instead of hs.window.focusedWindow(). Any hyper mode commands that change which window is focused should do something like:

someWindow:focus()
if focusStealingWebview:isVisible() then
  focusStealingWebview:hswindow():focus() -- to make sure that the hotkeys don't stop working
end
realCurrentWindow = someWindow -- so you don't get sent back to the original window after releasing hyper

@Rhys-T Thank you a lot already. I am pretty sure that I did not always use the same app when the issue occurred, but I will double check that and note which apps I used when the issue occurs again. 50% of me using that script is moving the terminal position, but I only use Alacritty. Input Monitoring only shows two apps that I hardly ever use. I tried with both apps open and had no issue with my script. I added the isSecureInputEnabled and report back if there is more information.

Are there specific apps that you’re usually in when you have this problem? I’m wondering if it could be an app enabling secure input mode (a.k.a. “secure keyboard input”).

Basically, an app can put the system into a mode where no other apps (like, for instance, Hammerspoon) can watch what’s being typed. This normally gets turned on when you’re in a password field. Some terminal apps turn it on by default too (at least iTerm2 and Apple’s Terminal, though possibly not Alacritty?). And sometimes certain apps can forget to turn it off again afterwards.

I think this originally just affected hs.eventtaps, but at some point started blocking hs.hotkeys as well - see #2880. Some people in that thread can still get hs.hotkeys that involve modifier keys to work, but others can’t. I wonder if it’s something like “ignore any hotkeys that were created after secure input was turned on (e.g. by a modal being entered)”.

Try changing your hyper.entered() function to look like this:

function hyper:entered()
  if hs.eventtap.isSecureInputEnabled() then
    hs.alert("⚠️ Secure Input is on. Hyper Mode commands might not work.")
  end

  -- The rest of this is the code you already had here:
  hyperAlerts = {}
  for i, screen in pairs(hs.screen.allScreens()) do
    alert = hs.alert("Hyper Mode ✈", hyperStyle, screen, "")
    hyperAlerts[i] = alert
  end
end

If the modal only gets stuck after showing that warning message, then it’s definitely secure input mode that’s causing the problem. You might also want to bind <kbd>Escape</kbd> in the modal to an exit command, so you can at least get out of hyper mode when this happens - it sounds like <kbd>Escape</kbd> hotkeys still work while SIM is on.[^canttest] (Or maybe just add hyper:exit(); return after the warning message, to immediately kick you back out of hyper mode before it gets stuck.)

[^canttest]: I’m stuck on an older system at the moment, so I’m afraid I can’t test this yet.

If the app that’s responsible still turns SIM off when it’s done, you might be able to work around it by creating and focusing an invisible hs.webview[^whynotcanvas] in hyper:entered() (thus taking you out of the password field or whatever), then restoring focus to the correct window in hyper:exited(). (Of course, all the other commands that look at hs.window.focusedWindow() would need to be changed to work on whatever window was saved by hyper:entered().)

[^whynotcanvas]: Edit 11/17/2023: I originally said to use an empty hs.canvas, because it’s probably more lightweight than a webview, but Hammerspoon doesn’t seem to give you a way to focus an hs.canvas. hs.webviews don’t directly have a focus method either, but you can at least do someWebview:hswindow():focus().

See also: #2897, #2858