local obj = {} local json = require("hs.json") -- ========================================== -- CONFIGURATION -- ========================================== obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json" obj.saveInterval = 300 -- Apps that require AppleScript authority local stubbornApps = { ["Gemini"] = true, ["AFFiNE"] = true, ["Hammerspoon"] = true, ["System Settings"] = true } -- Apps or Bundle IDs that the window manager should NEVER touch local ignoreList = { ["TheBoringNotch"] = true, ["theboringteam.boringnotch"] = true, ["boring.notch"] = true, ["The Boring Notch"] = true, ["Control Center"] = true, ["Notification Center"] = true, ["Dock"] = true, ["com.surteesstudios.Bartender"] = true, ["pro.betterdisplay.BetterDisplay"] = true } -- ========================================== -- LOGIC -- ========================================== -- Check if window actually needs to be moved local function needsMoving(win, savedFrame) local curr = win:frame() local threshold = 10 return math.abs(curr.x - savedFrame.x) > threshold or math.abs(curr.y - savedFrame.y) > threshold end function obj.saveLayout(silent) -- Safety: Don't save if the system is currently sleeping or screens are locked if hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end local allWindows = hs.window.allWindows() local consoleWin = hs.console.hswindow() if consoleWin then table.insert(allWindows, consoleWin) end local currentLayout = { saveTime = os.date("%Y-%m-%d %H:%M:%S"), windows = {} } for _, win in ipairs(allWindows) do local app = win:application() local screen = win:screen() local frame = win:frame() -- GHOST WINDOW FIX: Only save windows that have actual size if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then local name = app:name() or "" local bid = app:bundleID() or "" -- IGNORE LOGIC: Match by name, Bundle ID, or fuzzy notch match local isIgnored = ignoreList[name] or ignoreList[bid] or name:find("TheBoringNotch") if not isIgnored then table.insert(currentLayout.windows, { appName = name, bundleID = bid, winTitle = win:title(), screenName = screen:name(), x = frame.x, y = frame.y, w = frame.w, h = frame.h }) end end end hs.json.write(currentLayout, obj.layoutFile, true, true) if not silent then hs.alert.show("Layout Saved: " .. currentLayout.saveTime, 1.5) end end function obj.restoreLayout() local data = hs.json.read(obj.layoutFile) if not data then return end local windowList = data.windows or data local appleScriptParts = {"tell application \"System Events\""} local needsAppleScript = false for _, winData in ipairs(windowList) do -- Handle Hammerspoon Console specifically if winData.appName == "Hammerspoon" and winData.winTitle == "Hammerspoon Console" then local cWin = hs.console.hswindow() if cWin and needsMoving(cWin, winData) then cWin:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end else local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName) if app then for _, win in ipairs(app:allWindows()) do local targetScreen = hs.screen.find(winData.screenName) or hs.screen.primaryScreen() local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini") local isStubborn = stubbornApps[winData.appName] local shouldMove = (win:title() == winData.winTitle or #app:allWindows() == 1 or isFuzzy or isStubborn) if targetScreen and shouldMove and needsMoving(win, winData) then if isStubborn then needsAppleScript = true table.insert(appleScriptParts, string.format([[ try tell process "%s" to set position of window 1 to {%d, %d} tell process "%s" to set size of window 1 to {%d, %d} end try ]], winData.appName, winData.x, winData.y, winData.appName, winData.w, winData.h)) else win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end end end end end end if needsAppleScript then table.insert(appleScriptParts, "end tell") hs.applescript.applescript(table.concat(appleScriptParts, "\n")) end end -- Hotkeys hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false) end) hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end) -- Automation: Screen Watcher obj.screenWatcher = hs.screen.watcher.new(function() hs.timer.doAfter(2, function() obj.restoreLayout() end) end):start() -- Automation: Sleep Watcher obj.sleepWatcher = hs.caffeinate.watcher.new(function(event) if event == hs.caffeinate.watcher.systemWillSleep or event == hs.caffeinate.watcher.screensDidLock then if obj.autoSaveTimer then obj.autoSaveTimer:stop() end elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidUnlock then if obj.autoSaveTimer then obj.autoSaveTimer:start() end hs.timer.doAfter(3, function() obj.restoreLayout() end) end end):start() obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true) end) hs.alert.show("Window Manager Loaded", 1.5) return obj