-- WindowManager.lua local obj = {} local json = require("hs.json") local styledtext = require("hs.styledtext") -- ========================================== -- CONFIGURATION & CENTRAL CONFIG LOADING -- ========================================== obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json" local configFile = os.getenv("HOME") .. "/.hammerspoon/config.json" local stubbornAppsList = { ["Gemini"] = true, ["AFFiNE"] = true, ["Terminal"] = true, ["System Settings"] = true, ["Hammerspoon"] = true } local ignoreListItems = { ["TheBoringNotch"] = true, ["Control Center"] = true, ["Notification Center"] = true, ["Dock"] = true, ["BetterDisplay"] = true, ["Stats"] = true, ["DockDoor"] = true, ["Bartender 6"] = true, ["Bartender"] = true, ["Arq"] = true, ["Arq Agent"] = true, ["TypeWhisper"] = true, ["com.typewhisper.mac"] = true } -- Load Central Config if available local cfgData = hs.json.read(configFile) if cfgData then if cfgData.stubbornAppsList then stubbornAppsList = cfgData.stubbornAppsList end if cfgData.ignoreListItems then ignoreListItems = cfgData.ignoreListItems end end obj.saveInterval = 300 obj.saveCountdown = obj.saveInterval obj.isRescued = false obj.isTransitioning = false obj.isRestoring = false obj.isMenuUpdating = false -- Safety guard to prevent concurrent IPC menu rendering loops obj.wakeTimer = nil obj.lastScreenCount = #hs.screen.allScreens() obj.lastSavedTime = "Never" -- ========================================== -- INTERNAL UTILITIES -- ========================================== local function log(msg) print(string.format("WindowManager [%s]: %s", os.date("%H:%M:%S"), msg)) end local function wrapText(text, limit) local lines = {} local currentLine = "" for word in text:gmatch("%S+") do if #currentLine + #word >= limit then table.insert(lines, currentLine) currentLine = " " .. word else currentLine = currentLine == "" and " โ†ณ " .. word or currentLine .. " " .. word end end table.insert(lines, currentLine) return lines end -- ========================================== -- CORE LOGIC -- ========================================== function obj.saveLayout(silent) local currentScreens = #hs.screen.allScreens() if currentScreens ~= obj.lastScreenCount then log(string.format("Screen mismatch (%d vs %d). Syncing count.", currentScreens, obj.lastScreenCount)) obj.lastScreenCount = currentScreens end local layout = { saveTime = os.date("%H:%M:%S"), screenCount = currentScreens, windows = {} } for _, win in ipairs(hs.window.allWindows()) do pcall(function() local app = win:application() if app and win:isVisible() and win:frame().w > 0 then local appName = app:name() or "" local bundleID = app:bundleID() or "" if not ignoreListItems[appName] and not ignoreListItems[bundleID] then table.insert(layout.windows, { appName = appName, bundleID = bundleID, winTitle = win:title(), x = math.floor(win:frame().x), y = math.floor(win:frame().y), w = math.floor(win:frame().w), h = math.floor(win:frame().h) }) end end end) end hs.json.write(layout, obj.layoutFile, true, true) obj.lastSavedTime = layout.saveTime if silent then log("Autosave successful.") else log("Manual save successful.") hs.alert.show("Layout Saved", 1.5) end end function obj.restoreLayout() obj.isRestoring = true obj.isTransitioning = false obj.isRescued = false local data = hs.json.read(obj.layoutFile) if not data or not data.windows then log("Restore FAILED: No data found in JSON.") obj.isRestoring = false return end hs.screen.restoreGamma() local savedByApp = {} for _, winData in ipairs(data.windows) do if not ignoreListItems[winData.appName] and not ignoreListItems[winData.bundleID] then savedByApp[winData.appName] = savedByApp[winData.appName] or {} table.insert(savedByApp[winData.appName], winData) end end for appName, entries in pairs(savedByApp) do table.sort(entries, function(a, b) if a.y == b.y then return a.x < b.x end return a.y < b.y end) end log("RESTORE STARTING...") for appName, savedEntries in pairs(savedByApp) do local firstEntry = savedEntries[1] local app = hs.application.get(firstEntry.bundleID) or hs.application.get(appName) if app then local physicalWins = {} for _, w in ipairs(app:allWindows()) do if w:isVisible() and w:frame().w > 0 then table.insert(physicalWins, w) end end table.sort(physicalWins, function(a, b) local af, bf = a:frame(), b:frame() if af.y == bf.y then return af.x < bf.x end return af.y < bf.y end) for i, winData in ipairs(savedEntries) do local win = physicalWins[i] if win then log(string.format("Moving %s (%d/%d)", appName, i, #savedEntries)) if win:isMinimized() then win:unminimize() end pcall(function() win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) if stubbornAppsList[appName] then hs.timer.doAfter(0.5, function() pcall(function() win:setFrame({x=winData.x, y=winData.y, w=winData.w, h=winData.h}, 0) end) end) end end) end end else log(string.format("Skip: %s is not running.", appName)) end end hs.alert.show("Layout Restored", 1.5) hs.timer.doAfter(5, function() obj.isRestoring = false obj.isTransitioning = false log("RESTORE CYCLE COMPLETE.") end) end function obj.rescueWindowsToLaptop() local primary = hs.screen.primaryScreen() if not primary then return end local maxFrame = primary:frame() log("RESCUE: Cascading windows on laptop.") local allWindows = hs.window.allWindows() local staggerOffset = 0 for _, win in ipairs(allWindows) do pcall(function() local app = win:application() if app and win:isVisible() and win:frame().w > 0 then local appName = app:name() or "" local bundleID = app:bundleID() or "" if not ignoreListItems[appName] and not ignoreListItems[bundleID] then local f = win:frame() if f.w > maxFrame.w then f.w = maxFrame.w - 100 end if f.h > maxFrame.h then f.h = maxFrame.h - 100 end f.x = maxFrame.x + 50 + staggerOffset f.y = maxFrame.y + 50 + staggerOffset win:setFrame(f, 0) staggerOffset = staggerOffset + 30 if staggerOffset > 150 then staggerOffset = 0 end end end end) end obj.isRescued = true obj.isTransitioning = false hs.alert.show("Windows Cascaded", 1.5) end -- ========================================== -- MENUBAR & WATCHERS -- ========================================== local timerMenu = hs.menubar.new() function updateMenu() -- Safety Guard: Bail if update is already running from a previous thread block if obj.isMenuUpdating then return end obj.isMenuUpdating = true if timerMenu then local screens = hs.screen.allScreens() timerMenu:setTitle(string.format("๐Ÿ’  %d:%02d", math.floor(obj.saveCountdown / 60), obj.saveCountdown % 60)) local menuTable = { { title = "๐Ÿ“… Last Saved: " .. (obj.lastSavedTime or "Never"), disabled = true }, { title = ((#screens > 1) and "๐Ÿ–ฅ๏ธ Docked" or "๐Ÿ’ป Laptop") .. " (" .. #screens .. " Screens)", disabled = true }, { title = "-" }, { title = "๐Ÿ“ธ Save Layout (โ‡งโŒ˜W)", fn = function() obj.saveLayout(false); obj.saveCountdown = obj.saveInterval end }, { title = "๐Ÿ”„ Restore Layout (โ‡งโŒ˜R)", fn = function() obj.restoreLayout() end }, { title = "๐Ÿš€ Rescue Windows (Cascade on Laptop) (โ‡งโŒ˜โŒƒL)", fn = obj.rescueWindowsToLaptop }, { title = "-" }, { title = "๐Ÿ“ฆ Saved Apps:", disabled = true } } local data = hs.json.read(obj.layoutFile) if data and data.windows then local seen, names = {}, {} for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(names, w.appName .. ", "); seen[w.appName] = true end end for _, line in ipairs(wrapText(table.concat(names):gsub(", $", ""), 45)) do table.insert(menuTable, { title = line, disabled = true }) end end timerMenu:setMenu(menuTable) end obj.isMenuUpdating = false end obj.powerWatcher = hs.caffeinate.watcher.new(function(event) if event == hs.caffeinate.watcher.systemWillSleep or event == hs.caffeinate.watcher.screensDidSleep or event == hs.caffeinate.watcher.screensDidLock then log("POWER: Sleep event.") obj.isTransitioning = true -- Stop both timers entirely so they don't fire or stack requests while suspended if obj.autoSaveTimer then obj.autoSaveTimer:stop() end if obj.clockTimer then obj.clockTimer:stop() end elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidWake or event == hs.caffeinate.watcher.screensDidUnlock then log("POWER: Wake event.") obj.saveCountdown = obj.saveInterval obj.isMenuUpdating = false -- Explicitly clean guard flag upon waking up -- Safely start timers up only after wakeup initialization if obj.autoSaveTimer then obj.autoSaveTimer:start() end if obj.clockTimer then obj.clockTimer:start() end if obj.wakeTimer then obj.wakeTimer:stop() end local currentScreens = #hs.screen.allScreens() if currentScreens > 1 then obj.wakeTimer = hs.timer.doAfter(12, function() obj.isTransitioning = false obj.isRestoring = false obj.lastScreenCount = currentScreens obj.restoreLayout() obj.wakeTimer = nil end) else log("WAKE SKIP: Single screen detected. Syncing count only.") obj.isTransitioning = false obj.isRestoring = false obj.lastScreenCount = currentScreens updateMenu() end end end):start() hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); obj.saveCountdown = obj.saveInterval end) hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end) hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", obj.rescueWindowsToLaptop) obj.screenWatcher = hs.screen.watcher.new(function() if obj.isTransitioning or obj.isRestoring or obj.wakeTimer then log("DOCK EVENT: Ignored.") return end local currentScreens = #hs.screen.allScreens() if currentScreens == obj.lastScreenCount then log("DOCK EVENT: Ignored (Screen count unchanged).") return end log("DOCK EVENT: Detected.") obj.isTransitioning = true hs.timer.doAfter(15, function() if obj.isTransitioning and not obj.isRestoring then obj.isTransitioning = false log("STATE GUARD: Emergency clear of busy flag.") end end) hs.timer.doAfter(7, function() obj.lastScreenCount = #hs.screen.allScreens() obj.isTransitioning = false if obj.lastScreenCount > 1 then obj.restoreLayout() else obj.rescueWindowsToLaptop() end updateMenu() end) end):start() obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true) obj.saveCountdown = obj.saveInterval end) obj.clockTimer = hs.timer.doEvery(1, function() obj.saveCountdown = obj.saveCountdown - 1 if obj.saveCountdown < 0 then obj.saveCountdown = obj.saveInterval end updateMenu() end) updateMenu() -- ========================================== -- RAYCAST INTERACTIVE CHOOSER MENU -- ========================================== function obj.showMenu() local min = math.floor(obj.saveCountdown / 60) local sec = obj.saveCountdown % 60 -- 1. Base Core Operational Rows local choices = { { text = string.format("โฑ๏ธ Next Autosave: %d:%02d", min, sec), subText = "๐Ÿ“… Last Saved Profile State: " .. obj.lastSavedTime, action = "info" }, { text = "๐Ÿ“ธ Save State Layout", subText = "Snapshot active positions to saved_layout.json (Resets auto-timer)", action = "save" }, { text = "๐Ÿ”„ Restore State Layout", subText = "Force apps and window sizes back to your saved profile state", action = "restore" }, { text = "๐Ÿš€ Rescue Windows", subText = "Cascade active window threads onto primary laptop screen space", action = "rescue" }, { text = "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", subText = "๐Ÿ“ฆ Captured Windows Inside Profile State File:", action = "info" } } -- 2. Dynamically read layout file and append saved windows local data = hs.json.read(obj.layoutFile) if data and data.windows and #data.windows > 0 then for _, win in ipairs(data.windows) do -- Verify if the application is currently running on the system server local isRunning = hs.application.get(win.bundleID) or hs.application.get(win.appName) local statusIndicator = isRunning and "๐ŸŸข" or "๐Ÿ”ด" local cleanTitle = (win.winTitle and win.winTitle ~= "") and win.winTitle or "Untitled Window" if #cleanTitle > 60 then cleanTitle = string.sub(cleanTitle, 1, 57) .. "..." end table.insert(choices, { text = string.format("%s %s", statusIndicator, win.appName), subText = string.format("โ†ณ Title: \"%s\" | Bounds: %dx%d at (%d,%d)", cleanTitle, win.w, win.h, win.x, win.y), action = "info" }) end else table.insert(choices, { text = "โš ๏ธ No Saved Window Metrics Found", subText = "Run a Save operation to record your desktop layout profile context.", action = "info" }) end if obj.instanceChooser then obj.instanceChooser:hide() end obj.instanceChooser = hs.chooser.new(function(choice) if choice then if choice.action == "save" then obj.saveLayout(false) obj.saveCountdown = obj.saveInterval elseif choice.action == "restore" then obj.restoreLayout() elseif choice.action == "rescue" then obj.rescueWindowsToLaptop() end end end) obj.instanceChooser:placeholderText("Select a background workspace operation...") obj.instanceChooser:choices(choices) obj.instanceChooser:show() end -- Export module instance globally for direct IPC command routing WindowManager = obj return obj