From 28e1706e281c946a7e8d95195dc11262d9449f58 Mon Sep 17 00:00:00 2001 From: Felipe Rohde Date: Tue, 1 Jul 2025 11:01:34 -0300 Subject: [PATCH 1/2] Add OcusFocus spoon --- Source/OcusFocus.spoon/docs.json | 95 ++++++++++++++++ Source/OcusFocus.spoon/init.lua | 180 +++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 Source/OcusFocus.spoon/docs.json create mode 100644 Source/OcusFocus.spoon/init.lua diff --git a/Source/OcusFocus.spoon/docs.json b/Source/OcusFocus.spoon/docs.json new file mode 100644 index 00000000..db1755d8 --- /dev/null +++ b/Source/OcusFocus.spoon/docs.json @@ -0,0 +1,95 @@ +[ + { + "name": "OcusFocus", + "desc": "Prevents background apps from stealing focus.", + "doc": "Prevents background apps from stealing focus, while allowing expected user actions like Cmd+Tab, Cmd+Tab+Click, and clicking Dock icons. Useful for keeping your active window focused and blocking focus-stealing apps from interrupting your work.", + "stripped_doc": "", + "type": "Module", + "Constructor": [], + "Command": [], + "Constant": [], + "Deprecated": [], + "Field": [], + "Function": [], + "Variable": [], + "Method": [ + { + "name": "start", + "type": "Method", + "signature": "OcusFocus:start()", + "def": "OcusFocus:start()", + "desc": "Starts OcusFocus", + "doc": "Starts OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "parameters": [ + " * None" + ], + "returns": [ + " * The OcusFocus object" + ], + "notes": [ + " * Begins monitoring for app focus changes.", + " * Automatically restores focus to last user-selected window when unwanted focus steal occurs." + ], + "stripped_doc": "" + }, + { + "name": "stop", + "type": "Method", + "signature": "OcusFocus:stop()", + "def": "OcusFocus:stop()", + "desc": "Stops OcusFocus", + "doc": "Stops OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "parameters": [ + " * None" + ], + "returns": [ + " * The OcusFocus object" + ], + "notes": [ + " * Stops all focus monitoring and event interception." + ], + "stripped_doc": "" + } + ], + "items": [ + { + "name": "start", + "type": "Method", + "signature": "OcusFocus:start()", + "def": "OcusFocus:start()", + "desc": "Starts OcusFocus", + "doc": "Starts OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "parameters": [ + " * None" + ], + "returns": [ + " * The OcusFocus object" + ], + "notes": [ + " * Begins monitoring for app focus changes.", + " * Automatically restores focus to last user-selected window when unwanted focus steal occurs." + ], + "stripped_doc": "" + }, + { + "name": "stop", + "type": "Method", + "signature": "OcusFocus:stop()", + "def": "OcusFocus:stop()", + "desc": "Stops OcusFocus", + "doc": "Stops OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "parameters": [ + " * None" + ], + "returns": [ + " * The OcusFocus object" + ], + "notes": [ + " * Stops all focus monitoring and event interception." + ], + "stripped_doc": "" + } + ], + "submodules": [] + } +] \ No newline at end of file diff --git a/Source/OcusFocus.spoon/init.lua b/Source/OcusFocus.spoon/init.lua new file mode 100644 index 00000000..6c945a82 --- /dev/null +++ b/Source/OcusFocus.spoon/init.lua @@ -0,0 +1,180 @@ +--- === OcusFocus === +--- +--- Prevents background apps from stealing focus, while allowing user-triggered switches like Cmd+Tab, Dock clicks, and iTerm2 hotkey (e.g., Shift+Space). +--- Also prevents Dock-launched apps from stealing focus if the user switched elsewhere while the app was launching. + +local obj = {} +obj.__index = obj + +obj.name = "OcusFocus" +obj.version = "1.0" +obj.author = "Felipe Rohde " +obj.license = "MIT - https://opensource.org/licenses/MIT" +obj.homepage = "https://github.com/Hammerspoon/Spoons" + +local lastFocusedWindow = nil +local isUserSwitching = false +local blockInput = false +local allowNextActivation = false +local allowNextApp = nil + +-- Dock launch context +local dockLaunchedApp = nil +local dockLaunchTime = nil +local dockLaunchWindowCancelThreshold = 0.5 -- seconds + +-- Customize this for your own hotkey (e.g., iTerm2 trigger) +local hotkeyModifiers = { "shift" } +local hotkeyKey = "space" +local hotkeyAppName = "iTerm2" + +-- Apps that are always allowed to steal focus +local focusStealWhitelist = { + ["Finder"] = true, + ["SystemUIServer"] = true, + ["System Preferences"] = true, + ["System Settings"] = true, + ["SecurityAgent"] = true, + ["com.apple.authorizationhost"] = true, + ["com.apple.loginwindow"] = true, + ["Safari"] = true, + ["Google Chrome"] = true, + ["1Password"] = true +} + +local function isAllowedToStealFocus(app) + local appName = app:name() + if focusStealWhitelist[appName] then return true end + + if allowNextApp and appName == allowNextApp then + allowNextApp = nil + return true + end + + -- Dock-launched app logic + if dockLaunchedApp and appName == dockLaunchedApp then + local elapsed = hs.timer.secondsSinceEpoch() - (dockLaunchTime or 0) + if elapsed <= dockLaunchWindowCancelThreshold then + return false -- too soon, user may have canceled + end + -- else: too late to cancel + dockLaunchedApp = nil + end + + local win = app:focusedWindow() + if not win then return false end + + local role = win:role() + local subrole = win:subrole() + + return ( + subrole == "AXDialog" + or subrole == "AXSystemDialog" + or role == "AXFloatingWindow" + ) +end + +local function registerUserSwitch() + isUserSwitching = true + hs.timer.doAfter(0.1, function() + local win = hs.window.focusedWindow() + if win then lastFocusedWindow = win end + isUserSwitching = false + end) +end + +--- OcusFocus:start() +function obj:start() + -- Modifier keys: Cmd+Tab, etc. + self.modKeyWatcher = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(evt) + local flags = evt:getFlags() + if flags["cmd"] or flags["ctrl"] or flags["alt"] or flags["shift"] then + allowNextActivation = true + dockLaunchedApp = nil -- user canceled dock app + end + return false + end) + self.modKeyWatcher:start() + + -- Mouse click (e.g. Dock click) + self.mouseClickWatcher = hs.eventtap.new({ hs.eventtap.event.types.leftMouseDown }, function(evt) + local pos = hs.mouse.getAbsolutePosition() + local appIconUnderCursor = hs.application.frontmostApplication() + if appIconUnderCursor then + dockLaunchedApp = appIconUnderCursor:name() + dockLaunchTime = hs.timer.secondsSinceEpoch() + end + allowNextActivation = true + return false + end) + self.mouseClickWatcher:start() + + -- Hotkey watcher for iTerm2 (or other apps) + self.customHotkeyWatcher = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(evt) + local flags = evt:getFlags() + local key = evt:getCharacters(true) + local match = hs.fnutils.every(hotkeyModifiers, function(mod) + return flags[mod] + end) + if match and key:lower() == hotkeyKey then + allowNextApp = hotkeyAppName + end + return false + end) + self.customHotkeyWatcher:start() + + -- Input blocker to prevent interference during correction + self.blockInputTap = hs.eventtap.new({ + hs.eventtap.event.types.keyDown, + hs.eventtap.event.types.leftMouseDown, + hs.eventtap.event.types.rightMouseDown + }, function(evt) + if blockInput then return true end + return false + end) + self.blockInputTap:start() + + -- App watcher + self.appWatcher = hs.application.watcher.new(function(appName, eventType, app) + if eventType == hs.application.watcher.activated then + local current = hs.window.focusedWindow() + + if isUserSwitching or allowNextActivation then + registerUserSwitch() + allowNextActivation = false + return + end + + if isAllowedToStealFocus(app) then + lastFocusedWindow = current + return + end + + -- Restore previous window + if lastFocusedWindow and current and current:id() ~= lastFocusedWindow:id() then + blockInput = true + hs.timer.doAfter(0.05, function() + if lastFocusedWindow then + lastFocusedWindow:becomeMain() + lastFocusedWindow:focus() + end + hs.timer.doAfter(0.15, function() + blockInput = false + end) + end) + end + end + end) + self.appWatcher:start() +end + +--- OcusFocus:stop() +function obj:stop() + if self.modKeyWatcher then self.modKeyWatcher:stop() end + if self.mouseClickWatcher then self.mouseClickWatcher:stop() end + if self.customHotkeyWatcher then self.customHotkeyWatcher:stop() end + if self.blockInputTap then self.blockInputTap:stop() end + if self.appWatcher then self.appWatcher:stop() end +end + +return obj \ No newline at end of file From b62e40377114a62dac2f87ef306a476b6930aea1 Mon Sep 17 00:00:00 2001 From: Felipe Rohde Date: Wed, 2 Jul 2025 09:32:41 -0300 Subject: [PATCH 2/2] Adjusted the plugin name. --- .../docs.json | 42 +++++++++---------- .../init.lua | 10 ++--- 2 files changed, 26 insertions(+), 26 deletions(-) rename Source/{OcusFocus.spoon => HocusFocus.spoon}/docs.json (56%) rename Source/{OcusFocus.spoon => HocusFocus.spoon}/init.lua (98%) diff --git a/Source/OcusFocus.spoon/docs.json b/Source/HocusFocus.spoon/docs.json similarity index 56% rename from Source/OcusFocus.spoon/docs.json rename to Source/HocusFocus.spoon/docs.json index db1755d8..e0ede567 100644 --- a/Source/OcusFocus.spoon/docs.json +++ b/Source/HocusFocus.spoon/docs.json @@ -1,6 +1,6 @@ [ { - "name": "OcusFocus", + "name": "HocusFocus", "desc": "Prevents background apps from stealing focus.", "doc": "Prevents background apps from stealing focus, while allowing expected user actions like Cmd+Tab, Cmd+Tab+Click, and clicking Dock icons. Useful for keeping your active window focused and blocking focus-stealing apps from interrupting your work.", "stripped_doc": "", @@ -16,15 +16,15 @@ { "name": "start", "type": "Method", - "signature": "OcusFocus:start()", - "def": "OcusFocus:start()", - "desc": "Starts OcusFocus", - "doc": "Starts OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "signature": "HocusFocus:start()", + "def": "HocusFocus:start()", + "desc": "Starts HocusFocus", + "doc": "Starts HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", "parameters": [ " * None" ], "returns": [ - " * The OcusFocus object" + " * The HocusFocus object" ], "notes": [ " * Begins monitoring for app focus changes.", @@ -35,15 +35,15 @@ { "name": "stop", "type": "Method", - "signature": "OcusFocus:stop()", - "def": "OcusFocus:stop()", - "desc": "Stops OcusFocus", - "doc": "Stops OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "signature": "HocusFocus:stop()", + "def": "HocusFocus:stop()", + "desc": "Stops HocusFocus", + "doc": "Stops HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", "parameters": [ " * None" ], "returns": [ - " * The OcusFocus object" + " * The HocusFocus object" ], "notes": [ " * Stops all focus monitoring and event interception." @@ -55,15 +55,15 @@ { "name": "start", "type": "Method", - "signature": "OcusFocus:start()", - "def": "OcusFocus:start()", - "desc": "Starts OcusFocus", - "doc": "Starts OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", + "signature": "HocusFocus:start()", + "def": "HocusFocus:start()", + "desc": "Starts HocusFocus", + "doc": "Starts HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Begins monitoring for app focus changes.\n * Automatically restores focus to last user-selected window when unwanted focus steal occurs.", "parameters": [ " * None" ], "returns": [ - " * The OcusFocus object" + " * The HocusFocus object" ], "notes": [ " * Begins monitoring for app focus changes.", @@ -74,15 +74,15 @@ { "name": "stop", "type": "Method", - "signature": "OcusFocus:stop()", - "def": "OcusFocus:stop()", - "desc": "Stops OcusFocus", - "doc": "Stops OcusFocus\n\nParameters:\n * None\n\nReturns:\n * The OcusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", + "signature": "HocusFocus:stop()", + "def": "HocusFocus:stop()", + "desc": "Stops HocusFocus", + "doc": "Stops HocusFocus\n\nParameters:\n * None\n\nReturns:\n * The HocusFocus object\n\nNotes:\n * Stops all focus monitoring and event interception.", "parameters": [ " * None" ], "returns": [ - " * The OcusFocus object" + " * The HocusFocus object" ], "notes": [ " * Stops all focus monitoring and event interception." diff --git a/Source/OcusFocus.spoon/init.lua b/Source/HocusFocus.spoon/init.lua similarity index 98% rename from Source/OcusFocus.spoon/init.lua rename to Source/HocusFocus.spoon/init.lua index 6c945a82..04457ae4 100644 --- a/Source/OcusFocus.spoon/init.lua +++ b/Source/HocusFocus.spoon/init.lua @@ -1,4 +1,4 @@ ---- === OcusFocus === +--- === HocusFocus === --- --- Prevents background apps from stealing focus, while allowing user-triggered switches like Cmd+Tab, Dock clicks, and iTerm2 hotkey (e.g., Shift+Space). --- Also prevents Dock-launched apps from stealing focus if the user switched elsewhere while the app was launching. @@ -6,8 +6,8 @@ local obj = {} obj.__index = obj -obj.name = "OcusFocus" -obj.version = "1.0" +obj.name = "HocusFocus" +obj.version = "0.1" obj.author = "Felipe Rohde " obj.license = "MIT - https://opensource.org/licenses/MIT" obj.homepage = "https://github.com/Hammerspoon/Spoons" @@ -83,7 +83,7 @@ local function registerUserSwitch() end) end ---- OcusFocus:start() +--- HocusFocus:start() function obj:start() -- Modifier keys: Cmd+Tab, etc. self.modKeyWatcher = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(evt) @@ -168,7 +168,7 @@ function obj:start() self.appWatcher:start() end ---- OcusFocus:stop() +--- HocusFocus:stop() function obj:stop() if self.modKeyWatcher then self.modKeyWatcher:stop() end if self.mouseClickWatcher then self.mouseClickWatcher:stop() end