Initial commit of Hammerspoon config

This commit is contained in:
Franco Pellicciotti
2026-05-14 18:59:23 -04:00
commit 8a9f5c37ff
683 changed files with 180195 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
--- AppBorders.lua
local logger = hs.logger.new("AppBorders")
logger.i("Init")
-- 1. Variables must be global (no 'local') so they don't get garbage collected
global_border = nil
isBorderEnabled = false -- This tracks if the mode is ON or OFF
function initBorder()
local win = hs.window.focusedWindow()
local frame
if win ~= nil then
frame = win:frame()
else
frame = hs.geometry.new(0, 0, 0, 0)
end
global_border = hs.drawing.rectangle(frame)
global_border:setStrokeColor({ ["red"] = 1, ["blue"] = 0, ["green"] = 0, ["alpha"] = 0.8 })
global_border:setFill(false)
global_border:setStrokeWidth(8)
-- Only show if enabled
if isBorderEnabled then
global_border:show()
end
end
function redrawBorder(window, name, event)
-- If the toggle is OFF, make sure border is hidden and stop
if not isBorderEnabled then
if global_border then global_border:hide() end
return
end
-- Skip specific apps
if name == 'Kontrollzentrum' then return end
local win = hs.window.focusedWindow()
if win ~= nil then
-- Create the border object if it doesn't exist yet
if not global_border then initBorder() end
local newFrame = win:frame()
local currentFrame = global_border:frame()
if not newFrame:equals(currentFrame) then
global_border:setFrame(newFrame)
end
global_border:show()
else
if global_border then global_border:hide() end
end
end
-- 2. Setup the window filter (but it won't do anything until isBorderEnabled is true)
allwindows = hs.window.filter.new(nil)
allwindows:subscribe(hs.window.filter.windowCreated, redrawBorder)
allwindows:subscribe(hs.window.filter.windowDestroyed, redrawBorder)
allwindows:subscribe(hs.window.filter.windowFocused, redrawBorder)
allwindows:subscribe(hs.window.filter.windowMoved, redrawBorder)
allwindows:subscribe(hs.window.filter.windowUnfocused, redrawBorder)
-- 3. THE TOGGLE FUNCTION
function toggleAppBorders()
isBorderEnabled = not isBorderEnabled
if isBorderEnabled then
hs.alert.show("Window Borders: ON")
if not global_border then initBorder() end
redrawBorder() -- Trigger immediate draw
else
hs.alert.show("Window Borders: OFF")
if global_border then global_border:hide() end
end
end
-- 4. THE HOTKEY (Change "B" or the modifiers to your liking)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "B", function()
toggleAppBorders()
end)
+27
View File
@@ -0,0 +1,27 @@
local AppConfig = {}
-- Master list of apps that require AppleScript/Force-moves
AppConfig.stubbornApps = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true,
["Hammerspoon"] = true
}
-- Master list of system components/utilities to ignore
AppConfig.ignoreList = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true,
["stats"] = true,
["eu.exelban.Stats"] = true,
["com.ethanbills.DockDoor"] = true,
["DockDoor"] = true
}
return AppConfig
+73
View File
@@ -0,0 +1,73 @@
-- ~/.hammerspoon/ArqMonitor.lua
local arqMenu = hs.menubar.new()
local logPath = "/Library/Application Support/ArqAgent/logs/"
local targets = {
{ label = "Arq Storage", search = "Arq Cloud Storage" },
{ label = "Google Drive", search = "Google Drive" }
}
local function getDestinationStatus()
local menuTable = {}
for _, target in ipairs(targets) do
-- Find the latest log file that contains the target name
local findLog = "grep -l '" .. target.search .. "' '" .. logPath .. "'*.log 2>/dev/null | xargs ls -t | head -n 1"
local logFile = hs.execute(findLog)
local status = "⏳ Waiting"
if logFile and logFile ~= "" then
logFile = logFile:gsub("%s+$", "")
-- Check for the 'ended' string in that specific file
local checkEnded = "grep 'Backup activity ended' '" .. logFile .. "'"
local finished = hs.execute(checkEnded)
if finished and finished ~= "" then
status = "✅ Completed"
else
-- If not ended, check if it at least started or is snapshotting
local checkStarted = "grep -E 'Backup activity started|snapshot' '" .. logFile .. "'"
local started = hs.execute(checkStarted)
if started and started ~= "" then
status = "⏳ In Progress"
end
end
end
table.insert(menuTable, { title = target.label .. ": " .. status, disabled = true })
end
table.insert(menuTable, { title = "-" })
table.insert(menuTable, { title = "Force Refresh", fn = function() updateArqStatus() end })
return menuTable
end
function updateArqStatus()
-- Global status: check the absolute newest log file in the folder
local lastLogCmd = "ls -t '" .. logPath .. "'*.log 2>/dev/null | head -n 1"
local lastLog = hs.execute(lastLogCmd)
if lastLog and lastLog ~= "" then
lastLog = lastLog:gsub("%s+$", "")
local finished = hs.execute("grep 'Backup activity ended' '" .. lastLog .. "'")
if finished and finished ~= "" then
arqMenu:setTitle("Arq: ✅")
else
arqMenu:setTitle("Arq: ⏳")
end
else
arqMenu:setTitle("Arq: ❓")
end
arqMenu:setMenu(getDestinationStatus)
end
-- Refresh every 5 minutes
local arqTimer = hs.timer.doEvery(300, updateArqStatus)
updateArqStatus()
return arqMenu
+57
View File
@@ -0,0 +1,57 @@
--- Caffeine.lua
local obj = {}
-- 1. POPUP ON LOAD
hs.alert.show("Caffeine Loaded", 2)
-- 2. Create the menubar item
-- We use a unique ID just to keep things stable
obj.menu = hs.menubar.new(true, "CaffeineApp")
local on_message = 'Caffeine: ON'
local off_message = 'Caffeine: OFF'
local on_icon = "☕️"
local off_icon = "😴"
-- Removed since HyperKey is global
-- local hyper = {"cmd", "alt", "ctrl"}
function obj:init(mod, key, description)
local function setCaffeineDisplay(state)
if state then
hs.alert.show(on_message)
if obj.menu then
obj.menu:setTitle(on_icon)
-- This ensures the hover text says "Caffeine: ON"
obj.menu:setTooltip(on_message)
end
else
hs.alert.show(off_message)
if obj.menu then
obj.menu:setTitle(off_icon)
-- This ensures the hover text says "Caffeine: OFF"
obj.menu:setTooltip(off_message)
end
end
end
local function caffeineClicked()
-- Toggles the system sleep prevention and updates the UI
setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
end
if obj.menu then
obj.menu:setClickCallback(caffeineClicked)
-- Set initial state based on current system status
setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
end
-- Bind the hotkey (Cmd + Alt + Ctrl + C)
hs.hotkey.bind(mod, key, caffeineClicked)
end
-- Initialize the object
obj:init(hyper, 'u', "Toggle Caffeine")
return obj
+10
View File
@@ -0,0 +1,10 @@
-- ~/.hammerspoon/Config.lua
local _M = {}
_M.homeSSID = "Morpheus5G"
_M.vpnProfileID = "1774034144090"
_M.openvpnPath = "/Applications/OpenVPN Connect.app/Contents/MacOS/OpenVPN Connect"
_M.nasIP = "192.168.1.135"
_M.shares = {"Arq_Backup", "Documents", "Home", "Media"}
return _M
+51
View File
@@ -0,0 +1,51 @@
-- ~/.hammerspoon/Focus.lua
local Focus = {}
local hiddenAppBundleIDs = {}
function Focus.toggle()
local frontApp = hs.application.frontmostApplication()
-- UNFOCUS: Bring back only what WE hid
if #hiddenAppBundleIDs > 0 then
local count = 0
for _, bid in ipairs(hiddenAppBundleIDs) do
local app = hs.application.get(bid)
if app then
app:unhide()
count = count + 1
end
end
hiddenAppBundleIDs = {}
hs.alert.show("Focus Off: " .. count .. " apps restored")
return
end
-- FOCUS: Hide background apps instantly
local allApps = hs.application.runningApplications()
local count = 0
for _, app in ipairs(allApps) do
local bid = app:bundleID()
local name = app:name()
-- Hide if: Not current app, has a window, isn't already hidden, and isn't Hammerspoon
if app ~= frontApp and bid and app:mainWindow() and not app:isHidden() then
if bid ~= "org.hammerspoon.Hammerspoon" and name ~= "Hammerspoon" then
table.insert(hiddenAppBundleIDs, bid)
app:hide()
count = count + 1
end
end
end
if count > 0 then
hs.alert.show("Focus On: " .. count .. " apps hidden")
else
hs.alert.show("Already Focused")
end
end
hs.hotkey.bind(hyper, "F", Focus.toggle)
print("Focus Module: Stable App-Only Version Loaded")
return Focus
BIN
View File
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
--- AppBorders.lua
local logger = hs.logger.new("AppBorders")
logger.i("Init")
-- 1. Variables must be global (no 'local') so they don't get garbage collected
global_border = nil
isBorderEnabled = false -- This tracks if the mode is ON or OFF
function initBorder()
local win = hs.window.focusedWindow()
local frame
if win ~= nil then
frame = win:frame()
else
frame = hs.geometry.new(0, 0, 0, 0)
end
global_border = hs.drawing.rectangle(frame)
global_border:setStrokeColor({ ["red"] = 1, ["blue"] = 0, ["green"] = 0, ["alpha"] = 0.8 })
global_border:setFill(false)
global_border:setStrokeWidth(8)
-- Only show if enabled
if isBorderEnabled then
global_border:show()
end
end
function redrawBorder(window, name, event)
-- If the toggle is OFF, make sure border is hidden and stop
if not isBorderEnabled then
if global_border then global_border:hide() end
return
end
-- Skip specific apps
if name == 'Kontrollzentrum' then return end
local win = hs.window.focusedWindow()
if win ~= nil then
-- Create the border object if it doesn't exist yet
if not global_border then initBorder() end
local newFrame = win:frame()
local currentFrame = global_border:frame()
if not newFrame:equals(currentFrame) then
global_border:setFrame(newFrame)
end
global_border:show()
else
if global_border then global_border:hide() end
end
end
-- 2. Setup the window filter (but it won't do anything until isBorderEnabled is true)
allwindows = hs.window.filter.new(nil)
allwindows:subscribe(hs.window.filter.windowCreated, redrawBorder)
allwindows:subscribe(hs.window.filter.windowDestroyed, redrawBorder)
allwindows:subscribe(hs.window.filter.windowFocused, redrawBorder)
allwindows:subscribe(hs.window.filter.windowMoved, redrawBorder)
allwindows:subscribe(hs.window.filter.windowUnfocused, redrawBorder)
-- 3. THE TOGGLE FUNCTION
function toggleAppBorders()
isBorderEnabled = not isBorderEnabled
if isBorderEnabled then
hs.alert.show("Window Borders: ON")
if not global_border then initBorder() end
redrawBorder() -- Trigger immediate draw
else
hs.alert.show("Window Borders: OFF")
if global_border then global_border:hide() end
end
end
-- 4. THE HOTKEY (Change "B" or the modifiers to your liking)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "B", function()
toggleAppBorders()
end)
+56
View File
@@ -0,0 +1,56 @@
--- Caffeine.lua
local obj = {}
-- 1. POPUP ON LOAD
hs.alert.show("Caffeine Loaded", 2)
-- 2. Create the menubar item
-- We use a unique ID just to keep things stable
obj.menu = hs.menubar.new(true, "CaffeineApp")
local on_message = 'Caffeine: ON'
local off_message = 'Caffeine: OFF'
local on_icon = "☕️"
local off_icon = "😴"
local hyper = {"cmd", "alt", "ctrl"}
function obj:init(mod, key, description)
local function setCaffeineDisplay(state)
if state then
hs.alert.show(on_message)
if obj.menu then
obj.menu:setTitle(on_icon)
-- This ensures the hover text says "Caffeine: ON"
obj.menu:setTooltip(on_message)
end
else
hs.alert.show(off_message)
if obj.menu then
obj.menu:setTitle(off_icon)
-- This ensures the hover text says "Caffeine: OFF"
obj.menu:setTooltip(off_message)
end
end
end
local function caffeineClicked()
-- Toggles the system sleep prevention and updates the UI
setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
end
if obj.menu then
obj.menu:setClickCallback(caffeineClicked)
-- Set initial state based on current system status
setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
end
-- Bind the hotkey (Cmd + Alt + Ctrl + C)
hs.hotkey.bind(mod, key, caffeineClicked)
end
-- Initialize the object
obj:init(hyper, 'c', "Toggle Caffeine")
return obj
+219
View File
@@ -0,0 +1,219 @@
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
selector.hotkeys = {}
if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end
local stubbornAppsList = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true
}
-- ==========================================
-- INTERNAL LOGIC
-- ==========================================
local function wrapText(text, limit)
local lines = {}
local currentLine = ""
for word in text:gmatch("%S+") do
local cleanWord = word:gsub(",$", "")
if #currentLine + #word >= limit then
table.insert(lines, currentLine .. (currentLine ~= "" and "," or ""))
currentLine = " " .. word
else
currentLine = currentLine == "" and "" .. word or currentLine .. " " .. word
end
end
table.insert(lines, currentLine)
return lines
end
local function captureCurrentLayout()
local layout = { saveTime = os.date("%Y-%m-%d %H:%M:%S"), windows = {} }
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
local appName = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName, bundleID = bid, winTitle = win:title(),
screenName = screen:name(), x = math.floor(frame.x), y = math.floor(frame.y),
w = math.floor(frame.w), h = math.floor(frame.h)
})
end
end
end
return layout
end
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data then return end
local windowList = data.windows or data
local launchedAny = false
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
if winData.bundleID and winData.bundleID ~= "" then
hs.application.launchOrFocusByBundleID(winData.bundleID)
else
hs.application.launchOrFocus(winData.appName)
end
launchedAny = true
end
end
local function moveWindows()
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if app then
local pid = app:pid()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
for _, win in ipairs(app:allWindows()) do
-- Check if window is minimized and restore it if so
if win:isMinimized() then win:unminimize() end
local isStubborn = stubbornAppsList[app:name()] or isElectron
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
win:setFrame({x=x, y=y, w=w, h=h}, 0)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
set position of window 1 to {%d, %d}
set size of window 1 to {%d, %d}
end tell
]], pid, x, y, w, h)
hs.applescript.applescript(script)
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Launching apps...", 3.5)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
local function minimizeAll()
local allWindows = hs.window.filter.new():getWindows()
for _, win in ipairs(allWindows) do
local app = win:application()
if app and not ignoreListItems[app:name()] then
win:minimize()
end
end
hs.alert.show("All Windows Minimized", 1)
end
-- ==========================================
-- MENU BAR & HOTKEYS
-- ==========================================
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
for _, hk in pairs(selector.hotkeys) do hk:delete() end
selector.hotkeys = {}
-- Global Hotkeys
selector.hotkeys["save"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "S", function()
local _, name = hs.dialog.textPrompt("New Layout", "Enter name:", "", "Save", "Cancel")
if name and name ~= "" then
hs.json.write(captureCurrentLayout(), selector.storageDir .. name .. ".json", true, true)
selector.refreshMenu()
end
end)
selector.hotkeys["minimize"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "M", minimizeAll)
local menuTable = {
{ title = "📸 Save New Layout...", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "📉 Minimize All Windows", fn = minimizeAll },
{ title = "-" }
}
local files = {}
local iter, dir_obj = hs.fs.dir(selector.storageDir)
if iter then for f in iter, dir_obj do if f:find("%.json$") then table.insert(files, f) end end end
table.sort(files)
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
for i, file in ipairs(files) do
local path, label = selector.storageDir .. file, file:gsub("%.json$", "")
local data = hs.json.read(path)
if i <= 9 then selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end) end
table.insert(menuTable, {
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
fn = function() executeRestore(path, label) end
})
if data and data.saveTime then table.insert(menuTable, { title = " 📅 Saved: " .. data.saveTime, disabled = true }) end
if data and data.windows then
local seen, appNames = {}, {}
for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(appNames, w.appName .. ","); seen[w.appName] = true end end
for _, line in ipairs(wrapText(table.concat(appNames, " "), 50)) do table.insert(menuTable, { title = line:gsub(",$", ""), disabled = true }) end
end
table.insert(menuTable, {
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(menuTable, { title = " 🧹 Restore & Cleanup", fn = function() executeRestore(path, label) end })
table.insert(menuTable, { title = " 📝 Rename", fn = function()
local _, n = hs.dialog.textPrompt("Rename", "New name:", label, "Rename", "Cancel")
if n and n ~= "" then os.rename(path, selector.storageDir .. n .. ".json"); selector.refreshMenu() end
end })
table.insert(menuTable, { title = " 🗑️ Delete", fn = function()
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
os.remove(path); selector.refreshMenu()
end
end })
table.insert(menuTable, { title = "-" })
end
selector.barItem:setMenu(menuTable)
end
selector.barItem:setTitle("🪟")
selector.refreshMenu()
hs.alert.show("Layout Selector Loaded", 1.5)
return selector
+229
View File
@@ -0,0 +1,229 @@
-- SETTINGS: Customize your engines and display names here
local engines = {
-- primary = { name = "Google", url = "https://google.ca/search?q=" },
primary = { name = "Google/StartPage", url = "https://www.startpage.com/do/search?query=" },
secondary = { name = "Wikipedia", url = "https://wikipedia.org/wiki/Special:Search?search="},
tertiary = { name = "YouTube", url = "https://youtube.com/results?search_query=" },
chatgpt = { name = "ChatGPT", url = "https://chatgpt.com/?q=" },
maps = { name = "Maps", url = "https://www.google.com/maps/search/" },
spotify = { name = "Spotify", url = "https://open.spotify.com/search/" }
}
local closeTimeout = 1.0
local gracePeriod = 3.0
-- Global variables
searchView = nil
searchTimer = nil
escWatcher = nil
clickOutsideWatcher = nil
local timeSinceLeft = 0
local hasEnteredWindow = false
local autoKillTime = 0
------------------------------------------------------------
-- CLOSE SEARCH
------------------------------------------------------------
local function closeSearch()
if searchView then searchView:delete() searchView = nil end
if searchTimer then searchTimer:stop() searchTimer = nil end
if escWatcher then escWatcher:stop() escWatcher = nil end
if clickOutsideWatcher then clickOutsideWatcher:stop() clickOutsideWatcher = nil end
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
end
------------------------------------------------------------
-- PERFORM SEARCH
------------------------------------------------------------
local function performSearch(engine)
hs.alert.show("Searching " .. engine.name .. "...", 1)
hs.eventtap.keyStroke({"cmd"}, "c")
hs.timer.doAfter(0.2, function()
local query = hs.pasteboard.getContents()
if not query or query == "" then return end
closeSearch()
-- RESET TRACKERS FOR NEW SEARCH
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
local url = engine.url .. hs.http.encodeForQuery(query)
local screen = hs.mouse.getCurrentScreen()
local screenFrame = screen:frame()
local width, height = 500, 600
local x = screenFrame.x + (screenFrame.w - width) / 2
local y = screenFrame.y + (screenFrame.h - height) / 2
searchView = hs.webview.new({
x = x,
y = y,
w = width,
h = height
})
searchView:windowStyle({"titled", "utility", "closable", "resizable"})
searchView:level(hs.drawing.windowLevels.floating)
searchView:userAgent(
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)"
)
searchView:url(url)
searchView:show()
searchView:bringToFront()
--------------------------------------------------------
-- ESC CLOSE
--------------------------------------------------------
escWatcher = hs.eventtap.new(
{hs.eventtap.event.types.keyDown},
function(event)
if event:getKeyCode() == 53 then
closeSearch()
return true
end
return false
end
):start()
--------------------------------------------------------
-- CLICK OUTSIDE CLOSE
--------------------------------------------------------
clickOutsideWatcher = hs.eventtap.new(
{hs.eventtap.event.types.leftMouseDown},
function()
if not searchView then return false end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if isOutside then closeSearch() end
return false
end
):start()
--------------------------------------------------------
-- AUTO CLOSE TIMER
--------------------------------------------------------
searchTimer = hs.timer.doEvery(0.1, function()
if not searchView then return end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if not isOutside then
hasEnteredWindow = true
timeSinceLeft = 0
else
if hasEnteredWindow then
timeSinceLeft = timeSinceLeft + 0.1
if timeSinceLeft >= closeTimeout then
closeSearch()
end
else
autoKillTime = autoKillTime + 0.1
if autoKillTime >= gracePeriod then
closeSearch()
end
end
end
end)
end)
end
------------------------------------------------------------
-- RIGHT CLICK TRIGGERS
------------------------------------------------------------
if clickWatcher then clickWatcher:stop() end
clickWatcher = hs.eventtap.new(
{hs.eventtap.event.types.rightMouseDown},
function(event)
local flags = event:getFlags()
if flags.cmd and flags.shift then
performSearch(engines.maps)
return true
elseif flags.alt and flags.shift then
performSearch(engines.spotify)
return true
elseif flags.cmd then
performSearch(engines.primary)
return true
elseif flags.alt then
performSearch(engines.secondary)
return true
elseif flags.ctrl then
performSearch(engines.tertiary)
return true
elseif flags.shift then
performSearch(engines.chatgpt)
return true
end
return false
end
):start()
------------------------------------------------------------
-- CMD + ENTER → OPEN CURRENT PAGE
------------------------------------------------------------
hs.hotkey.bind({"cmd"}, "return", function()
if searchView then
local currentPage = searchView:url()
if currentPage then
hs.urlevent.openURL(currentPage)
else
hs.alert.show("No page loaded")
end
else
hs.alert.show("No search window open")
end
end)
+16
View File
@@ -0,0 +1,16 @@
-- System_Tweaks.lua
-- Disable Time Machine throttling silently
local function disableTMThrottling()
-- Use hs.execute to run the command directly via sudo
-- Since we added the NOPASSWD rule, this will be silent and instant
local output, status, type, rc = hs.execute("sudo /usr/sbin/sysctl debug.lowpri_throttle_enabled=0", true)
if status then
hs.notify.new({title="Hammerspoon", informativeText="Time Machine unthrottled (Silent)."}):send()
else
print("Sudo Error: " .. output)
end
end
-- Run on Hammerspoon load/reload
disableTMThrottling()
@@ -0,0 +1,263 @@
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
selector.hotkeys = {}
if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end
local stubbornAppsList = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true,
["stats"] = true,
["eu.exelban.Stats"] = true,
["com.ethanbills.DockDoor"] = true,
["DockDoor"] = true
}
-- ==========================================
-- INTERNAL LOGIC
-- ==========================================
local function wrapText(text, limit)
local lines = {}
local currentLine = ""
for word in text:gmatch("%S+") do
local cleanWord = word:gsub(",$", "")
if #currentLine + #word >= limit then
table.insert(lines, currentLine .. (currentLine ~= "" and "," or ""))
currentLine = " " .. word
else
currentLine = currentLine == "" and "" .. word or currentLine .. " " .. word
end
end
table.insert(lines, currentLine)
return lines
end
local function captureCurrentLayout()
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
if app and screen and win:isVisible() and frame.w > 0 and frame.h > 0 then
local appName = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName,
bundleID = bid,
winTitle = win:title(),
winID = win:id(),
screenName = screen:name(),
x = math.floor(frame.x),
y = math.floor(frame.y),
w = math.floor(frame.w),
h = math.floor(frame.h)
})
end
end
end
return layout
end
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data then return end
local windowList = data.windows or data
local launchedAny = false
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
if winData.bundleID and winData.bundleID ~= "" then
hs.application.launchOrFocusByBundleID(winData.bundleID)
else
hs.application.launchOrFocus(winData.appName)
end
launchedAny = true
end
end
local function moveWindows()
for _, winData in ipairs(windowList) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if app then
local pid = app:pid()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
for _, win in ipairs(app:allWindows()) do
if win:isMinimized() then win:unminimize() end
local isExactMatch = (win:id() == winData.winID)
local isTitleMatch = (win:title() == winData.winTitle)
local isStubborn = stubbornAppsList[app:name()] or isElectron
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
if shouldMove then
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
-- FIX: Target window by title to prevent stacking
local safeTitle = win:title():gsub('"', '\\"')
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to (first window whose name is "%s")
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], pid, safeTitle, x, y, w, h)
hs.applescript.applescript(script)
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Launching apps...", 3.5)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
local function minimizeAll()
local allWindows = hs.window.filter.new():getWindows()
for _, win in ipairs(allWindows) do
local app = win:application()
if app and not ignoreListItems[app:name()] then
win:minimize()
end
end
hs.alert.show("All Windows Minimized", 1)
end
-- ==========================================
-- MENU BAR & HOTKEYS
-- ==========================================
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
for _, hk in pairs(selector.hotkeys) do hk:delete() end
selector.hotkeys = {}
local screens = hs.screen.allScreens()
local isDocked = #screens > 1
selector.barItem:setTitle(isDocked and "🖥️" or "💻")
selector.hotkeys["save"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "S", function()
local _, name = hs.dialog.textPrompt("New Layout", "Enter name:", "", "Save", "Cancel")
if name and name ~= "" then
hs.json.write(captureCurrentLayout(), selector.storageDir .. name .. ".json", true, true)
selector.refreshMenu()
end
end)
selector.hotkeys["minimize"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "M", minimizeAll)
local menuTable = {
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "📉 Minimize All Windows (⇧⌃⌥⌘M)", fn = minimizeAll },
{ title = "-" }
}
local files = {}
local iter, dir_obj = hs.fs.dir(selector.storageDir)
if iter then for f in iter, dir_obj do if f:find("%.json$") then table.insert(files, f) end end end
table.sort(files)
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
for i, file in ipairs(files) do
local path, label = selector.storageDir .. file, file:gsub("%.json$", "")
local data = hs.json.read(path)
if i <= 9 then selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end) end
table.insert(menuTable, {
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
fn = function() executeRestore(path, label) end
})
if data and data.mode then
local contextStr = (data.mode == "Docked")
and string.format(" 🖥️ Docked (%d Screens)", data.screenCount or 0)
or " 💻 Laptop Mode"
table.insert(menuTable, { title = contextStr, disabled = true })
end
if data and data.saveTime then table.insert(menuTable, { title = " 📅 Saved: " .. data.saveTime, disabled = true }) end
if data and data.windows then
local seen, appNames = {}, {}
for _, w in ipairs(data.windows) do if not seen[w.appName] then table.insert(appNames, w.appName .. ","); seen[w.appName] = true end end
for _, line in ipairs(wrapText(table.concat(appNames, " "), 50)) do table.insert(menuTable, { title = line:gsub(",$", ""), disabled = true }) end
end
table.insert(menuTable, {
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(menuTable, { title = " 🧹 Restore & Cleanup", fn = function() executeRestore(path, label) end })
table.insert(menuTable, { title = " 📝 Rename", fn = function()
local _, n = hs.dialog.textPrompt("Rename", "New name:", label, "Rename", "Cancel")
if n and n ~= "" then os.rename(path, selector.storageDir .. n .. ".json"); selector.refreshMenu() end
end })
table.insert(menuTable, { title = " 🗑️ Delete", fn = function()
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
os.remove(path); selector.refreshMenu()
end
end })
table.insert(menuTable, { title = "-" })
end
selector.barItem:setMenu(menuTable)
end
selector.screenWatcher = hs.screen.watcher.new(function()
selector.refreshMenu()
end):start()
selector.refreshMenu()
hs.alert.show("Layout Selector Loaded", 1.5)
return selector
@@ -0,0 +1,309 @@
local obj = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
obj.isRescued = false
obj.isTransitioning = false
obj.lastScreenCount = #hs.screen.allScreens()
local stubbornAppsList = {
["Gemini"] = true,
["AFFiNE"] = true,
["Terminal"] = true,
["System Settings"] = true,
["Hammerspoon"] = true
}
local ignoreListItems = {
["TheBoringNotch"] = true,
["theboringteam.boringnotch"] = true,
["Control Center"] = true,
["Notification Center"] = true,
["Dock"] = true,
["com.surteesstudios.Bartender"] = true,
["pro.betterdisplay.BetterDisplay"] = true,
["stats"] = true,
["eu.exelban.Stats"] = true,
["com.ethanbills.DockDoor"] = true,
["DockDoor"] = true
}
-- ==========================================
-- POWER & SLEEP WATCHER
-- ==========================================
obj.powerWatcher = hs.caffeinate.watcher.new(function(event)
if event == hs.caffeinate.watcher.systemWillSleep or
event == hs.caffeinate.watcher.systemWillPowerOff or
event == hs.caffeinate.watcher.screensDidSleep then
-- Lock immediately to prevent saving the "scrambled" sleep layout
obj.isTransitioning = true
elseif event == hs.caffeinate.watcher.systemDidWake or
event == hs.caffeinate.watcher.screensDidWake then
-- Wait for monitors to handshake, then auto-restore
hs.timer.doAfter(5, function()
obj.isTransitioning = false
obj.restoreLayout()
end)
end
end):start()
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
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)
if obj.isTransitioning or hs.caffeinate.get("displayIdle") or hs.caffeinate.get("systemIdle") then return end
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
local allWindows = hs.window.allWindows()
local consoleWin = hs.console.hswindow()
if consoleWin then table.insert(allWindows, consoleWin) end
for _, win in ipairs(allWindows) do
local app = win:application()
local screen = win:screen()
local frame = win:frame()
if app and screen and (win:isVisible() or win:title() == "Hammerspoon Console") and frame.w > 0 and frame.h > 0 then
local appName = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[appName] or ignoreListItems[bid] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName,
bundleID = bid,
winTitle = win:title(),
winID = win:id(),
x = math.floor(frame.x),
y = math.floor(frame.y),
w = math.floor(frame.w),
h = math.floor(frame.h)
})
end
end
end
hs.json.write(layout, obj.layoutFile, true, true)
obj.lastSavedTime = layout.saveTime
if not silent then hs.alert.show("Layout Saved", 1.5) end
end
function obj.restoreLayout()
local data = hs.json.read(obj.layoutFile)
if not data then return end
obj.isRescued = false
local windowList = data.windows or data
for _, winData in ipairs(windowList) do
if winData.winTitle == "Hammerspoon Console" then
local cWin = hs.console.hswindow()
if cWin 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
local pid = app:pid()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
for _, win in ipairs(app:allWindows()) do
if win:isMinimized() then win:unminimize() end
local isExactMatch = (win:id() == winData.winID)
local isTitleMatch = (win:title() == winData.winTitle)
local isStubborn = stubbornAppsList[app:name()] or isElectron
local isFuzzy = (winData.bundleID == "com.apple.Terminal" or winData.appName == "Gemini")
local shouldMove = (isExactMatch or (not winData.winID and isTitleMatch) or (#app:allWindows() == 1 and (isFuzzy or isStubborn)))
if shouldMove then
local x, y, w, h = math.floor(winData.x), math.floor(winData.y), math.floor(winData.w), math.floor(winData.h)
if isStubborn then
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (app:name() == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to %s
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], pid, winTarget, x, y, w, h)
hs.applescript.applescript(script)
hs.timer.doAfter(0.5, function() hs.applescript.applescript(script) end)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
end
end
end
end
hs.alert.show("Layout Restored", 1.5)
end
-- ==========================================
-- RESCUE LOGIC
-- ==========================================
function obj.rescueWindowsToLaptop()
local screens = hs.screen.allScreens()
if #screens == 1 and not obj.isRescued then
local primary = hs.screen.primaryScreen()
local primaryFrame = primary:frame()
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
if app then
local name = app:name() or ""
local bid = app:bundleID() or ""
if not (ignoreListItems[name] or ignoreListItems[bid] or name:find("TheBoringNotch")) then
win:unminimize()
local appPath = app:path() or ""
local isElectron = appPath:find("Electron") or appPath:find("Frameworks")
if stubbornAppsList[name] or isElectron then
local pid = app:pid()
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (name == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set position of %s to {%d, %d}
end try
end tell
]], pid, winTarget, math.floor(primaryFrame.x + 20), math.floor(primaryFrame.y + 40))
hs.applescript.applescript(script)
else
win:moveToScreen(primary, false, true)
end
end
end
end
obj.isRescued = true
hs.alert.show("Forced to Laptop Screen", 1.5)
end
end
-- ==========================================
-- MENUBAR UI
-- ==========================================
local saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local minutes = math.floor(saveCountdown / 60)
local seconds = saveCountdown % 60
timerMenu:setTitle(string.format("💠 %d:%02d", minutes, seconds))
local screens = hs.screen.allScreens()
local currentCount = #screens
local modeIcon = (currentCount > 1) and "🖥️" or "💻"
local modeName = (currentCount > 1) and "Docked" or "Laptop"
local data = hs.json.read(obj.layoutFile)
local lastTime = data and data.saveTime or "Never"
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
local menuTable = {
{ title = "📅 Last Saved: " .. lastTime, disabled = true },
{ title = "-" },
{ title = modeIcon .. " Status: " .. modeName .. " Mode", disabled = true },
{ title = " ↳ 📺 Screens Detected: " .. currentCount, disabled = true },
{ title = "-" },
{ title = hs.styledtext.new("📸 Save Layout (⇧⌘W)", boldStyle), fn = function() obj.saveLayout(false); obj.resetCountdown() end },
{ title = hs.styledtext.new("🔄 Restore Layout (⇧⌘R)", boldStyle), fn = function() obj.restoreLayout() end },
{ title = hs.styledtext.new("🚀 Force Rescue: All Windows to Laptop (⇧⌘⌃L)", boldStyle), fn = function() obj.isRescued = false; obj.rescueWindowsToLaptop() end },
{ title = "-" },
{ title = "📦 Windows in Saved File:", disabled = true }
}
if data and data.windows then
local seen, appNames = {}, {}
for _, w in ipairs(data.windows) do
if not seen[w.appName] then
table.insert(appNames, w.appName .. ", ")
seen[w.appName] = true
end
end
local appString = table.concat(appNames):gsub(", $", "")
for _, line in ipairs(wrapText(appString, 45)) do
table.insert(menuTable, { title = line, disabled = true })
end
end
timerMenu:setMenu(menuTable)
end
end
function obj.resetCountdown()
saveCountdown = obj.saveInterval
updateMenu()
end
-- ==========================================
-- WATCHERS & HOTKEYS
-- ==========================================
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); obj.resetCountdown() end)
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.restoreLayout() end)
hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", function() obj.isRescued = false; obj.rescueWindowsToLaptop() end)
obj.screenWatcher = hs.screen.watcher.new(function()
local currentCount = #hs.screen.allScreens()
if currentCount ~= obj.lastScreenCount then
obj.isTransitioning = true
if currentCount > 1 then
obj.isRescued = false
hs.timer.doAfter(4, function() obj.restoreLayout(); obj.isTransitioning = false end)
else
hs.timer.doAfter(2, function() obj.rescueWindowsToLaptop(); obj.isTransitioning = false end)
end
obj.lastScreenCount = currentCount
updateMenu()
end
end):start()
obj.autoSaveTimer = hs.timer.doEvery(obj.saveInterval, function() obj.saveLayout(true); obj.resetCountdown() end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then obj.resetCountdown() end
updateMenu()
end)
updateMenu()
return obj
+157
View File
@@ -0,0 +1,157 @@
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
+78
View File
@@ -0,0 +1,78 @@
-- ~/.hammerspoon/init.lua
require('SearchWindows')
require('Caffeine')
require('AppBorders')
-- require('System_Tweaks')
-- Load the window management module
windowMgr = require("WindowManager")
-- Load Spoon Files
hs.loadSpoon('SpoonInstall')
hs.loadSpoon('SpeedMenu')
hs.loadSpoon('BrewInfo')
-- hs.loadSpoon('Seal')
--- 3. Run/Configure Spoons
---- SpeedMenu Config
if spoon.SpeedMenu then
-- 1. Define the Fix Function (includes MAC and IPv6)
local function applyFullMenuFix()
local interface = spoon.SpeedMenu.interface or "en0"
local ssid = hs.wifi.currentNetwork() or "Disconnected"
local details = hs.network.interfaceDetails(interface)
local ipv4 = (details and details.IPv4) and details.IPv4.Addresses[1] or "N/A"
local ipv6 = (details and details.IPv6) and details.IPv6.Addresses[1] or "N/A"
-- Get MAC Address via shell
local macaddr = hs.execute('ifconfig ' .. interface .. ' | grep ether | awk \'{print $2}\''):gsub("%s+", "")
local menuitems = {
{ title = "SSID: " .. ssid, fn = function() hs.pasteboard.setContents(ssid) end },
{ title = "IPv4: " .. ipv4, fn = function() hs.pasteboard.setContents(ipv4) end },
{ title = "IPv6: " .. ipv6, fn = function() hs.pasteboard.setContents(ipv6) end },
{ title = "MAC: " .. macaddr, fn = function() hs.pasteboard.setContents(macaddr) end },
{ title = "-" },
{ title = "Rescan Network Interfaces", fn = function() spoon.SpeedMenu:rescan() end }
}
spoon.SpeedMenu.menubar:setMenu(menuitems)
end
-- 2. Hook the rescan method
local oldRescan = spoon.SpeedMenu.rescan
spoon.SpeedMenu.rescan = function(self)
oldRescan(self)
applyFullMenuFix()
end
-- 3. Toggle Logic (Starts as OFF)
local speedMenuRunning = false
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
if speedMenuRunning then
spoon.SpeedMenu:stop()
hs.alert.show("SpeedMenu Stopped")
else
spoon.SpeedMenu:start() -- This puts it in the menu bar
applyFullMenuFix() -- This populates the data
hs.alert.show("SpeedMenu Started")
end
speedMenuRunning = not speedMenuRunning
end)
end
---- SpeedMenu Config END
---- BrewInfo
if spoon.BrewInfo then
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "I", function()
spoon.BrewInfo:showBrewInfo()
end)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "J", function()
spoon.BrewInfo:showBrewInfoCurSel()
end)
end
---- BrewInfo END
hs.alert.show("Hammerspoon Config Reloaded")
+109
View File
@@ -0,0 +1,109 @@
-- 1. Identify it uniquely (Fresh ID forces macOS to update the hover title)
GeminiMonitorItem = hs.menubar.new(true, "GeminiMonitorV3")
-- 2. Set the hover text (Tooltip)
if GeminiMonitorItem then
GeminiMonitorItem:setTooltip("Gemini Usage Monitor")
end
local JSON_KEY_PATH = os.getenv("HOME") .. "/.hammerspoon/gcp-key.json"
local PROJECT_ID = "gen-lang-client-0842463959"
local UPDATE_INTERVAL = 300
-- 3. Setup the Icon
-- "NSActionTemplate" is the standard 'Gear' icon
local geminiStar = hs.image.imageFromName("NSActionTemplate")
if geminiStar then
-- 'template' allows the icon to switch between black and white for Dark Mode
geminiStar:template(true)
GeminiMonitorItem:setIcon(geminiStar)
end
-- 2026 Flash Preview Pricing
local PRICING_ESTIMATES = {
["Gemini 3 Flash Preview"] = 0.0010,
["Gemini 3.1 Pro"] = 0.0450,
["Gemini 2.0 Ultra"] = 0.1500
}
local selectedModel = "Gemini 3 Flash Preview"
local stats = {
requests = 0,
lastUpdate = "Never"
}
local authToken = nil
-- POPUP ON LOAD
hs.alert.show("GeminiMonitor Loaded", 2)
local function getAccessToken(callback)
local keyFile = io.open(JSON_KEY_PATH, "r")
if not keyFile then return end
local keyData = hs.json.decode(keyFile:read("*all"))
keyFile:close()
local now = os.time()
local header = hs.base64.encode(hs.json.encode({alg="RS256", typ="JWT"}))
local claim = hs.base64.encode(hs.json.encode({iss=keyData.client_email, scope="https://www.googleapis.com/auth/monitoring.read", aud="https://oauth2.googleapis.com/token", exp=now+3600, iat=now}))
local sign_cmd = string.format("echo -n '%s' | openssl dgst -sha256 -sign <(echo '%s') -binary | openssl base64", header.."."..claim, keyData.private_key)
hs.task.new("/bin/bash", function(c, out, e)
local sig = out:gsub("%s+", ""):gsub("/", "_"):gsub("+", "-"):gsub("=", "")
hs.http.asyncPost("https://oauth2.googleapis.com/token", "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion="..header.."."..claim.."."..sig, {["Content-Type"]="application/x-www-form-urlencoded"}, function(s, b)
local res = hs.json.decode(b)
if res and res.access_token then callback(res.access_token) end
end)
end, {"-c", sign_cmd}):start()
end
local function updateUsage()
if not authToken then
getAccessToken(function(token) authToken = token; updateUsage() end)
return
end
local endTime = os.date("!%Y-%m-%dT%H:%M:%SZ")
local startTime = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() - 86400)
local filter = 'resource.type="consumed_api" AND metric.type="serviceruntime.googleapis.com/api/request_count" AND resource.labels.service="generativelanguage.googleapis.com"'
local url = string.format("https://monitoring.googleapis.com/v3/projects/%s/timeSeries?filter=%s&interval.startTime=%s&interval.endTime=%s",
PROJECT_ID, hs.http.encodeForQuery(filter), startTime, endTime)
hs.http.asyncGet(url, {Authorization = "Bearer "..authToken}, function(status, body)
if status ~= 200 then return end
local data = hs.json.decode(body)
local r = 0
if data.timeSeries then
for _, s in ipairs(data.timeSeries) do
for _, p in ipairs(s.points) do r = r + (tonumber(p.value.int64Value) or 0) end
end
end
stats.requests = r
stats.lastUpdate = os.date("%H:%M")
local estCost = r * PRICING_ESTIMATES[selectedModel]
GeminiMonitorItem:setTitle(string.format(" %d ($%0.3f)", r, estCost))
end)
end
GeminiMonitorItem:setMenu(function()
return {
{ title = "GEMINI FLASH MONITOR", disabled = true },
{ title = "Project: " .. PROJECT_ID, disabled = true },
{ title = "-" },
{ title = "24H Requests: " .. stats.requests },
{ title = string.format("Est. Cost: $%0.4f", stats.requests * PRICING_ESTIMATES[selectedModel]) },
{ title = "-" },
{ title = "ACTIVE MODEL:", disabled = true },
{ title = "Gemini 3 Flash Preview", checked = (selectedModel == "Gemini 3 Flash Preview"), fn = function() selectedModel = "Gemini 3 Flash Preview"; updateUsage() end },
{ title = "Gemini 3.1 Pro", checked = (selectedModel == "Gemini 3.1 Pro"), fn = function() selectedModel = "Gemini 3.1 Pro"; updateUsage() end },
{ title = "-" },
{ title = "Refresh Now", fn = updateUsage },
{ title = "Last Sync: " .. stats.lastUpdate, disabled = true }
}
end)
updateUsage()
if GeminiTimer then GeminiTimer:stop() end
GeminiTimer = hs.timer.doEvery(UPDATE_INTERVAL, updateUsage)
+32
View File
@@ -0,0 +1,32 @@
--- HotkeyMapper.lua
local obj = {}
obj.registry = {}
obj.chooser = hs.chooser.new(function(choice)
if not choice then return end
end)
-- This is the function you'll use to log keys
function obj:register(mod, key, description, source)
table.insert(self.registry, {
text = string.format("%s + %s: %s", hs.inspect(mod):gsub('"', ''), key:upper(), description),
subText = "Source: " .. (source or "Unknown")
})
end
function obj:show()
if #self.registry == 0 then
hs.alert.show("No hotkeys registered in Mapper")
return
end
table.sort(self.registry, function(a, b) return a.text < b.text end)
obj.chooser:choices(self.registry)
obj.chooser:show()
end
function obj:init(mod, key)
hs.hotkey.bind(mod, key, function() self:show() end)
return self
end
return obj
+16
View File
@@ -0,0 +1,16 @@
-- ~/.hammerspoon/HyperKey.lua
-- Define the Hyper Key global (Cmd + Alt + Ctrl + Shift)
-- Map Caps Lock to this combo using Karabiner-Elements
-- hyper = {"ralt"}
hyper = {"cmd", "alt", "ctrl", "shift"}
-- Global Shortcuts
hs.hotkey.bind(hyper, "R", function()
hs.reload()
end)
print("HyperKey Module: Set to CAPS Lock")
return hyper
+218
View File
@@ -0,0 +1,218 @@
local obj = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
obj.isRescued = false
obj.isTransitioning = false
obj.isRestoring = false
obj.wakeTimer = nil
obj.lastScreenCount = #hs.screen.allScreens()
obj.lastSavedTime = "Never"
local ignoreListItems = {
["TheBoringNotch"] = true, ["Control Center"] = true, ["Notification Center"] = true,
["Dock"] = true, ["BetterDisplay"] = true, ["Stats"] = true, ["DockDoor"] = true
}
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
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 (PURE NATIVE)
-- ==========================================
function obj.saveLayout(silent)
if obj.isTransitioning or obj.isRestoring or #hs.screen.allScreens() ~= obj.lastScreenCount then
return
end
local layout = {
saveTime = os.date("%H:%M:%S"),
screenCount = #hs.screen.allScreens(),
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
if app and win:isVisible() and win:frame().w > 0 then
local appName = app:name() or ""
if not ignoreListItems[appName] then
table.insert(layout.windows, {
appName = appName,
bundleID = app: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
hs.json.write(layout, obj.layoutFile, true, true)
obj.lastSavedTime = layout.saveTime
if not silent then hs.alert.show("Layout Saved", 1.5) end
end
function obj.restoreLayout()
if obj.isRestoring then return end
local data = hs.json.read(obj.layoutFile)
if not data or not data.windows then return end
obj.isRestoring = true
obj.isRescued = false
-- 1. Group saved windows by App Name
local savedByApp = {}
for _, winData in ipairs(data.windows) do
savedByApp[winData.appName] = savedByApp[winData.appName] or {}
table.insert(savedByApp[winData.appName], winData)
end
-- 2. Sort saved windows by position
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
print("WindowManager: Restore Starting (Native Engine)")
-- 3. Move windows using Pure Hammerspoon logic
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
-- Sort physical windows to match
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
if win:isMinimized() then win:unminimize() end
-- Move window to the exact saved frame
win:setFrame({
x = winData.x,
y = winData.y,
w = winData.w,
h = winData.h
}, 0) -- 0 duration for instant move
end
end
end
end
hs.alert.show("Layout Restored", 1.5)
hs.timer.doAfter(2, function()
obj.isRestoring = false
print("WindowManager: Restore Lock Cleared.")
end)
end
function obj.rescueWindowsToLaptop()
local primary = hs.screen.primaryScreen()
if not primary then return end
print("WindowManager: Rescue Triggered.")
for _, win in ipairs(hs.window.allWindows()) do
if win and win:isVisible() and win:frame().w > 0 then
win:setFrame(primary:fullFrame(), 0)
end
end
obj.isRescued = true
hs.alert.show("Rescued to Laptop", 1.5)
end
-- ==========================================
-- MENUBAR & WATCHERS
-- ==========================================
local saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local screens = hs.screen.allScreens()
timerMenu:setTitle(string.format("💠 %d:%02d", math.floor(saveCountdown / 60), saveCountdown % 60))
local menuTable = {
{ title = "📸 Save Layout (⇧⌘W)", fn = function() obj.saveLayout(false); saveCountdown = obj.saveInterval end },
{ title = "🔄 Restore Layout (⇧⌘R)", fn = function() obj.isRestoring = false; obj.restoreLayout() end },
{ title = "🚀 Rescue Windows (Bring to Laptop) (⇧⌘⌃L)", fn = obj.rescueWindowsToLaptop }
}
timerMenu:setMenu(menuTable)
end
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
print("WindowManager: SLEEP")
obj.isTransitioning = true
elseif event == hs.caffeinate.watcher.systemDidWake or event == hs.caffeinate.watcher.screensDidWake or event == hs.caffeinate.watcher.screensDidUnlock then
print("WindowManager: WAKE")
if obj.wakeTimer then obj.wakeTimer:stop() end
obj.wakeTimer = hs.timer.doAfter(10, function()
obj.isTransitioning = false
obj.isRestoring = false
obj.restoreLayout()
obj.wakeTimer = nil
end)
end
end):start()
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); saveCountdown = obj.saveInterval end)
hs.hotkey.bind({"shift", "cmd"}, "R", function() obj.isRestoring = false; 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 return end
print("WindowManager: DOCK")
obj.isTransitioning = true
hs.timer.doAfter(5, 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); saveCountdown = obj.saveInterval end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then saveCountdown = obj.saveInterval end
updateMenu()
end)
updateMenu()
return obj
+243
View File
@@ -0,0 +1,243 @@
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
selector.hotkeys = {}
if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end
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
}
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
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 RESTORE LOGIC (SORTED MATCHING)
-- ==========================================
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data or not data.windows then return end
local launchedAny = false
for _, winData in ipairs(data.windows) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
hs.application.launchOrFocusByBundleID(winData.bundleID)
launchedAny = true
end
end
local function moveWindows()
local savedByApp = {}
for _, winData in ipairs(data.windows) do
savedByApp[winData.appName] = savedByApp[winData.appName] or {}
table.insert(savedByApp[winData.appName], winData)
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
for appName, savedEntries in pairs(savedByApp) do
local app = hs.application.get(savedEntries[1].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
if win:isMinimized() then win:unminimize() end
local x, y, w, h = winData.x, winData.y, winData.w, winData.h
local isStubborn = stubbornAppsList[appName] or (app:path() or ""):find("Electron")
local function moveAction()
if isStubborn then
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (appName == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to %s
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], app:pid(), winTarget, x, y, w, h)
hs.applescript.applescript(script)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
moveAction()
hs.timer.doAfter(0.5, moveAction)
hs.timer.doAfter(1.5, moveAction)
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Syncing Apps...", 3)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
-- ==========================================
-- MENU BAR & UI
-- ==========================================
local function captureCurrentLayout()
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
if app and win:isVisible() and win:frame().w > 0 then
local appName = app:name() or ""
if not (ignoreListItems[appName] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName, bundleID = app: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
return layout
end
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
for _, hk in pairs(selector.hotkeys) do hk:delete() end
selector.hotkeys = {}
local screens = hs.screen.allScreens()
selector.barItem:setTitle(#screens > 1 and "🖥️" or "💻")
-- Define global save hotkey
selector.hotkeys["save"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "S", function()
local _, name = hs.dialog.textPrompt("New Layout", "Enter name:", "", "Save", "Cancel")
if name and name ~= "" then
hs.json.write(captureCurrentLayout(), selector.storageDir .. name .. ".json", true, true)
selector.refreshMenu()
end
end)
local menuTable = {
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "-" }
}
local files = {}
local iter, dir_obj = hs.fs.dir(selector.storageDir)
if iter then for f in iter, dir_obj do if f:find("%.json$") then table.insert(files, f) end end end
table.sort(files)
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
for i, file in ipairs(files) do
local path, label = selector.storageDir .. file, file:gsub("%.json$", "")
local data = hs.json.read(path)
if i <= 9 then
selector.hotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end)
end
-- Layout Header
table.insert(menuTable, {
title = hs.styledtext.new("Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
fn = function() executeRestore(path, label) end
})
if data then
-- Context Metadata
local modeStr = (data.mode == "Docked") and " 🖥️ Docked (".. (data.screenCount or "?") .." Screens)" or " 💻 Laptop Mode"
table.insert(menuTable, { title = modeStr, disabled = true })
table.insert(menuTable, { title = " 📅 Saved: " .. (data.saveTime or "Unknown"), disabled = true })
-- App List (Wrapped)
if 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
local appString = table.concat(names):gsub(", $", "")
for _, line in ipairs(wrapText(appString, 45)) do
table.insert(menuTable, { title = line, disabled = true })
end
end
end
-- Actions
table.insert(menuTable, {
title = hs.styledtext.new(" 🔄 Update " .. label, boldStyle),
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(menuTable, { title = " 🗑️ Delete", fn = function() if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then os.remove(path); selector.refreshMenu() end end })
table.insert(menuTable, { title = "-" })
end
selector.barItem:setMenu(menuTable)
end
selector.screenWatcher = hs.screen.watcher.new(selector.refreshMenu):start()
selector.refreshMenu()
return selector
+303
View File
@@ -0,0 +1,303 @@
-- LayoutSelector.lua
local selector = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION & CENTRAL CONFIG LOADING
-- ==========================================
selector.storageDir = os.getenv("HOME") .. "/.hammerspoon/layouts/"
local configFile = os.getenv("HOME") .. "/.hammerspoon/config.json"
selector.layoutHotkeys = {}
selector.staticHotkeys = {}
if not hs.fs.attributes(selector.storageDir) then hs.fs.mkdir(selector.storageDir) end
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
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
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
local function getLayoutFiles()
local files = {}
local iter, dir_obj = hs.fs.dir(selector.storageDir)
if iter then for f in iter, dir_obj do if f:find("%.json$") then table.insert(files, f) end end end
table.sort(files)
return files
end
-- ==========================================
-- CORE LOGIC
-- ==========================================
local function captureCurrentLayout()
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%Y-%m-%d %H:%M:%S"),
screenCount = #screens,
mode = (#screens > 1) and "Docked" or "Laptop",
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
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 ""
-- Hybrid check for Capture
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
return layout
end
local function executeRestore(filePath, layoutName)
local data = hs.json.read(filePath)
if not data or not data.windows then return end
local launchedAny = false
for _, winData in ipairs(data.windows) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if not app then
hs.application.launchOrFocusByBundleID(winData.bundleID)
launchedAny = true
end
end
local function moveWindows()
local savedByApp = {}
for _, winData in ipairs(data.windows) do
-- Hybrid check for Restore processing
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
for appName, savedEntries in pairs(savedByApp) do
local app = hs.application.get(savedEntries[1].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
if win:isMinimized() then win:unminimize() end
local x, y, w, h = winData.x, winData.y, winData.w, winData.h
local isStubborn = stubbornAppsList[appName] or (app:path() or ""):find("Electron")
local function moveAction()
if isStubborn then
local safeTitle = win:title():gsub('"', '\\"')
local winTarget = (appName == "Gemini") and "window 1" or string.format('first window whose name is "%s"', safeTitle)
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to %s
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], app:pid(), winTarget, x, y, w, h)
hs.applescript.applescript(script)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
moveAction()
hs.timer.doAfter(0.5, moveAction)
hs.timer.doAfter(1.5, moveAction)
end
end
end
end
hs.alert.show("Restored: " .. layoutName, 1.5)
end
if launchedAny then
hs.alert.show("Syncing Apps...", 3)
hs.timer.doAfter(4.5, moveWindows)
else
moveWindows()
end
end
-- ==========================================
-- HOTKEY & MENU MANAGEMENT
-- ==========================================
function selector.rebindLayoutKeys()
-- Only rebind when called explicitly (on file changes)
for _, hk in pairs(selector.layoutHotkeys) do hk:delete() end
selector.layoutHotkeys = {}
local files = getLayoutFiles()
for i, file in ipairs(files) do
if i > 9 then break end
local path = selector.storageDir .. file
local label = file:gsub("%.json$", "")
selector.layoutHotkeys[i] = hs.hotkey.bind({"ctrl", "alt", "cmd"}, tostring(i), function() executeRestore(path, label) end)
end
print("LayoutSelector: Dynamic Hotkeys Rebuilt")
end
selector.barItem = hs.menubar.new()
function selector.refreshMenu()
-- Guard against title-looping
local icon = #hs.screen.allScreens() > 1 and "🖥️" or "💻"
if selector.barItem:title() ~= icon then selector.barItem:setTitle(icon) end
local menuTable = {
{ title = "📸 Save New Layout... (⇧⌃⌥⌘S)", fn = function() hs.eventtap.keyStroke({"shift", "ctrl", "alt", "cmd"}, "S") end },
{ title = "-" }
}
local files = getLayoutFiles()
local boldStyle = { font = { name = ".AppleSystemUIFontBold", size = 13 } }
for i, file in ipairs(files) do
local path, label = selector.storageDir .. file, file:gsub("%.json$", "")
local data = hs.json.read(path)
local layoutSubmenu = {}
-- Logic to determine visual cue icon
local mainIcon = ""
if data and data.mode == "Docked" then mainIcon = "🖥️ " elseif data and data.mode == "Laptop" then mainIcon = "💻 " end
table.insert(layoutSubmenu, {
title = hs.styledtext.new("🚀 Restore Layout", boldStyle),
fn = function() executeRestore(path, label) end
})
table.insert(layoutSubmenu, { title = "-" })
if data then
local modeStr = (data.mode == "Docked") and "🖥️ Docked (".. (data.screenCount or "?") .." Screens)" or "💻 Laptop Mode"
table.insert(layoutSubmenu, { title = modeStr, disabled = true })
table.insert(layoutSubmenu, { title = "📅 Saved: " .. (data.saveTime or "Unknown"), disabled = true })
if 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
local appString = table.concat(names):gsub(", $", "")
for _, line in ipairs(wrapText(appString, 45)) do table.insert(layoutSubmenu, { title = line, disabled = true }) end
end
end
table.insert(layoutSubmenu, { title = "-" })
table.insert(layoutSubmenu, {
title = "🔄 Update " .. label,
fn = function()
hs.json.write(captureCurrentLayout(), path, true, true)
selector.refreshMenu()
hs.alert.show("Updated: " .. label)
end
})
table.insert(layoutSubmenu, { title = "✏️ Rename", fn = function()
local _, newName = hs.dialog.textPrompt("Rename Layout", "Enter new name for " .. label .. ":", label, "Rename", "Cancel")
if newName and newName ~= "" and newName ~= label then
local newPath = selector.storageDir .. newName .. ".json"
os.rename(path, newPath)
selector.rebindLayoutKeys()
selector.refreshMenu()
end
end })
table.insert(layoutSubmenu, { title = "🗑️ Delete", fn = function()
if hs.dialog.blockAlert("Delete", "Delete "..label.."?", "Delete", "Cancel", "critical") == "Delete" then
os.remove(path)
selector.rebindLayoutKeys()
selector.refreshMenu()
end
end })
table.insert(menuTable, {
title = hs.styledtext.new(mainIcon .. "Layout: " .. label .. (i<=9 and " (⌃⌥⌘"..i..")" or ""), boldStyle),
menu = layoutSubmenu
})
end
selector.barItem:setMenu(menuTable)
end
-- ==========================================
-- INITIALIZATION
-- ==========================================
-- Static Save Hotkey (Defined once)
selector.staticHotkeys["save"] = hs.hotkey.bind({"shift", "ctrl", "alt", "cmd"}, "S", function()
local _, name = hs.dialog.textPrompt("New Layout", "Enter name:", "", "Save", "Cancel")
if name and name ~= "" then
hs.json.write(captureCurrentLayout(), selector.storageDir .. name .. ".json", true, true)
selector.rebindLayoutKeys() -- Rebuild keys only when file added
selector.refreshMenu()
end
end)
-- Screen watcher only refreshes the VISUAL menu, not the keys
selector.screenWatcher = hs.screen.watcher.new(function()
hs.timer.doAfter(2, selector.refreshMenu)
end):start()
selector.rebindLayoutKeys() -- Run once at load
selector.refreshMenu() -- Run once at load
return selector
+326
View File
@@ -0,0 +1,326 @@
-- ~/.hammerspoon/NetworkCenter.lua
local NetworkCenter = {}
local Config = require("Config")
local menuBar = hs.menubar.new()
-- State Management
local nasOnline = false
local vpnActive = false
local ovpnIP = "Offline"
local tailscaleIP = "Offline"
local tailnetDomain = "Offline" -- NEW: Added state for domain
local speedTestResult = "Idle"
local lastExternalIP = "Offline"
local ispName = "Fetching..."
local wifiInfo = {
ssid = "N/A", speed = 0, gen = "N/A", band = "N/A",
channel = "N/A", protocol = "N/A", rssi = 0, noise = 0
}
local gatewayLatency = "---"
-- Service Monitoring State
local services = {
{ name = "Plex", host = "192.168.1.105", port = 32400 },
{ name = "Komga", host = "192.168.1.101", port = 25600 },
{ name = "Sonarr", host = "192.168.1.101", port = 8989 },
{ name = "Radarr", host = "192.168.1.101", port = 7878 },
{ name = "qBitorrent", host = "192.168.1.101", port = 8080 }
}
local serviceStatus = {}
-- ==========================================
-- 1. DATA RETRIEVAL
-- ==========================================
local function getWifiDetails()
local details = hs.wifi.interfaceDetails()
if details then
wifiInfo.ssid = details.ssid or "Disconnected"
wifiInfo.speed = details.transmitRate or 0
wifiInfo.rssi = details.rssi or 0
wifiInfo.noise = details.noise or 0
local phy = details.activePHYMode
local phyStr = tostring(phy)
if phy == 6 or phyStr:find("6") or phyStr:find("ax") then
wifiInfo.gen, wifiInfo.protocol = "6", "AX"
elseif phy == 5 or phyStr:find("5") or phyStr:find("ac") then
wifiInfo.gen, wifiInfo.protocol = "5", "AC"
elseif phy == 4 or phyStr:find("4") or phyStr:find("n") then
wifiInfo.gen, wifiInfo.protocol = "4", "N"
else
wifiInfo.gen, wifiInfo.protocol = "?", "?"
end
if details.wlanChannel and type(details.wlanChannel) == "table" then
wifiInfo.band = tostring(details.wlanChannel.band):gsub("GHz", "") or "N/A"
wifiInfo.channel = tostring(details.wlanChannel.number) or "N/A"
end
else
wifiInfo.ssid = hs.wifi.currentNetwork() or "N/A"
end
end
function NetworkCenter.updateStatus()
getWifiDetails()
-- Async NAS Ping
hs.task.new("/sbin/ping", function(code)
nasOnline = (code == 0)
NetworkCenter.refreshUI()
end, {"-c", "1", "-t", "1", Config.nasIP}):start()
-- Gateway Latency
hs.task.new("/sbin/ping", function(code, stdOut)
if code == 0 and stdOut then
local ms = stdOut:match("time=(%d+%.?%d*) ms")
gatewayLatency = ms and (math.floor(tonumber(ms)) .. "ms") or "Error"
else
gatewayLatency = "Timeout"
end
NetworkCenter.refreshUI()
end, {"-c", "1", "-t", "1", "192.168.1.1"}):start()
-- Service Port Checks
for _, svc in ipairs(services) do
hs.task.new("/usr/bin/nc", function(code)
serviceStatus[svc.name] = (code == 0 and "🟢" or "🔴")
NetworkCenter.refreshUI()
end, {"-z", "-w", "2", svc.host, tostring(svc.port)}):start()
end
-- Tailscale Detection & Domain Retrieval
local tsPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
local tsStatus = hs.execute(tsPath .. " status --json 2>/dev/null")
local foundTSIP = "Offline"
local foundTSDomain = "Offline"
if tsStatus and tsStatus ~= "" then
local tsData = hs.json.decode(tsStatus)
if tsData and tsData.BackendState == "Running" then
-- Get the Tailnet name/domain
foundTSDomain = tsData.MagicDNSSuffix or "N/A"
if tsData.Self and tsData.Self.TailscaleIPs then
for _, ip in ipairs(tsData.Self.TailscaleIPs) do
if ip:match("^100%.") then foundTSIP = ip break end
end
end
end
end
tailscaleIP = foundTSIP
tailnetDomain = foundTSDomain
-- OpenVPN Detection
local ovpnCheck = hs.execute("pgrep -x 'OpenVPN Connect'"):gsub("%s+", "")
local ovpnIPQuery = hs.execute("ifconfig | grep -A 1 'utun' | grep 'inet 10.' | awk '{print $2}'"):gsub("%s+", "")
if ovpnCheck ~= "" and ovpnIPQuery ~= "" then
vpnActive = true
ovpnIP = ovpnIPQuery
else
vpnActive = false
ovpnIP = "Offline"
end
-- External IP & ISP
hs.task.new("/usr/bin/curl", function(exitCode, stdOut)
if exitCode == 0 and stdOut then
local data = hs.json.decode(stdOut)
if data then
lastExternalIP = data.ip or "Offline"
ispName = data.asn_org or data.company_name or "Unknown ISP"
end
else
lastExternalIP = "Offline"
ispName = "Offline"
end
NetworkCenter.refreshUI()
end, {"-s", "-m", "5", "https://ifconfig.co/json"}):start()
end
local function runSpeedTest()
speedTestResult = "Testing..."
NetworkCenter.refreshUI()
hs.task.new("/usr/bin/networkQuality", function(exitCode, stdOut)
if exitCode == 0 and stdOut then
local data = hs.json.decode(stdOut)
if data then
local downMbps = math.floor((data.dl_throughput / 1048576) + 0.5)
local upMbps = math.floor((data.ul_throughput / 1048576) + 0.5)
speedTestResult = string.format("D:%d / U:%d Mbps", downMbps, upMbps)
else
speedTestResult = "Parse Error"
end
else
speedTestResult = "Failed"
end
NetworkCenter.refreshUI()
end, {"-c"}):start()
end
-- ==========================================
-- 2. ACTIONS & STEALTH LOGIC
-- ==========================================
local function fastHideOpenVPN()
local count = 0
local hideTimer
hideTimer = hs.timer.doEvery(0.02, function()
local app = hs.application.get("OpenVPN Connect")
if app then
app:hide()
hs.applescript.applescript('tell application "System Events" to set visible of process "OpenVPN Connect" to false')
local win = app:mainWindow()
if win then win:setFrame(hs.geometry.rect(5000, 5000, 0, 0), 0) end
end
count = count + 1
if count > 75 then hideTimer:stop() end
end)
end
local function watchForVPN()
local attempts = 0
local watchTimer
watchTimer = hs.timer.doEvery(1, function()
attempts = attempts + 1
local checkIP = hs.execute("ifconfig | grep -A 1 'utun' | grep 'inet 10.'"):gsub("%s+", "")
if checkIP ~= "" or attempts > 10 then
NetworkCenter.updateStatus()
watchTimer:stop()
end
end)
end
local function vpnAction(mode)
if mode == "connect" then
hs.alert.show("Connecting VPN...")
fastHideOpenVPN()
hs.execute(string.format('"%s" --connect-shortcut=%s', Config.openvpnPath, Config.vpnProfileID))
watchForVPN()
else
hs.alert.show("Disconnecting VPN...")
hs.execute(string.format('"%s" --disconnect-shortcut', Config.openvpnPath))
hs.timer.doAfter(0.5, function()
hs.execute("pkill -9 'OpenVPN Connect'")
NetworkCenter.updateStatus()
end)
end
end
local function tailscaleAction(mode)
local tsPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
if mode == "up" then
hs.alert.show("Starting Tailscale...")
hs.execute(tsPath .. " up")
else
hs.alert.show("Stopping Tailscale...")
hs.execute(tsPath .. " down")
end
hs.timer.doAfter(1, NetworkCenter.updateStatus)
hs.timer.doAfter(3, NetworkCenter.updateStatus)
end
-- ==========================================
-- 3. THE MENU UI
-- ==========================================
function NetworkCenter.refreshUI()
if not menuBar then return end
local isHome = (wifiInfo.ssid == Config.homeSSID)
local locationStr = isHome and "HOME" or "REMOTE"
local topIcon = isHome and "🏠" or "🌐"
local tsActive = (tailscaleIP ~= "Offline")
if vpnActive and tsActive then
topIcon = topIcon .. "🛡️"
elseif vpnActive then
topIcon = topIcon .. "🔒"
elseif tsActive then
topIcon = topIcon .. "📡"
end
menuBar:setTitle(topIcon .. " " .. math.floor(wifiInfo.speed) .. "Mb")
local shareSubMenu = {}
for i, share in ipairs(Config.shares) do
local isMounted = hs.fs.attributes("/Volumes/" .. share) ~= nil
local sIcon = isMounted and "" or "⚪️"
local titleText = sIcon .. " " .. share
if isMounted then
local capCmd = "df -h '/Volumes/" .. share .. "' | tail -1 | awk '{gsub(\"i\", \"b\", $2); print \"(\" $2 \")\"}'"
titleText = titleText .. " " .. hs.execute(capCmd):gsub("\n", "")
end
table.insert(shareSubMenu, {
title = titleText,
fn = function()
if isMounted then hs.execute("diskutil unmount /Volumes/" .. share)
else hs.applescript.applescript(string.format('mount volume "smb://%s/%s"', Config.nasIP, share)) end
hs.timer.doAfter(3, NetworkCenter.updateStatus)
end
})
if isMounted then
local barCmd = "df -h '/Volumes/" .. share .. "' | tail -1 | awk '{p=int($5); bar=\"\"; for(i=1;i<=10;i++){if(i<=p/10) bar=bar \"\"; else bar=bar \"\"} gsub(\"i\", \"b\", $3); print \" [\" bar \"] \" p \"% (\" $3 \" Used)\"}'"
table.insert(shareSubMenu, { title = hs.execute(barCmd):gsub("\n", ""), disabled = true })
table.insert(shareSubMenu, { title = " 📂 Browse Folder", fn = function() hs.execute("open /Volumes/" .. share) end })
table.insert(shareSubMenu, { title = "-" })
end
end
local labSubMenu = {}
local labIcons = ""
for i, svc in ipairs(services) do
local status = serviceStatus[svc.name] or "⚪️"
labIcons = labIcons .. status
table.insert(labSubMenu, {
title = status .. " " .. svc.name,
fn = function() hs.execute(string.format("open http://%s:%s", svc.host, svc.port)) end
})
end
local ovpnHeader = "🔐 OPENVPN CONTROLS" .. (vpnActive and " (🟢 ACTIVE)" or "")
local tsHeader = "🚀 TAILSCALE MESH" .. (tsActive and " (🟢 ACTIVE)" or "")
local menu = {
{ title = "📍 LOCATION STATUS" },
{ title = " ├─ Profile: " .. locationStr, disabled = true },
{ title = " ├─ Gateway: " .. gatewayLatency, disabled = true },
{ title = " ├─ Channel: " .. wifiInfo.channel .. " (" .. wifiInfo.band .. " GHz)", disabled = true },
{ title = " ├─ Signal: " .. wifiInfo.rssi .. " dBm / Noise: " .. wifiInfo.noise .. " dBm", disabled = true },
{ title = " └─ WiFi " .. wifiInfo.gen .. " (" .. wifiInfo.protocol .. ") @ " .. wifiInfo.speed .. " Mbps", disabled = true },
{ title = "-" },
{ title = "🚀 LAB SERVICES " .. labIcons, menu = labSubMenu },
{ title = "-" },
{ title = "🛡️ VPN & MESH NET" },
{ title = " ├─ OpenVPN: " .. ovpnIP, disabled = true },
{ title = " ├─ Tailscale IP: " .. tailscaleIP, disabled = true },
{ title = " ├─ Tailnet: " .. tailnetDomain, disabled = true }, -- NEW: Display Domain
{ title = " ├─ ISP: " .. ispName, disabled = true },
{ title = " └─ Public IP: " .. lastExternalIP, disabled = true },
{ title = "-" },
{ title = "📦 STORAGE & SHARES", menu = shareSubMenu },
{ title = " ├─ NAS IP: " .. Config.nasIP, disabled = true },
{ title = " └─ NAS Status: " .. (nasOnline and "🟢 Online" or "🔴 Offline"), disabled = true },
{ title = "-" },
{ title = "⚡️ SPEED TEST: " .. speedTestResult, fn = runSpeedTest },
{ title = "-" },
{ title = ovpnHeader },
{ title = " ├─ 🔒 Connect tunnel", fn = function() vpnAction("connect") end },
{ title = " └─ 🔓 Disconnect & Quit", fn = function() vpnAction("disconnect") end },
{ title = "-" },
{ title = tsHeader },
{ title = " ├─ 🟢 Start Service", fn = function() tailscaleAction("up") end },
{ title = " └─ 🔴 Stop Service", fn = function() tailscaleAction("down") end },
{ title = "-" },
{ title = "🔄 Refresh Status", fn = NetworkCenter.updateStatus }
}
menuBar:setMenu(menu)
end
NetworkCenter.updateStatus()
hs.timer.doEvery(30, NetworkCenter.updateStatus)
hs.alert.show("Network Center Platinum Loaded")
return NetworkCenter
+163
View File
@@ -0,0 +1,163 @@
-- ~/.hammerspoon/NetworkMenu.lua
NetworkMenu = {}
NetworkMenu.menuBarItem = hs.menubar.new()
-- Global state management
automationEnabled = (automationEnabled == nil) and true or automationEnabled
local nasOnline = false
local lastInternalIP = "Offline"
local lastExternalIP = "Offline"
local vpnActive = false
-- 1. ASYNC STATUS UPDATE (Prevents hangs and crashes)
function updateStatus()
if not config then
print("NetworkMenu Error: Config not found")
return
end
-- A. Async NAS Ping
hs.task.new("/sbin/ping", function(code)
nasOnline = (code == 0)
end, {"-c", "1", "-t", "1", config.nasIP}):start()
-- B. Internal VPN IP (Fast local command)
local internalCmd = "ifconfig | grep -A 1 'utun' | grep 'inet ' | awk '{print $2}' | head -n 1"
local internalIP = hs.execute(internalCmd):gsub("%s+", "")
lastInternalIP = (internalIP == "" and "Offline" or internalIP)
vpnActive = (internalIP ~= "")
-- C. Async External IP
hs.task.new("/usr/bin/curl", function(exitCode, stdOut)
if exitCode == 0 and stdOut then
lastExternalIP = stdOut:gsub("%s+", "")
else
lastExternalIP = "Offline"
end
end, {"-s", "-m", "2", "icanhazip.com"}):start()
-- D. Update Menu Icon
local currentSSID = hs.wifi.currentNetwork()
if NetworkMenu.menuBarItem then
local automationIcon = automationEnabled and "" or "🔘"
local icon = (currentSSID == config.homeSSID) and "🏠" or "🌐"
if vpnActive then icon = icon .. "🛡️" end
if nasOnline then icon = icon .. "🟢" end
NetworkMenu.menuBarItem:setTitle(automationIcon .. icon)
end
end
-- 2. Aggressive Backgrounding for OpenVPN
function NetworkMenu.deepHideOpenVPN()
local count = 0
local hideTimer
hideTimer = hs.timer.doEvery(0.1, function()
local app = hs.application.get("OpenVPN Connect")
if app then
app:hide()
hs.applescript.applescript('tell application "System Events" to set visible of process "OpenVPN Connect" to false')
end
count = count + 1
if count > 30 then hideTimer:stop() end
end)
end
-- 3. Check Share Status (Local Filesystem Check)
local function getShareStatus()
local statusLines = {}
if not config or not config.shares then return statusLines end
for _, share in ipairs(config.shares) do
local path = "/Volumes/" .. share
local isMounted = hs.fs.attributes(path) ~= nil
local icon = isMounted and "🟢" or "⚪️"
table.insert(statusLines, { title = " " .. icon .. " " .. share, disabled = true })
end
return statusLines
end
-- 4. THE MENU UI
function NetworkMenu.buildMenu()
local currentSSID = hs.wifi.currentNetwork() or "Disconnected"
local isHome = config and (currentSSID == config.homeSSID)
local menu = {
{ title = (automationEnabled and "🟢" or "🔴") .. " Automation: " .. (automationEnabled and "Enabled" or "Disabled"),
fn = function()
automationEnabled = not automationEnabled
updateStatus()
hs.alert.show("Network Automation: " .. (automationEnabled and "ON" or "OFF"))
end
},
{ title = "-" },
{ title = "📍 Profile: " .. (isHome and "Home" or "Remote"), disabled = true },
{ title = " " .. (isHome and "🏠" or "🌐") .. " Location: " .. currentSSID, disabled = true },
{ title = "-" },
{ title = (vpnActive and "🔒" or "🔓") .. " VPN: " .. (vpnActive and "Connected" or "Disconnected"), disabled = true },
{ title = " 🆔 Int IP: " .. lastInternalIP, disabled = true },
{ title = " 🌍 Ext IP: " .. lastExternalIP, disabled = true },
{ title = (nasOnline and "" or "") .. " NAS Status: " .. (nasOnline and "Online" or "Offline"), disabled = true },
{ title = " 📂 Mounted Shares:", disabled = true },
}
local shares = getShareStatus()
for _, s in ipairs(shares) do table.insert(menu, s) end
table.insert(menu, { title = "-" })
table.insert(menu, { title = "⚡️ VPN Controls:", disabled = true })
table.insert(menu, { title = " 🔒 Connect VPN", fn = function()
if config then
hs.execute(string.format('"%s" --connect-shortcut=%s', config.openvpnPath, config.vpnProfileID))
NetworkMenu.deepHideOpenVPN()
hs.timer.doAfter(6, updateStatus)
end
end })
table.insert(menu, { title = " 🔓 Disconnect & Quit VPN", fn = function()
if config then
hs.execute(string.format('"%s" --disconnect-shortcut', config.openvpnPath))
hs.timer.doAfter(1, function()
local app = hs.application.get("OpenVPN Connect")
if app then app:kill() end
updateStatus()
end)
end
end })
table.insert(menu, { title = "-" })
table.insert(menu, { title = "📂 NAS Actions:", disabled = true })
table.insert(menu, { title = " ⬆️ Mount All Shares", fn = function()
if config then
for _, share in ipairs(config.shares) do
hs.applescript.applescript(string.format('mount volume "smb://%s/%s"', config.nasIP, share))
end
end
end })
table.insert(menu, { title = " ⬇️ Unmount All Shares", fn = function()
if config then
for _, share in ipairs(config.shares) do
hs.execute(string.format("diskutil unmount /Volumes/%s", share))
end
end
end })
table.insert(menu, { title = "-" })
table.insert(menu, { title = "🔄 Refresh Status", fn = updateStatus })
return menu
end
-- 5. INITIALIZATION
if NetworkMenu.menuBarItem then
NetworkMenu.menuBarItem:setMenu(NetworkMenu.buildMenu)
-- Small delay before first status update to ensure config is ready
hs.timer.doAfter(1, updateStatus)
end
-- Watchers
NetworkMenu.watcher = hs.wifi.watcher.new(updateStatus):start()
NetworkMenu.timer = hs.timer.doEvery(30, updateStatus)
print("NetworkMenu Module Loaded Successfully")
return NetworkMenu
+229
View File
@@ -0,0 +1,229 @@
-- SETTINGS: Customize your engines and display names here
local engines = {
-- primary = { name = "Google", url = "https://google.ca/search?q=" },
primary = { name = "Google/StartPage", url = "https://www.startpage.com/do/search?query=" },
secondary = { name = "Wikipedia", url = "https://wikipedia.org/wiki/Special:Search?search="},
tertiary = { name = "YouTube", url = "https://youtube.com/results?search_query=" },
chatgpt = { name = "ChatGPT", url = "https://chatgpt.com/?q=" },
maps = { name = "Maps", url = "https://www.google.com/maps/search/" },
spotify = { name = "Spotify", url = "https://open.spotify.com/search/" }
}
local closeTimeout = 1.0
local gracePeriod = 3.0
-- Global variables
searchView = nil
searchTimer = nil
escWatcher = nil
clickOutsideWatcher = nil
local timeSinceLeft = 0
local hasEnteredWindow = false
local autoKillTime = 0
------------------------------------------------------------
-- CLOSE SEARCH
------------------------------------------------------------
local function closeSearch()
if searchView then searchView:delete() searchView = nil end
if searchTimer then searchTimer:stop() searchTimer = nil end
if escWatcher then escWatcher:stop() escWatcher = nil end
if clickOutsideWatcher then clickOutsideWatcher:stop() clickOutsideWatcher = nil end
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
end
------------------------------------------------------------
-- PERFORM SEARCH
------------------------------------------------------------
local function performSearch(engine)
hs.alert.show("Searching " .. engine.name .. "...", 1)
hs.eventtap.keyStroke({"cmd"}, "c")
hs.timer.doAfter(0.2, function()
local query = hs.pasteboard.getContents()
if not query or query == "" then return end
closeSearch()
-- RESET TRACKERS FOR NEW SEARCH
timeSinceLeft = 0
hasEnteredWindow = false
autoKillTime = 0
local url = engine.url .. hs.http.encodeForQuery(query)
local screen = hs.mouse.getCurrentScreen()
local screenFrame = screen:frame()
local width, height = 500, 600
local x = screenFrame.x + (screenFrame.w - width) / 2
local y = screenFrame.y + (screenFrame.h - height) / 2
searchView = hs.webview.new({
x = x,
y = y,
w = width,
h = height
})
searchView:windowStyle({"titled", "utility", "closable", "resizable"})
searchView:level(hs.drawing.windowLevels.floating)
searchView:userAgent(
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)"
)
searchView:url(url)
searchView:show()
searchView:bringToFront()
--------------------------------------------------------
-- ESC CLOSE
--------------------------------------------------------
escWatcher = hs.eventtap.new(
{hs.eventtap.event.types.keyDown},
function(event)
if event:getKeyCode() == 53 then
closeSearch()
return true
end
return false
end
):start()
--------------------------------------------------------
-- CLICK OUTSIDE CLOSE
--------------------------------------------------------
clickOutsideWatcher = hs.eventtap.new(
{hs.eventtap.event.types.leftMouseDown},
function()
if not searchView then return false end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if isOutside then closeSearch() end
return false
end
):start()
--------------------------------------------------------
-- AUTO CLOSE TIMER
--------------------------------------------------------
searchTimer = hs.timer.doEvery(0.1, function()
if not searchView then return end
local mousePos = hs.mouse.absolutePosition()
local winFrame = searchView:frame()
local isOutside =
mousePos.x < winFrame.x or
mousePos.x > (winFrame.x + winFrame.w) or
mousePos.y < winFrame.y or
mousePos.y > (winFrame.y + winFrame.h)
if not isOutside then
hasEnteredWindow = true
timeSinceLeft = 0
else
if hasEnteredWindow then
timeSinceLeft = timeSinceLeft + 0.1
if timeSinceLeft >= closeTimeout then
closeSearch()
end
else
autoKillTime = autoKillTime + 0.1
if autoKillTime >= gracePeriod then
closeSearch()
end
end
end
end)
end)
end
------------------------------------------------------------
-- RIGHT CLICK TRIGGERS
------------------------------------------------------------
if clickWatcher then clickWatcher:stop() end
clickWatcher = hs.eventtap.new(
{hs.eventtap.event.types.rightMouseDown},
function(event)
local flags = event:getFlags()
if flags.cmd and flags.shift then
performSearch(engines.maps)
return true
elseif flags.alt and flags.shift then
performSearch(engines.spotify)
return true
elseif flags.cmd then
performSearch(engines.primary)
return true
elseif flags.alt then
performSearch(engines.secondary)
return true
elseif flags.ctrl then
performSearch(engines.tertiary)
return true
elseif flags.shift then
performSearch(engines.chatgpt)
return true
end
return false
end
):start()
------------------------------------------------------------
-- CMD + ENTER → OPEN CURRENT PAGE
------------------------------------------------------------
hs.hotkey.bind({"cmd"}, "return", function()
if searchView then
local currentPage = searchView:url()
if currentPage then
hs.urlevent.openURL(currentPage)
else
hs.alert.show("No page loaded")
end
else
hs.alert.show("No search window open")
end
end)
BIN
View File
Binary file not shown.
+322
View File
@@ -0,0 +1,322 @@
[
{
"Command": [],
"Constant": [],
"Constructor": [],
"Deprecated": [],
"Field": [],
"Function": [],
"Method": [
{
"def": "BrewInfo:bindHotkeys(mapping)",
"desc": "Binds hotkeys for BrewInfo",
"doc": "Binds hotkeys for BrewInfo\n\nParameters:\n * mapping - A table containing hotkey modifier/key details for the following items:\n * show_brew_info - Show output of `brew info` using the selected text as package name\n * open_brew_url - Open the homepage of the formula whose name is currently selected\n * show_brew_cask_info - Show output of `brew cask info` using the selected text as package name\n * open_brew_cask_url - Open the homepage of the Cask whose name is currently selected",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "166",
"name": "bindHotkeys",
"notes": [],
"parameters": [
" * mapping - A table containing hotkey modifier/key details for the following items:\n * show_brew_info - Show output of `brew info` using the selected text as package name\n * open_brew_url - Open the homepage of the formula whose name is currently selected\n * show_brew_cask_info - Show output of `brew cask info` using the selected text as package name\n * open_brew_cask_url - Open the homepage of the Cask whose name is currently selected"
],
"returns": [],
"signature": "BrewInfo:bindHotkeys(mapping)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:openBrewURL(pkg, subcommand)",
"desc": "Opens the homepage for package `pkg`, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`",
"doc": "Opens the homepage for package `pkg`, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`\n\nParameters:\n * pkg - name of the package to query\n * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "126",
"name": "openBrewURL",
"notes": [],
"parameters": [
" * pkg - name of the package to query",
" * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:openBrewURL(pkg, subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:openBrewURLCurSel(subcommand)",
"desc": "Opens the homepage for the currently-selected package, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`",
"doc": "Opens the homepage for the currently-selected package, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`\n\nParameters:\n * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "153",
"name": "openBrewURLCurSel",
"notes": [],
"parameters": [
" * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:openBrewURLCurSel(subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:showBrewInfo(pkg, subcommand)",
"desc": "Displays an alert with the output of `brew <subcommand> info <pkg>`",
"doc": "Displays an alert with the output of `brew <subcommand> info <pkg>`\n\nParameters:\n * pkg - name of the package to query\n * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask info <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "89",
"name": "showBrewInfo",
"notes": [],
"parameters": [
" * pkg - name of the package to query",
" * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask info <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:showBrewInfo(pkg, subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:showBrewInfoCurSel(subcommand)",
"desc": "Display `brew <subcommand> info` using the selected text as the package name",
"doc": "Display `brew <subcommand> info` using the selected text as the package name\n\nParameters:\n * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info\" being run. For example, if `subcommand` is \"cask\", the `brew cask info` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "113",
"name": "showBrewInfoCurSel",
"notes": [],
"parameters": [
" * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info\" being run. For example, if `subcommand` is \"cask\", the `brew cask info` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:showBrewInfoCurSel(subcommand)",
"stripped_doc": "",
"type": "Method"
}
],
"Variable": [
{
"def": "BrewInfo.brew_info_delay_sec",
"desc": "An integer specifying how long the alerts generated by BrewInfo will stay onscreen",
"doc": "An integer specifying how long the alerts generated by BrewInfo will stay onscreen",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "30",
"name": "brew_info_delay_sec",
"signature": "BrewInfo.brew_info_delay_sec",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.brew_info_style",
"desc": "A table in conformance with the [hs.alert.defaultStyle](http://www.hammerspoon.org/docs/hs.alert.html#defaultStyle[]) format that specifies the style used by the alerts. Default value: `{ textFont = \"Courier New\", textSize = 14, radius = 10 }`",
"doc": "A table in conformance with the [hs.alert.defaultStyle](http://www.hammerspoon.org/docs/hs.alert.html#defaultStyle[]) format that specifies the style used by the alerts. Default value: `{ textFont = \"Courier New\", textSize = 14, radius = 10 }`",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "35",
"name": "brew_info_style",
"signature": "BrewInfo.brew_info_style",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.brew_path",
"desc": "A string specifying the path to the `brew` executable. Defaults to `/usr/local/bin/brew`",
"doc": "A string specifying the path to the `brew` executable. Defaults to `/usr/local/bin/brew`",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "25",
"name": "brew_path",
"signature": "BrewInfo.brew_path",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.select_text_if_needed",
"desc": "If `true`, and no text is currently selected in the terminal, issue a double-click to select the text below the cursor, and use that as the input to `brew info`. See also `BrewInfo.select_text_modifiers`. Defaults to `true`.",
"doc": "If `true`, and no text is currently selected in the terminal, issue a double-click to select the text below the cursor, and use that as the input to `brew info`. See also `BrewInfo.select_text_modifiers`. Defaults to `true`.",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "44",
"name": "select_text_if_needed",
"signature": "BrewInfo.select_text_if_needed",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.select_text_modifiers",
"desc": "Table containing the modifiers to be used together with a double-click when `BrewInfo.select_text_if_needed` is true. Defaults to `{cmd = true, shift = true}` to issue a Cmd-Shift-double-click, which will select a continuous non-space string in Terminal and iTerm2.",
"doc": "Table containing the modifiers to be used together with a double-click when `BrewInfo.select_text_if_needed` is true. Defaults to `{cmd = true, shift = true}` to issue a Cmd-Shift-double-click, which will select a continuous non-space string in Terminal and iTerm2.",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "49",
"name": "select_text_modifiers",
"signature": "BrewInfo.select_text_modifiers",
"stripped_doc": "",
"type": "Variable"
}
],
"desc": "Display pop-up with Homebrew Formula info, or open their URL",
"doc": "Display pop-up with Homebrew Formula info, or open their URL\n\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip)\n\nYou can bind keys to automatically display the output of `brew\ninfo` of the currently-selected package name, or to open its\nhomepage. I use it to quickly explore new packages from the output\nof `brew update`.",
"items": [
{
"def": "BrewInfo:bindHotkeys(mapping)",
"desc": "Binds hotkeys for BrewInfo",
"doc": "Binds hotkeys for BrewInfo\n\nParameters:\n * mapping - A table containing hotkey modifier/key details for the following items:\n * show_brew_info - Show output of `brew info` using the selected text as package name\n * open_brew_url - Open the homepage of the formula whose name is currently selected\n * show_brew_cask_info - Show output of `brew cask info` using the selected text as package name\n * open_brew_cask_url - Open the homepage of the Cask whose name is currently selected",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "166",
"name": "bindHotkeys",
"notes": [],
"parameters": [
" * mapping - A table containing hotkey modifier/key details for the following items:\n * show_brew_info - Show output of `brew info` using the selected text as package name\n * open_brew_url - Open the homepage of the formula whose name is currently selected\n * show_brew_cask_info - Show output of `brew cask info` using the selected text as package name\n * open_brew_cask_url - Open the homepage of the Cask whose name is currently selected"
],
"returns": [],
"signature": "BrewInfo:bindHotkeys(mapping)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo.brew_info_delay_sec",
"desc": "An integer specifying how long the alerts generated by BrewInfo will stay onscreen",
"doc": "An integer specifying how long the alerts generated by BrewInfo will stay onscreen",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "30",
"name": "brew_info_delay_sec",
"signature": "BrewInfo.brew_info_delay_sec",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.brew_info_style",
"desc": "A table in conformance with the [hs.alert.defaultStyle](http://www.hammerspoon.org/docs/hs.alert.html#defaultStyle[]) format that specifies the style used by the alerts. Default value: `{ textFont = \"Courier New\", textSize = 14, radius = 10 }`",
"doc": "A table in conformance with the [hs.alert.defaultStyle](http://www.hammerspoon.org/docs/hs.alert.html#defaultStyle[]) format that specifies the style used by the alerts. Default value: `{ textFont = \"Courier New\", textSize = 14, radius = 10 }`",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "35",
"name": "brew_info_style",
"signature": "BrewInfo.brew_info_style",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.brew_path",
"desc": "A string specifying the path to the `brew` executable. Defaults to `/usr/local/bin/brew`",
"doc": "A string specifying the path to the `brew` executable. Defaults to `/usr/local/bin/brew`",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "25",
"name": "brew_path",
"signature": "BrewInfo.brew_path",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo:openBrewURL(pkg, subcommand)",
"desc": "Opens the homepage for package `pkg`, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`",
"doc": "Opens the homepage for package `pkg`, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`\n\nParameters:\n * pkg - name of the package to query\n * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "126",
"name": "openBrewURL",
"notes": [],
"parameters": [
" * pkg - name of the package to query",
" * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:openBrewURL(pkg, subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:openBrewURLCurSel(subcommand)",
"desc": "Opens the homepage for the currently-selected package, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`",
"doc": "Opens the homepage for the currently-selected package, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`\n\nParameters:\n * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "153",
"name": "openBrewURLCurSel",
"notes": [],
"parameters": [
" * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in \"brew cat <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask cat <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:openBrewURLCurSel(subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo.select_text_if_needed",
"desc": "If `true`, and no text is currently selected in the terminal, issue a double-click to select the text below the cursor, and use that as the input to `brew info`. See also `BrewInfo.select_text_modifiers`. Defaults to `true`.",
"doc": "If `true`, and no text is currently selected in the terminal, issue a double-click to select the text below the cursor, and use that as the input to `brew info`. See also `BrewInfo.select_text_modifiers`. Defaults to `true`.",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "44",
"name": "select_text_if_needed",
"signature": "BrewInfo.select_text_if_needed",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo.select_text_modifiers",
"desc": "Table containing the modifiers to be used together with a double-click when `BrewInfo.select_text_if_needed` is true. Defaults to `{cmd = true, shift = true}` to issue a Cmd-Shift-double-click, which will select a continuous non-space string in Terminal and iTerm2.",
"doc": "Table containing the modifiers to be used together with a double-click when `BrewInfo.select_text_if_needed` is true. Defaults to `{cmd = true, shift = true}` to issue a Cmd-Shift-double-click, which will select a continuous non-space string in Terminal and iTerm2.",
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "49",
"name": "select_text_modifiers",
"signature": "BrewInfo.select_text_modifiers",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "BrewInfo:showBrewInfo(pkg, subcommand)",
"desc": "Displays an alert with the output of `brew <subcommand> info <pkg>`",
"doc": "Displays an alert with the output of `brew <subcommand> info <pkg>`\n\nParameters:\n * pkg - name of the package to query\n * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask info <pkg>` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "89",
"name": "showBrewInfo",
"notes": [],
"parameters": [
" * pkg - name of the package to query",
" * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info <pkg>\" being run. For example, if `subcommand` is \"cask\", the `brew cask info <pkg>` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:showBrewInfo(pkg, subcommand)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "BrewInfo:showBrewInfoCurSel(subcommand)",
"desc": "Display `brew <subcommand> info` using the selected text as the package name",
"doc": "Display `brew <subcommand> info` using the selected text as the package name\n\nParameters:\n * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info\" being run. For example, if `subcommand` is \"cask\", the `brew cask info` command will be used.\n\nReturns:\n * The Spoon object",
"examples": [],
"file": "Source/BrewInfo.spoon//init.lua",
"lineno": "113",
"name": "showBrewInfoCurSel",
"notes": [],
"parameters": [
" * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in \"brew info\" being run. For example, if `subcommand` is \"cask\", the `brew cask info` command will be used."
],
"returns": [
" * The Spoon object"
],
"signature": "BrewInfo:showBrewInfoCurSel(subcommand)",
"stripped_doc": "",
"type": "Method"
}
],
"name": "BrewInfo",
"stripped_doc": "\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip)\n\nYou can bind keys to automatically display the output of `brew\ninfo` of the currently-selected package name, or to open its\nhomepage. I use it to quickly explore new packages from the output\nof `brew update`.",
"submodules": [],
"type": "Module"
}
]
+195
View File
@@ -0,0 +1,195 @@
--- === BrewInfo ===
---
--- Display pop-up with Homebrew Formula info, or open their URL
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/BrewInfo.spoon.zip)
---
--- You can bind keys to automatically display the output of `brew
--- info` of the currently-selected package name, or to open its
--- homepage. I use it to quickly explore new packages from the output
--- of `brew update`.
local mod={}
mod.__index = mod
-- Conformance hack, our Travis linter expects the object to be called "obj"
local obj=mod
-- Metadata
mod.name = "BrewInfo"
obj.version = "1.1"
mod.author = "Diego Zamboni <diego@zzamboni.org>"
mod.homepage = "https://github.com/Hammerspoon/Spoons"
mod.license = "MIT - https://opensource.org/licenses/MIT"
--- BrewInfo.brew_path
--- Variable
--- A string specifying the path to the `brew` executable. Defaults to `/usr/local/bin/brew`
mod.brew_path = "/usr/local/bin/brew"
--- BrewInfo.brew_info_delay_sec
--- Variable
--- An integer specifying how long the alerts generated by BrewInfo will stay onscreen
mod.brew_info_delay_sec = 3
--- BrewInfo.brew_info_style
--- Variable
--- A table in conformance with the [hs.alert.defaultStyle](http://www.hammerspoon.org/docs/hs.alert.html#defaultStyle[]) format that specifies the style used by the alerts. Default value: `{ textFont = "Courier New", textSize = 14, radius = 10 }`
mod.brew_info_style = {
textFont = "Courier New",
textSize = 14,
radius = 10
}
--- BrewInfo.select_text_if_needed
--- Variable
--- If `true`, and no text is currently selected in the terminal, issue a double-click to select the text below the cursor, and use that as the input to `brew info`. See also `BrewInfo.select_text_modifiers`. Defaults to `true`.
mod.select_text_if_needed = true
--- BrewInfo.select_text_modifiers
--- Variable
--- Table containing the modifiers to be used together with a double-click when `BrewInfo.select_text_if_needed` is true. Defaults to `{cmd = true, shift = true}` to issue a Cmd-Shift-double-click, which will select a continuous non-space string in Terminal and iTerm2.
mod.select_text_modifiers = {cmd = true, shift = true}
-- Internal function to issue a double click with given modifiers
function leftDoubleClick(modifiers)
local pos=hs.mouse.absolutePosition()
hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftMouseDown, pos, modifiers)
:setProperty(hs.eventtap.event.properties.mouseEventClickState, 2)
:post()
hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftMouseUp, pos, modifiers)
:post()
end
-- Internal method to get the currently selected text
-- If `select_text_if_needed` is true and no text is selected, issue
-- a double-click to select, then use that
function mod:current_selection()
local elem=hs.uielement.focusedElement()
if elem then
local sel = elem:selectedText()
if (sel == nil or sel == "") and self.select_text_if_needed then
-- Simulate a double click to select the text under the cursor
leftDoubleClick(self.select_text_modifiers)
hs.timer.usleep(20000)
sel = elem:selectedText()
end
return sel
else
return nil
end
end
-- Internal method to show an alert in the configured style
function mod:show(text)
hs.alert.show(text, self.brew_info_style, self.brew_info_delay_sec)
return self
end
--- BrewInfo:showBrewInfo(pkg, subcommand)
--- Method
--- Displays an alert with the output of `brew <subcommand> info <pkg>`
---
--- Parameters:
--- * pkg - name of the package to query
--- * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in "brew info <pkg>" being run. For example, if `subcommand` is "cask", the `brew cask info <pkg>` command will be used.
---
--- Returns:
--- * The Spoon object
function mod:showBrewInfo(pkg, subcommand)
local info = "No package selected"
local st = nil
if pkg and pkg ~= "" then
local cmd=string.format("%s %s info %s", self.brew_path, subcommand or "", pkg)
info, st=hs.execute(cmd)
if st == nil then
info = "No information found about formula '" .. pkg .. "'!"
end
end
self:show(info)
return self
end
--- BrewInfo:showBrewInfoCurSel(subcommand)
--- Method
--- Display `brew <subcommand> info` using the selected text as the package name
---
--- Parameters:
--- * subcommand - brew subcommand to use for the `info` command. Defaults to an empty string, which results in "brew info" being run. For example, if `subcommand` is "cask", the `brew cask info` command will be used.
---
--- Returns:
--- * The Spoon object
function mod:showBrewInfoCurSel(subcommand)
return self:showBrewInfo(self:current_selection(), subcommand)
end
--- BrewInfo:openBrewURL(pkg, subcommand)
--- Method
--- Opens the homepage for package `pkg`, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`
---
--- Parameters:
--- * pkg - name of the package to query
--- * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in "brew cat <pkg>" being run. For example, if `subcommand` is "cask", the `brew cask cat <pkg>` command will be used.
---
--- Returns:
--- * The Spoon object
function mod:openBrewURL(pkg, subcommand)
local msg = "No package selected"
if pkg and pkg ~= "" then
local j, st, t, rc=hs.execute(string.format("%s %s cat %s", self.brew_path, (subcommand or ""), pkg ))
if st ~= nil then
local url=string.match(j, "\n%s*homepage%s+['\"](.-)['\"]%s*\n")
if url and url ~= "" then
hs.urlevent.openURLWithBundle(url, hs.urlevent.getDefaultHandler("http"))
return self
end
end
msg = "An error occurred obtaining information about '" .. pkg .. "'"
end
self:show(msg)
return self
end
--- BrewInfo:openBrewURLCurSel(subcommand)
--- Method
--- Opens the homepage for the currently-selected package, as obtained from the `homepage` field in `brew <subcommand> cat <pkg>`
---
--- Parameters:
--- * subcommand - brew subcommand to use for the `cat` command. Defaults to an empty string, which results in "brew cat <pkg>" being run. For example, if `subcommand` is "cask", the `brew cask cat <pkg>` command will be used.
---
--- Returns:
--- * The Spoon object
function mod:openBrewURLCurSel(subcommand)
return self:openBrewURL(self:current_selection(), subcommand)
end
--- BrewInfo:bindHotkeys(mapping)
--- Method
--- Binds hotkeys for BrewInfo
---
--- Parameters:
--- * mapping - A table containing hotkey modifier/key details for the following items:
--- * show_brew_info - Show output of `brew info` using the selected text as package name
--- * open_brew_url - Open the homepage of the formula whose name is currently selected
--- * show_brew_cask_info - Show output of `brew cask info` using the selected text as package name
--- * open_brew_cask_url - Open the homepage of the Cask whose name is currently selected
function mod:bindHotkeys(mapping)
local def = {
show_brew_info = function() self:showBrewInfoCurSel() end,
open_brew_url = function() self:openBrewURLCurSel() end,
}
for action, key in pairs(mapping) do
local subcommand_show = action:match("show_brew_(.*)_info")
if subcommand_show and subcommand_show ~= "" then
def[action] = function() self:showBrewInfoCurSel(subcommand_show) end
end
local subcommand_open = action:match("open_brew_(.*)_url")
if subcommand_open and subcommand_open ~= "" then
def[action] = function() self:openBrewURLCurSel(subcommand_open) end
end
end
hs.spoons.bindHotkeysToSpec(def, mapping)
end
return mod
+123
View File
@@ -0,0 +1,123 @@
[
{
"Command": [],
"Constant": [],
"Constructor": [],
"Deprecated": [],
"Field": [],
"Function": [],
"Method": [
{
"def": "KSheet:bindHotkeys(mapping)",
"desc": "Binds hotkeys for KSheet",
"doc": "Binds hotkeys for KSheet\n\nParameters:\n * mapping - A table containing hotkey modifier/key details for the following items:\n * show - Show the keybinding view\n * hide - Hide the keybinding view\n * toggle - Show if hidden, hide if shown",
"name": "bindHotkeys",
"parameters": [
" * mapping - A table containing hotkey modifier/key details for the following items:",
" * show - Show the keybinding view",
" * hide - Hide the keybinding view",
" * toggle - Show if hidden, hide if shown"
],
"signature": "KSheet:bindHotkeys(mapping)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:hide()",
"desc": "Hide the cheatsheet view.",
"doc": "Hide the cheatsheet view.",
"name": "hide",
"signature": "KSheet:hide()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:init()",
"desc": "Initialize the spoon",
"doc": "Initialize the spoon",
"name": "init",
"signature": "KSheet:init()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:show()",
"desc": "Show current application's keybindings in a view.",
"doc": "Show current application's keybindings in a view.",
"name": "show",
"signature": "KSheet:show()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:toggle()",
"desc": "Alternatively show/hide the cheatsheet view.",
"doc": "Alternatively show/hide the cheatsheet view.",
"name": "toggle",
"signature": "KSheet:toggle()",
"stripped_doc": "",
"type": "Method"
}
],
"Variable": [],
"desc": "Keybindings cheatsheet for current application",
"doc": "Keybindings cheatsheet for current application\n\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip)",
"items": [
{
"def": "KSheet:bindHotkeys(mapping)",
"desc": "Binds hotkeys for KSheet",
"doc": "Binds hotkeys for KSheet\n\nParameters:\n * mapping - A table containing hotkey modifier/key details for the following items:\n * show - Show the keybinding view\n * hide - Hide the keybinding view\n * toggle - Show if hidden, hide if shown",
"name": "bindHotkeys",
"parameters": [
" * mapping - A table containing hotkey modifier/key details for the following items:",
" * show - Show the keybinding view",
" * hide - Hide the keybinding view",
" * toggle - Show if hidden, hide if shown"
],
"signature": "KSheet:bindHotkeys(mapping)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:hide()",
"desc": "Hide the cheatsheet view.",
"doc": "Hide the cheatsheet view.",
"name": "hide",
"signature": "KSheet:hide()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:init()",
"desc": "Initialize the spoon",
"doc": "Initialize the spoon",
"name": "init",
"signature": "KSheet:init()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:show()",
"desc": "Show current application's keybindings in a view.",
"doc": "Show current application's keybindings in a view.",
"name": "show",
"signature": "KSheet:show()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "KSheet:toggle()",
"desc": "Alternatively show/hide the cheatsheet view.",
"doc": "Alternatively show/hide the cheatsheet view.",
"name": "toggle",
"signature": "KSheet:toggle()",
"stripped_doc": "",
"type": "Method"
}
],
"name": "KSheet",
"stripped_doc": "\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip)",
"submodules": [],
"type": "Module"
}
]
+242
View File
@@ -0,0 +1,242 @@
--- === KSheet ===
---
--- Keybindings cheatsheet for current application
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/KSheet.spoon.zip)
local obj={}
obj.__index = obj
-- Metadata
obj.name = "KSheet"
obj.version = "1.0"
obj.author = "ashfinal <ashfinal@gmail.com>"
obj.homepage = "https://github.com/Hammerspoon/Spoons"
obj.license = "MIT - https://opensource.org/licenses/MIT"
-- Workaround for "Dictation" menuitem
hs.application.menuGlyphs[148]="fn fn"
obj.commandEnum = {
cmd = '',
shift = '',
alt = '',
ctrl = '',
}
--- KSheet:init()
--- Method
--- Initialize the spoon
function obj:init()
self.sheetView = hs.webview.new({x=0, y=0, w=0, h=0})
self.sheetView:windowTitle("CheatSheets")
self.sheetView:windowStyle("utility")
self.sheetView:allowGestures(true)
self.sheetView:allowNewWindows(false)
self.sheetView:level(hs.drawing.windowLevels.tornOffMenu)
local cscreen = hs.screen.mainScreen()
local cres = cscreen:fullFrame()
self.sheetView:frame({
x = cres.x+cres.w*0.15/2,
y = cres.y+cres.h*0.25/2,
w = cres.w*0.85,
h = cres.h*0.75
})
end
local function processMenuItems(menustru)
local menu = ""
for pos,val in pairs(menustru) do
if type(val) == "table" then
-- TODO: Remove menubar items with no shortcuts in them
if val.AXRole == "AXMenuBarItem" and type(val.AXChildren) == "table" then
menu = menu .. "<ul class='col col" .. pos .. "'>"
menu = menu .. "<li class='title'><strong>" .. val.AXTitle .. "</strong></li>"
menu = menu .. processMenuItems(val.AXChildren[1])
menu = menu .. "</ul>"
elseif val.AXRole == "AXMenuItem" and not val.AXChildren then
if not (val.AXMenuItemCmdChar == '' and val.AXMenuItemCmdGlyph == '') then
local CmdModifiers = ''
for key, value in pairs(val.AXMenuItemCmdModifiers) do
CmdModifiers = CmdModifiers .. obj.commandEnum[value]
end
local CmdChar = val.AXMenuItemCmdChar
local CmdGlyph = hs.application.menuGlyphs[val.AXMenuItemCmdGlyph] or ''
local CmdKeys = CmdChar .. CmdGlyph
menu = menu .. "<li><div class='cmdModifiers'>" .. CmdModifiers .. " " .. CmdKeys .. "</div><div class='cmdtext'>" .. " " .. val.AXTitle .. "</div></li>"
end
elseif val.AXRole == "AXMenuItem" and type(val.AXChildren) == "table" then
menu = menu .. processMenuItems(val.AXChildren[1])
end
end
end
return menu
end
local function generateHtml(application)
local app_title = application:title()
local menuitems_tree = application:getMenuItems()
local allmenuitems = processMenuItems(menuitems_tree)
local html = [[
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
*{margin:0; padding:0;}
html, body{
background-color:#eee;
font-family: arial;
font-size: 13px;
}
a{
text-decoration:none;
color:#000;
font-size:12px;
}
li.title{ text-align:center;}
ul, li{list-style: inside none; padding: 0 0 5px;}
footer{
position: fixed;
left: 0;
right: 0;
height: 48px;
background-color:#eee;
}
header{
position: fixed;
top: 0;
left: 0;
right: 0;
height:48px;
background-color:#eee;
z-index:99;
}
footer{ bottom: 0; }
header hr,
footer hr {
border: 0;
height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.title{
padding: 15px;
}
li.title{padding: 0 10px 15px}
.content{
padding: 0 0 15px;
font-size:12px;
overflow:hidden;
}
.content.maincontent{
position: relative;
height: 577px;
margin-top: 46px;
}
.content > .col{
width: 23%;
padding:20px 0 20px 20px;
}
li:after{
visibility: hidden;
display: block;
font-size: 0;
content: " ";
clear: both;
height: 0;
}
.cmdModifiers{
width: 60px;
padding-right: 15px;
text-align: right;
float: left;
font-weight: bold;
}
.cmdtext{
float: left;
overflow: hidden;
width: 165px;
}
</style>
</head>
<body>
<header>
<div class="title"><strong>]] .. app_title .. [[</strong></div>
<hr />
</header>
<div class="content maincontent">]] .. allmenuitems .. [[</div>
<br>
<footer>
<hr />
<div class="content" >
<div class="col">
by <a href="https://github.com/dharmapoudel" target="_parent">dharma poudel</a>
</div>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.isotope/2.2.2/isotope.pkgd.min.js"></script>
<script type="text/javascript">
var elem = document.querySelector('.content');
var iso = new Isotope( elem, {
// options
itemSelector: '.col',
layoutMode: 'masonry'
});
</script>
</body>
</html>
]]
return html
end
--- KSheet:show()
--- Method
--- Show current application's keybindings in a view.
function obj:show()
local capp = hs.application.frontmostApplication()
local webcontent = generateHtml(capp)
self.sheetView:html(webcontent)
self.sheetView:show()
end
--- KSheet:hide()
--- Method
--- Hide the cheatsheet view.
function obj:hide()
self.sheetView:hide()
end
--- KSheet:toggle()
--- Method
--- Alternatively show/hide the cheatsheet view.
function obj:toggle()
if self.sheetView and self.sheetView:hswindow() and self.sheetView:hswindow():isVisible() then
self:hide()
else
self:show()
end
end
--- KSheet:bindHotkeys(mapping)
--- Method
--- Binds hotkeys for KSheet
---
--- Parameters:
--- * mapping - A table containing hotkey modifier/key details for the following items:
--- * show - Show the keybinding view
--- * hide - Hide the keybinding view
--- * toggle - Show if hidden, hide if shown
function obj:bindHotkeys(mapping)
local actions = {
toggle = hs.fnutils.partial(self.toggle, self),
show = hs.fnutils.partial(self.show, self),
hide = hs.fnutils.partial(self.hide, self)
}
hs.spoons.bindHotkeysToSpec(actions, mapping)
end
return obj
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
--- === Seal ===
---
--- Pluggable launch bar
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/Seal.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/Seal.spoon.zip)
---
--- Seal includes a number of plugins, which you can choose to load (see `:loadPlugins()` below):
--- * apps : Launch applications by name
--- * calc : Simple calculator
--- * rot13 : Apply ROT13 substitution cipher
--- * safari_bookmarks : Open Safari bookmarks (this is broken since at least High Sierra)
--- * screencapture : Lets you take screenshots in various ways
--- * urlformats : User defined URL formats to open
--- * useractions : User defined custom actions
--- * vpn : Connect and disconnect VPNs (currently supports Viscosity and macOS system preferences)A
local obj = {}
obj.__index = obj
-- Metadata
obj.name = "Seal"
obj.version = "1.0"
obj.author = "Chris Jones <cmsj@tenshu.net>"
obj.homepage = "https://github.com/Hammerspoon/Spoons"
obj.license = "MIT - https://opensource.org/licenses/MIT"
obj.chooser = nil
obj.hotkeyShow = nil
obj.hotkeyToggle = nil
obj.plugins = {}
obj.commands = {}
obj.queryChangedTimer = nil
obj.spoonPath = hs.spoons.scriptPath()
--- Seal.queryChangedTimerDuration
--- Variable
--- Time between the last keystroke and the start of the recalculation of the choices to display, in seconds.
---
--- Notes:
--- * Defaults to 0.02s (20ms).
obj.queryChangedTimerDuration = 0.02
--- Seal.plugin_search_paths
--- Variable
--- List of directories where Seal will look for plugins. Defaults to `~/.hammerspoon/seal_plugins/` and the Seal Spoon directory.
obj.plugin_search_paths = { hs.configdir .. "/seal_plugins", obj.spoonPath }
--- Seal:refreshCommandsForPlugin(plugin_name)
--- Method
--- Refresh the list of commands provided by the given plugin.
---
--- Parameters:
--- * plugin_name - the name of the plugin. Should be the name as passed to `loadPlugins()` or `loadPluginFromFile`.
---
--- Returns:
--- * The Seal object
---
--- Notes:
--- * Most Seal plugins expose a static list of commands (if any), which are registered at the time the plugin is loaded. This method is used for plugins which expose a dynamic or changing (e.g. depending on configuration) list of commands.
function obj:refreshCommandsForPlugin(plugin_name)
plugin = self.plugins[plugin_name]
if plugin.commands then
for cmd,cmdInfo in pairs(plugin:commands()) do
if not self.commands[cmd] then
print("-- Adding Seal command: "..cmd)
self.commands[cmd] = cmdInfo
end
end
end
return self
end
--- Seal:refreshAllCommands()
--- Method
--- Refresh the list of commands provided by all the currently loaded plugins.
---
--- Parameters:
--- * None
---
--- Returns:
--- * The Seal object
---
--- Notes:
--- * Most Seal plugins expose a static list of commands (if any), which are registered at the time the plugin is loaded. This method is used for plugins which expose a dynamic or changing (e.g. depending on configuration) list of commands.
function obj:refreshAllCommands()
for p, _ in pairs(self.plugins) do
self:refreshCommandsForPlugin(p)
end
return self
end
--- Seal:loadPluginFromFile(plugin_name, file)
--- Method
--- Loads a plugin from a given file
---
--- Parameters:
--- * plugin_name - the name of the plugin, without "seal_" at the beginning or ".lua" at the end
--- * file - the file where the plugin code is stored.
---
--- Returns:
--- * The Seal object if the plugin was successfully loaded, `nil` otherwise
---
--- Notes:
--- * You should normally use `Seal:loadPlugins()`. This method allows you to load plugins
--- from non-standard locations and is mostly a development interface.
--- * Some plugins may immediately begin doing background work (e.g. Spotlight searches)
function obj:loadPluginFromFile(plugin_name, file)
local f,err = loadfile(file)
if f~= nil then
local plugin = f()
plugin.seal = self
self.plugins[plugin_name] = plugin
self:refreshCommandsForPlugin(plugin_name)
return self
else
return nil
end
end
--- Seal:loadPlugins(plugins)
--- Method
--- Loads a list of Seal plugins
---
--- Parameters:
--- * plugins - A list containing the names of plugins to load
---
--- Returns:
--- * The Seal object
---
--- Notes:
--- * The plugins live inside the Seal.spoon directory
--- * The plugin names in the list, should not have `seal_` at the start, or `.lua` at the end
--- * Some plugins may immediately begin doing background work (e.g. Spotlight searches)
function obj:loadPlugins(plugins)
self.chooser = hs.chooser.new(self.completionCallback)
self.chooser:choices(self.choicesCallback)
self.chooser:queryChangedCallback(self.queryChangedCallback)
for k,plugin_name in pairs(plugins) do
local loaded=nil
print("-- Loading Seal plugin: " .. plugin_name)
for _,dir in ipairs(self.plugin_search_paths) do
if obj.plugins[plugin_name] == nil then
local file = dir .. "/seal_" .. plugin_name .. ".lua"
loaded = (self:loadPluginFromFile(plugin_name, file) ~= nil)
end
end
if (not loaded) then
hs.showError(string.format("Error: could not find Seal plugin %s in any of the load paths %s", plugin_name, hs.inspect(self.plugin_search_paths)))
end
end
return self
end
--- Seal:bindHotkeys(mapping)
--- Method
--- Binds hotkeys for Seal
---
--- Parameters:
--- * mapping - A table containing hotkey modifier/key details for the following (optional) items:
--- * show - This will cause Seal's UI to be shown
--- * toggle - This will cause Seal's UI to be shown or hidden depending on its current state
---
--- Returns:
--- * The Seal object
function obj:bindHotkeys(mapping)
if (self.hotkeyShow) then
self.hotkeyShow:delete()
end
if (self.hotkeyToggle) then
self.hotkeyToggle:delete()
end
if mapping["show"] ~= nil then
local showMods = mapping["show"][1]
local showKey = mapping["show"][2]
self.hotkeyShow = hs.hotkey.new(showMods, showKey, function() self:show() end)
end
if mapping["toggle"] ~= nil then
local toggleMods = mapping["toggle"][1]
local toggleKey = mapping["toggle"][2]
self.hotkeyToggle = hs.hotkey.new(toggleMods, toggleKey, function() self:toggle() end)
end
return self
end
--- Seal:start()
--- Method
--- Starts Seal
---
--- Parameters:
--- * None
---
--- Returns:
--- * The Seal object
function obj:start()
print("-- Starting Seal")
if self.hotkeyShow then
self.hotkeyShow:enable()
end
if self.hotkeyToggle then
self.hotkeyToggle:enable()
end
return self
end
--- Seal:stop()
--- Method
--- Stops Seal
---
--- Parameters:
--- * None
---
--- Returns:
--- * The Seal object
---
--- Notes:
--- * Some Seal plugins will continue performing background work even after this call (e.g. Spotlight searches)
function obj:stop()
print("-- Stopping Seal")
self.chooser:hide()
if self.hotkeyShow then
self.hotkeyShow:disable()
end
if self.hotkeyToggle then
self.hotkeyToggle:disable()
end
return self
end
--- Seal:show(query)
--- Method
--- Shows the Seal UI
---
--- Parameters:
--- * query - An optional string to pre-populate the query box with
---
--- Returns:
--- * None
---
--- Notes:
--- * This may be useful if you wish to show Seal in response to something other than its hotkey
function obj:show(query)
self.chooser:show()
if query then self.chooser:query(query) end
return self
end
--- Seal:toggle(query)
--- Method
--- Shows or hides the Seal UI
---
--- Parameters:
--- * query - An optional string to pre-populate the query box with
---
--- Returns:
--- * None
function obj:toggle(query)
if self.chooser:isVisible() then
self.chooser:hide()
else
self:show(query)
end
return self
end
function obj.completionCallback(rowInfo)
if rowInfo == nil then
return
end
if rowInfo["type"] == "plugin_cmd" then
obj.chooser:query(rowInfo["cmd"])
return
end
for k,plugin in pairs(obj.plugins) do
if plugin.__name == rowInfo["plugin"] then
plugin.completionCallback(rowInfo)
break
end
end
end
function obj.choicesCallback()
-- TODO: Sort each of these clusters of choices, alphabetically
choices = {}
query = obj.chooser:query()
cmd = nil
query_words = {}
if tostring(query):find("^%s*$") ~= nil then
return choices
end
for word in string.gmatch(query, "%S+") do
if cmd == nil then
cmd = word
else
table.insert(query_words, word)
end
end
query_words = table.concat(query_words, " ")
-- First get any direct command matches
for _,cmdInfo in pairs(obj.commands) do
cmd_fn = cmdInfo["fn"]
if cmd:lower() == cmdInfo["cmd"]:lower() then
if (query_words or "") == "" then
query_words = ".*"
end
fn_choices = cmd_fn(query_words)
if fn_choices ~= nil then
for j,choice in pairs(fn_choices) do
table.insert(choices, choice)
end
end
end
end
-- Now get any bare matches
for k,plugin in pairs(obj.plugins) do
bare = plugin:bare()
if bare then
for i,choice in pairs(bare(query)) do
table.insert(choices, choice)
end
end
end
-- Now add in any matching commands
-- TODO: This only makes sense to do if we can select the choice without dismissing the chooser, which requires changes to HSChooser
for command,cmdInfo in pairs(obj.commands) do
if string.match(command, query) and #query_words == 0 then
choice = {}
choice["text"] = cmdInfo["name"]
choice["subText"] = cmdInfo["description"]
choice["type"] = "plugin_cmd"
table.insert(choices,choice)
end
end
return choices
end
function obj.queryChangedCallback(query)
if obj.queryChangedTimer then
obj.queryChangedTimer:stop()
end
obj.queryChangedTimer = hs.timer.doAfter(obj.queryChangedTimerDuration,
function() obj.chooser:refreshChoicesCallback() end)
end
return obj
--- === Seal.plugins ===
---
--- Various APIs for Seal plugins
-- This isn't really shown, but it's necessary to force Seal.plugins.html to render
--- Seal.plugins
--- Constant
--- This is a table containing all of the loaded plugins for Seal. You should interact with it only via documented API that the plugins expose.
+236
View File
@@ -0,0 +1,236 @@
--- === Seal.plugins.apps ===
---
--- A plugin to add launchable apps/scripts, making Seal act as a launch bar
local obj = {}
obj.__index = obj
obj.__name = "seal_apps"
obj.appCache = {}
--- Seal.plugins.apps.appSearchPaths
--- Variable
--- Table containing the paths to search for launchable items
---
--- Notes:
--- * If you change this, you will need to call `spoon.Seal.plugins.apps:restart()` to force Spotlight to search for new items.
obj.appSearchPaths = {
"/Applications",
"/System/Applications",
"~/Applications",
"/Developer/Applications",
"/Applications/Xcode.app/Contents/Applications",
"/System/Library/PreferencePanes",
"/Library/PreferencePanes",
"~/Library/PreferencePanes",
"/System/Library/CoreServices/Applications",
"/System/Library/CoreServices/",
"/usr/local/Cellar",
"/Library/Scripts",
"~/Library/Scripts"
}
local modifyNameMap = function(info, add)
for _, item in ipairs(info) do
icon = nil
local displayname = item.kMDItemDisplayName or hs.fs.displayName(item.kMDItemPath)
displayname = displayname:gsub("%.app$", "", 1)
if string.find(item.kMDItemPath, "%.prefPane$") then
displayname = displayname .. " preferences"
if add then
icon = hs.image.iconForFile(item.kMDItemPath)
end
end
if add then
bundleID = item.kMDItemCFBundleIdentifier
if (not icon) and (bundleID) then
icon = hs.image.imageFromAppBundle(bundleID)
end
obj.appCache[displayname] = {
path = item.kMDItemPath,
bundleID = bundleID,
icon = icon
}
else
obj.appCache[displayname] = nil
end
end
end
local updateNameMap = function(obj, msg, info)
if info then
-- all three can occur in either message, so check them all!
if info.kMDQueryUpdateAddedItems then modifyNameMap(info.kMDQueryUpdateAddedItems, true) end
if info.kMDQueryUpdateChangedItems then modifyNameMap(info.kMDQueryUpdateChangedItems, true) end
if info.kMDQueryUpdateRemovedItems then modifyNameMap(info.kMDQueryUpdateRemovedItems, false) end
else
-- shouldn't happen for didUpdate or inProgress
print("~~~ userInfo from SpotLight was empty for " .. msg)
end
end
--- Seal.plugins.apps:start()
--- Method
--- Starts the Spotlight app searcher
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * This is called automatically when the plugin is loaded
function obj:start()
obj.spotlight = hs.spotlight.new():queryString([[ (kMDItemContentType = "com.apple.application-bundle") || (kMDItemContentType = "com.apple.systempreference.prefpane") || (kMDItemContentType = "com.apple.applescript.text") || (kMDItemContentType = "com.apple.applescript.script") ]])
:callbackMessages("didUpdate", "inProgress")
:setCallback(updateNameMap)
:searchScopes(obj.appSearchPaths)
:start()
end
--- Seal.plugins.apps:stop()
--- Method
--- Stops the Spotlight app searcher
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
function obj:stop()
obj.spotlight:stop()
obj.spotlight = nil
obj.appCache = {}
end
--- Seal.plugins.apps:restart()
--- Method
--- Restarts the Spotlight app searcher
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
function obj:restart()
self:stop()
self:start()
end
hs.application.enableSpotlightForNameSearches(true)
obj:start()
function obj:commands()
return {kill = {
cmd = "kill",
fn = obj.choicesKillCommand,
plugin = obj.__name,
name = "Kill",
description = "Kill an application"
},
reveal = {
cmd = "reveal",
fn = obj.choicesRevealCommand,
plugin = obj.__name,
name = "Reveal",
description = "Reveal an application in the Finder"
}
}
end
function obj:bare()
return self.choicesApps
end
function obj.choicesApps(query)
local choices = {}
if query == nil or query == "" then
return choices
end
for name,app in pairs(obj.appCache) do
if string.match(name:lower(), query:lower()) then
local choice = {}
local instances = {}
if app["bundleID"] then
instances = hs.application.applicationsForBundleID(app["bundleID"])
end
if #instances > 0 then
choice["text"] = name .. " (Running)"
else
choice["text"] = name
end
choice["subText"] = app["path"]
if app["icon"] then
choice["image"] = app["icon"]
end
choice["path"] = app["path"]
choice["uuid"] = obj.__name.."__"..(app["bundleID"] or name)
choice["plugin"] = obj.__name
choice["type"] = "launchOrFocus"
table.insert(choices, choice)
end
end
return choices
end
function obj.choicesKillCommand(query)
local choices = {}
if query == nil then
return choices
end
local apps = hs.application.runningApplications()
for k, app in pairs(apps) do
local name = app:name()
if string.match(name:lower(), query:lower()) and app:mainWindow() then
local choice = {}
choice["text"] = "Kill "..name
choice["subText"] = app:path().." PID: "..app:pid()
choice["pid"] = app:pid()
choice["plugin"] = obj.__name
choice["type"] = "kill"
choice["image"] = hs.image.imageFromAppBundle(app:bundleID())
table.insert(choices, choice)
end
end
return choices
end
function obj.choicesRevealCommand(query)
local choices = {}
if query == nil then
return choices
end
local apps = obj.choicesApps(query)
for k, app in pairs(apps) do
local name = app.text
if string.match(name:lower(), query:lower()) then
local choice = {}
choice["text"] = "Reveal "..name
choice["path"] = app.path
choice["subText"] = app.path
choice["plugin"] = obj.__name
choice["type"] = "reveal"
if app.image then
choice["image"] = app.image
end
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "launchOrFocus" then
if string.find(rowInfo["path"], "%.applescript$") or string.find(rowInfo["path"], "%.scpt$") then
hs.task.new("/usr/bin/osascript", nil, { rowInfo["path"] }):start()
else
hs.task.new("/usr/bin/open", nil, { rowInfo["path"] }):start()
end
elseif rowInfo["type"] == "kill" then
hs.application.get(rowInfo["pid"]):kill()
elseif rowInfo["type"] == "reveal" then
hs.osascript.applescript(string.format([[tell application "Finder" to reveal (POSIX file "%s")]], rowInfo["path"]))
hs.application.launchOrFocus("Finder")
end
end
return obj
+47
View File
@@ -0,0 +1,47 @@
local obj = {}
obj.__index = obj
obj.__name = "seal_calc"
obj.icon = hs.image.imageFromAppBundle("com.apple.Calculator")
function obj:commands()
return {}
end
function obj:bare()
return self.bareCalc
end
function obj.bareCalc(query)
local choices = {}
if query == nil or query == "" then
return choices
end
-- Filter out commas and dollar signs
query, _ = query:gsub("[%,%$]", "")
-- We need to determine if the query only contains mathematical calculations
-- To do this we'll see if it matches the inverse of that set of characters
if string.match(query, "[^%d^%.^%+^%-^/^%*^%^^ ^%(^%)]") == nil then
local choice = {}
local compile_result, fn = load("return " .. query)
if type(compile_result) == "function" then
local result = compile_result()
choice["text"] = result
choice["subText"] = "Copy result to clipboard"
choice["image"] = obj.icon
choice["plugin"] = obj.__name
choice["type"] = "copyToClipboard"
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "copyToClipboard" then
hs.pasteboard.setContents(rowInfo["text"])
end
end
return obj
+168
View File
@@ -0,0 +1,168 @@
--- === Seal.plugins.filesearch ===
---
--- A plugin to add file search capabilities, making Seal act as a spotlight file search
local obj = {}
obj.__index = obj
obj.__name = "seal_filesearch"
--- Seal.plugins.filesearch.fileSearchPaths
--- Variable
--- Table containing the paths to search for files
---
--- Notes:
--- * You will need to authorize hammerspoon to access the folders in this list in order for this to work.
obj.fileSearchPaths = {"~/", "~/Downloads", "~/Documents", "~/Movies", "~/Desktop", "~/Music", "~/Pictures"}
--- Seal.plugins.filesearch.maxResults
--- Variable
--- Maximum number of results to display
obj.maxQueryResults = 40
--- Seal.plugins.filesearch.displayResultsTimeout
--- Variable
--- Maximum time to wait before displaying the results
--- Defaults to 0.2s (200ms).
---
--- Notes:
--- * higher value might give you more results but will give a less snappy experience
obj.displayResultsTimeout = 0.2
-- Variables
obj.currentQuery = nil
obj.currentQueryResults = {}
obj.currentQueryResultsDisplayed = false
obj.showQueryResultsTimer = nil
obj.spotlight = hs.spotlight.new()
-- hammerspoon passes .* as empty query
EMPTY_QUERY = ".*"
-- Private functions
local stopCurrentSearch = function()
if obj.spotlight:isRunning() then
obj.spotlight:stop()
end
if obj.showQueryResultsTimer ~= nil and obj.showQueryResultsTimer:running() then
obj.showQueryResultsTimer:stop()
end
end
local displayQueryResults = function()
stopCurrentSearch()
if not obj.currentQueryResultsDisplayed then
obj.currentQueryResultsDisplayed = true
-- we force seal to refresh the choices so we can serve the real query results
obj.seal.chooser:refreshChoicesCallback()
end
end
local buildSpotlightQuery = function(query)
local queryWords = hs.fnutils.split(query, "%s+")
local searchFilters = hs.fnutils.map(queryWords, function(word)
return [[kMDItemFSName like[c] "*]] .. word .. [[*"]]
end)
local spotligthQuery = table.concat(searchFilters, [[ && ]])
return spotligthQuery
end
local convertSpotlightResultToQueryResult = function(item)
local icon = hs.image.iconForFile(item.kMDItemPath)
local bundleID = item.kMDItemCFBundleIdentifier
if (not icon) and (bundleID) then
icon = hs.image.imageFromAppBundle(bundleID)
end
return {
text = item.kMDItemDisplayName,
subText = item.kMDItemPath,
path = item.kMDItemPath,
uuid = obj.__name .. "__" .. (bundleID or item.kMDItemDisplayName),
plugin = obj.__name,
type = "open",
image = icon
}
end
local updateQueryResults = function(items)
for _, item in ipairs(items) do
if #obj.currentQueryResults >= obj.maxQueryResults then
break
end
table.insert(obj.currentQueryResults, convertSpotlightResultToQueryResult(item))
end
end
local handleSpotlightCallback = function(_, msg, info)
if msg == "inProgress" and info.kMDQueryUpdateAddedItems ~= nil then
updateQueryResults(info.kMDQueryUpdateAddedItems)
end
if msg == "didFinish" or #obj.currentQueryResults >= obj.maxQueryResults then
displayQueryResults()
end
end
-- Public methods
function obj:commands()
return {
filesearch = {
cmd = "'",
fn = obj.fileSearch,
name = "Search file",
description = "Search file",
plugin = obj.__name
}
}
end
function obj:bare()
return nil
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "open" then
if string.find(rowInfo["path"], "%.applescript$") or string.find(rowInfo["path"], "%.scpt$") then
hs.task.new("/usr/bin/osascript", nil, {rowInfo["path"]}):start()
else
hs.task.new("/usr/bin/open", nil, {rowInfo["path"]}):start()
end
end
end
function obj.fileSearch(query)
stopCurrentSearch()
if query == EMPTY_QUERY then
obj.currentQuery = ""
obj.currentQueryResults = {}
return {}
end
if query ~= obj.currentQuery then
-- Seal want the results synchronously, but spotlight will return then asynchronously
-- to workaround that, we launch the spotlight search in the background and
-- return the previous results (so that Seal doesn't change the current results list)
-- We force a refresh later once we have the results
local previousResults = obj.currentQueryResults
obj.currentQuery = query
obj.currentQueryResults = {}
obj.currentQueryResultsDisplayed = false
obj.spotlight:queryString(buildSpotlightQuery(query)):start()
obj.showQueryResultsTimer = hs.timer.doAfter(obj.displayResultsTimeout, displayQueryResults)
return previousResults
else
-- If we are here, it's mean the force refreshed has been triggered after receving spotlight results
-- we just return the results we accumulated from spotlight
return obj.currentQueryResults
end
end
obj.spotlight:searchScopes(obj.fileSearchPaths):callbackMessages("inProgress", "didFinish"):setCallback(
handleSpotlightCallback)
return obj
+182
View File
@@ -0,0 +1,182 @@
--- ==== Seal.plugins.pasteboard ====
---
--- Visual, searchable pasteboard (ie clipboard) history
local obj = {}
obj.__index = obj
obj.__name = "seal_pasteboard"
obj.timer = nil
obj.lastItem = nil
obj.itemBuffer = {}
obj.choices = {}
--- Seal.plugins.pasteboard.historySize
--- Variable
---
--- The number of history items to keep. Defaults to 50
obj.historySize = 50
--- Seal.plugins.pasteboard.saveHistory
--- Variable
---
--- A boolean, true if Seal should automatically load/save clipboard history. Defaults to true
obj.saveHistory = true
--- Seal.plugins.pasteboard.skipUTIs
--- Variable
---
--- An array of UTIs to skip when saving to the history. Defaults to:
--- ```
--- {
--- "de.petermaurer.TransientPasteboardType",
--- "com.typeit4me.clipping",
--- "Pasteboard generator type",
--- "com.agilebits.onepassword",
--- "org.nspasteboard.TransientType",
--- "org.nspasteboard.ConcealedType",
--- "org.nspasteboard.AutoGeneratedType"
--- }
--- ```
obj.skipUTIs = {
"de.petermaurer.TransientPasteboardType",
"com.typeit4me.clipping",
"Pasteboard generator type",
"com.agilebits.onepassword",
"org.nspasteboard.TransientType",
"org.nspasteboard.ConcealedType",
"org.nspasteboard.AutoGeneratedType"
}
function obj:commands()
return {
pb = {
cmd = "pb",
fn = obj.choicesPasteboardCommand,
name = "Pasteboard",
description = "Pasteboard history",
plugin = obj.__name
}
}
end
function obj:bare()
return nil
end
function obj.choicesPasteboardCommand(query)
-- Return the choices that match the query
return hs.fnutils.filter(obj.choices, function(choice)
return string.find(string.lower(choice["text"]), string.lower(query))
end)
end
function obj.pasteboardToChoice(item)
local choice = {}
choice["uuid"] = item["uuid"]
choice["name"] = item["text"]
choice["text"] = item["text"]
choice["kind"] = kind
choice["plugin"] = obj.__name
choice["type"] = "copy"
choice["subText"] = ""
if item["uti"] then
choice["subText"] = item["uti"]
if hs.application.defaultAppForUTI then
local bundleID = hs.application.defaultAppForUTI(item["uti"])
print("Default app for " .. item["uti"] .. " :: " .. (bundleID or "(null)"))
if bundleID then
choice["image"] = hs.image.imageFromAppBundle(bundleID)
end
end
end
if item["dateTime"] then
choice["subText"] = choice["subText"] .. " :: " .. item["dateTime"]
end
return choice
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "copy" then
hs.pasteboard.setContents(rowInfo["name"])
end
end
function obj.checkPasteboard()
local pasteboard = hs.pasteboard.getContents()
local shouldSave = false
if pasteboard == nil then
return
end
if (#obj.itemBuffer == 0) or (pasteboard ~= obj.itemBuffer[#obj.itemBuffer]["text"]) then
local currentTypes = hs.pasteboard.allContentTypes()[1]
if currentTypes == nil then
print("ERROR: NO PASTEBOARD CURRENT TYPES. Please file a bug so we can understand this:")
print(hs.inspect(pasteboard))
return
end
for _, aType in pairs(currentTypes) do
for _, uti in pairs(obj.skipUTIs) do
if uti == aType then
return
end
end
end
local item = {}
item["uuid"] = hs.host.uuid()
item["text"] = pasteboard
item["uti"] = currentTypes[1]
item["dateTime"] = os.date()
table.insert(obj.itemBuffer, item)
table.insert(obj.choices, obj.pasteboardToChoice(item))
shouldSave = true
end
if #obj.itemBuffer > obj.historySize then
table.remove(obj.itemBuffer, 1)
table.remove(obj.choices, 1)
shouldSave = true
end
if shouldSave then
obj.save()
end
end
function obj.save()
local json = hs.json.encode(obj.itemBuffer)
local file = io.open(os.getenv("HOME") .. "/.hammerspoon/pasteboard_history.json", "w")
if file then
file:write(json)
file:close()
end
end
function obj.load()
local file = io.open(os.getenv("HOME") .. "/.hammerspoon/pasteboard_history.json", "r")
if file then
local json = hs.json.decode(file:read())
if json then
obj.itemBuffer = json
-- Convert all the items to the choice buffer
for _, v in ipairs(obj.itemBuffer) do
table.insert(obj.choices, obj.pasteboardToChoice(v))
end
end
file:close()
end
end
obj.load()
if obj.timer == nil then
obj.timer = hs.timer.doEvery(1, function() obj.checkPasteboard(obj) end)
obj.timer:start()
end
return obj
+48
View File
@@ -0,0 +1,48 @@
local obj = {}
obj.__index = obj
obj.__name = "seal_rot13"
function obj:commands()
return {
rot13 = {
cmd = "rot13",
fn = obj.rot13,
name = "ROT13",
description = "Apply ROT13 substitution cipher"
}
}
end
function obj:bare()
return nil
end
function obj.rot13(query)
-- ROT13 implementation taken from https://rosettacode.org/wiki/Rot-13#Lua
local a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
local b = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm"
local rot13Text =
query:gsub(
"%a",
function(c)
return b:sub(a:find(c))
end
)
return {
{
text = rot13Text,
subText = "Copy result to clipboard",
plugin = obj.__name,
type = "copyToClipboard"
}
}
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "copyToClipboard" then
hs.pasteboard.setContents(rowInfo["text"])
end
end
return obj
@@ -0,0 +1,90 @@
--- === Seal.plugins.safari_bookmarks ===
--- Access Safari bookmarks from Seal
---
--- Note: Apple has changed the way Safari stores bookmarks and this plugin no longer works on recent macOS releases.
local obj = {}
obj.__index = obj
obj.__name = "seal_safari_bookmarks"
obj.bookmarkCache = {}
obj.icon = hs.image.iconForFileType("com.apple.safari.bookmark")
--- Seal.plugins.safari_bookmarks.always_open_with_safari
--- Variable
--- If `true` (default), bookmarks are always opened with Safari, otherwise they are opened with the default application using the `/usr/bin/open` command.
obj.always_open_with_safari = true
local modifyNameMap = function(info, add)
local name
for _, item in ipairs(info) do
name = item.kMDItemDisplayName
if name ~= nil then
if add then
obj.bookmarkCache[name] = {
url = item.kMDItemURL,
}
else
obj.bookmarkCache[name] = nil
end
end
end
end
local updateNameMap = function(obj, msg, info)
if info then
-- all three can occur in either message, so check them all!
if info.kMDQueryUpdateAddedItems then modifyNameMap(info.kMDQueryUpdateAddedItems, true) end
if info.kMDQueryUpdateChangedItems then modifyNameMap(info.kMDQueryUpdateChangedItems, true) end
if info.kMDQueryUpdateRemovedItems then modifyNameMap(info.kMDQueryUpdateRemovedItems, false) end
else
-- shouldn't happen for didUpdate or inProgress
print("~~~ userInfo from SpotLight was empty for " .. msg)
end
end
obj.spotlight = hs.spotlight.new():queryString([[ kMDItemContentType = "com.apple.safari.bookmark" ]])
:callbackMessages("didUpdate", "inProgress")
:setCallback(updateNameMap)
:start()
function obj:commands()
return {}
end
function obj:bare()
return self.choicesBookmarks
end
function obj.choicesBookmarks(query)
local choices = {}
if query == nil or query == "" then
return choices
end
for name,bookmark in pairs(obj.bookmarkCache) do
url = bookmark["url"]
if url and (string.match(name:lower(), query:lower()) or string.match(url:lower(), query:lower())) then
local choice = {}
local instances = {}
choice["text"] = name
choice["subText"] = url
choice["url"] = url
choice["image"] = obj.icon
choice["uuid"] = obj.__name.."__"..name.."__"..url
choice["plugin"] = obj.__name
choice["type"] = "openURL"
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "openURL" then
if obj.always_open_with_safari then
hs.urlevent.openURLWithBundle(rowInfo["url"], "com.apple.Safari")
else
hs.execute(string.format("/usr/bin/open '%s'", rowInfo["url"]))
end
end
end
return obj
+98
View File
@@ -0,0 +1,98 @@
--- === Seal.plugins.screencapture ===
---
--- A plugin to capture the screen in various ways
local obj = {}
obj.__index = obj
obj.__name = "seal_screencapture"
--- Seal.plugins.screencapture.showPostUI
--- Variable
--- Whether or not to show the screen capture UI in macOS 10.14 or later
obj.showPostUI = true
local static_choices = {
{
text = "Capture menu",
subText = "Show macOS screen capture menu",
plugin = obj.__name,
type = "screenUI"
},
{
text = "Capture Screen",
subText = "Capture the current screen",
plugin = obj.__name,
type = "screen"
},
{
text = "Capture Screen to Clipboard",
subText = "Capture the current screen to the clipboard",
plugin = obj.__name,
type = "screen_clipboard"
},
{
text = "Capture Interactive",
subText = "Draw a rectangle to capture",
plugin = obj.__name,
type = "interactive"
},
{
text = "Capture Interactive to Clipboard",
subText = "Draw a rectangle to capture to the clipboard",
plugin = obj.__name,
type = "interactive_clipboard"
}
}
function obj:commands()
return {sc = {
cmd = "sc",
fn = obj.choicesScreenCaptureCommand,
name = "Screencapture",
description = "Capture the screen",
plugin = obj.__name
}
}
end
function obj:bare()
return nil
end
function obj.choicesScreenCaptureCommand(query)
local choices = {}
for k,choice in pairs(static_choices) do
if string.match(choice["text"]:lower(), query:lower()) then
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
local filename = hs.fs.pathToAbsolute("~").."/Desktop/Screen Capture at "..os.date("!%Y-%m-%d-%T")..".png"
local args = ""
local scType = rowInfo["type"]
if scType == "screen" then
-- Nothing required here
elseif scType == "screen_clipboard" then
args = "-c"
elseif scType == "interactive" then
args = "-i"
elseif scType == "screenUI" then
args = "-iU"
elseif scType == "interactive_clipboard" then
args = "-ci"
end
if obj.showPostUI then
args = args .. "u"
end
print(hs.inspect(args))
hs.task.new("/usr/sbin/screencapture", nil, {args, filename}):start()
end
return obj
+125
View File
@@ -0,0 +1,125 @@
--- === Seal.plugins.urlformats ===
---
--- A plugin to quickly open URLs containing a search/query term
--- This plugin is invoked with the `uf` keyword and requires some configuration, see `:providersTable()`
---
--- The way this works is by defining a set of providers, each of which contains a URL with a `%s` somewhere insert it.
--- When the user types `uf` in Seal, followed by some more characters, those characters will be inserted into the string at the point where the `%s` is.
---
--- By way of an example, you could define a provider with a url like `http://bugs.mycorp.com/showBug?id=%s`, and just need to type `uf 123456` in Seal to get a quick shortcut to open the full URL.
local obj = {}
obj.__index = obj
obj.__name = "seal_urlformats"
obj.providers = {}
-- Example format for providers table
-- {
-- rhbz = {
-- name = "Red Hat Bugzilla",
-- url = "https://bugzilla.redhat.com/show_bug.cgi?id=%s",
-- },
-- lp = {
-- name = "Launchpad Bug",
-- url = "https://launchpad.net/bugs/%s",
-- },
-- }
function obj:commands()
return {uf = {
cmd = "uf",
fn = obj.choicesURLPart,
name = "URL Formats",
description = "Open a full URL with a search term",
plugin = obj.__name
}
}
end
function obj:bare()
return obj.choicesBareURL
end
function obj.choicesBareURL(query)
local choices = {}
if string.find(query, "://") ~= nil then
local scheme = string.sub(query, 1, string.find(query, "://") - 1)
local handlers = hs.urlevent.getAllHandlersForScheme(scheme)
for _,bundleID in pairs(handlers) do
local choice = {}
local bundleInfo = hs.application.infoForBundleID(bundleID)
if bundleInfo and bundleInfo["CFBundleName"] then
choice["text"] = "Open URI with "..bundleInfo["CFBundleName"]
choice["handler"] = bundleID
choice["scheme"] = scheme
choice["type"] = "launch"
choice["url"] = query
choice["plugin"] = obj.__name
choice["image"] = hs.image.imageFromAppBundle(bundleID)
table.insert(choices, choice)
end
end
end
return choices
end
function obj.choicesURLPart(query)
--print("choicesURLPart for: "..query)
local choices = {}
for name,data in pairs(obj.providers) do
local data_url = data["url"]:gsub("([^%%])%%([^s])", "%1%%%%%2")
local full_url = string.format(data_url, query)
local url_scheme = string.sub(full_url, 1, string.find(full_url, "://") - 1)
local choice = {}
choice["text"] = data["name"]
choice["subText"] = full_url
choice["plugin"] = obj.__name
choice["type"] = "launch"
choice["url"] = full_url
choice["scheme"] = url_scheme
table.insert(choices, choice)
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "launch" then
local handler = nil
if rowInfo["handler"] == nil then
handler = hs.urlevent.getDefaultHandler(rowInfo["scheme"])
else
handler = rowInfo["handler"]
end
hs.urlevent.openURLWithBundle(rowInfo["url"], handler)
end
end
--- Seal.plugins.urlformats:providersTable(aTable)
--- Method
--- Gets or sets the current providers table
---
--- Parameters:
--- * aTable - An optional table of providers, which must contain the following keys:
--- * name - A string naming the provider, which will be shown in the Seal results
--- * url - A string containing the URL to insert the user's query into. This should contain one and only one `%s`
---
--- Returns:
--- * Either a table of current providers, if no parameter was passed, or nothing if a parmameter was passed.
---
--- Notes:
--- * An example table might look like:
--- ```lua
--- {
--- rhbz = { name = "Red Hat Bugzilla", url = "https://bugzilla.redhat.com/show_bug.cgi?id=%s", },
--- lp = { name = "Launchpad Bug", url = "https://launchpad.net/bugs/%s", },
--- }
--- ```
function obj:providersTable(aTable)
if aTable then
self.providers = aTable
else
return self.providers
end
end
return obj
+286
View File
@@ -0,0 +1,286 @@
--- ==== Seal.plugins.useractions ====
---
--- Allow accessing user-defined bookmarks and arbitrary actions from Seal.
---
local obj = {}
obj.__index = obj
obj.__basename = "useractions"
obj.__name = "seal_" .. obj.__basename
obj.default_icon = hs.image.imageFromName(hs.image.systemImageNames.ActionTemplate)
--- Seal.plugins.useractions.actions
--- Variable
---
--- Notes:
--- * A table containing the definitions of static user-defined actions. Each entry is indexed by the name of the entry as it will be shown in the chooser. Its value is a table which can have the following keys (one of `fn` or `url` is required. If both are provided, `url` is ignored):
--- * fn - A function which will be called when the entry is selected. The function receives no arguments.
--- * url - A URL which will be opened when the entry is selected. Can also be non-HTTP URLs, such as `mailto:` or other app-specific URLs.
--- * description - (optional) A string or `hs.styledtext` object that will be shown underneath the main text of the choice.
--- * icon - (optional) An `hs.image` object that will be shown next to the entry in the chooser. If not provided, `Seal.plugins.useractions.default_icon` is used. For `url` bookmarks, it can be set to `"favicon"` to fetch and use the website's favicon.
--- * keyword - (optional) A command by which this action will be invoked, effectively turning it into a Seal command. Any arguments passed to the command will be handled as follows:
--- * For `fn` actions, passed as an argument to the function
--- * For `url` actions, substituted into the URL, taking the place of any occurrences of `${query}`.
--- * hotkey - (optional) A hotkey specification in the form `{ modifiers, key }` by which this action can be invoked.
--- * Example configuration:
--- ```
--- spoon.Seal:loadPlugins({"useractions"})
--- spoon.Seal.plugins.useractions.actions =
--- {
--- ["Hammerspoon docs webpage"] = {
--- url = "http://hammerspoon.org/docs/",
--- icon = hs.image.imageFromName(hs.image.systemImageNames.ApplicationIcon),
--- description = "Open Hammerspoon documentation",
--- hotkey = { hyper, "h" },
--- },
--- ["Leave corpnet"] = {
--- fn = function()
--- spoon.WiFiTransitions:processTransition('foo', 'corpnet01')
--- end,
--- },
--- ["Arrive in corpnet"] = {
--- fn = function()
--- spoon.WiFiTransitions:processTransition('corpnet01', 'foo')
--- end,
--- },
--- ["Translate using Leo"] = {
--- url = "http://dict.leo.org/ende/index_de.html#/search=${query}",
--- icon = 'favicon',
--- keyword = "leo",
--- },
--- ["Tell me something"] = {
--- keyword = "tellme",
--- fn = function(str) hs.alert.show(str) end,
--- }
--- ```
obj.actions = {}
--- Seal.plugins.useractions.get_favicon
--- Variable
---
--- If `true`, attempt to obtain the favicon for URLs added through the `add` command, and use it in the chooser. Defaults to `true`
obj.get_favicon = true
-- Internal functions for storing/retrieving bookmarks in the settings database.
local getSetting = function(label, default) return hs.settings.get(obj.__name.."."..label) or default end
local setSetting = function(label, value) hs.settings.set(obj.__name.."."..label, value); return value end
-- Internal variable where the dynamically-added bookmarks are kept
obj.stored_actions = getSetting('stored_actions', {})
-- Internal variable where the merged list of bookmarks/actions is kept
obj.all_actions = nil
function update_all_actions()
if (obj.all_actions == nil) then
obj.all_actions = {}
for k,v in pairs(obj.actions) do obj.all_actions[k] = hs.fnutils.copy(v) end
for k,v in pairs(obj.stored_actions) do
obj.all_actions[k] = hs.fnutils.copy(v)
if v.encoded_icon then
obj.all_actions[k].icon = hs.image.imageFromURL(v.encoded_icon)
end
end
end
end
function obj:commands()
local cmds={
add = {
cmd = "add",
fn = obj.choicesAddURLCommand,
name = "Add URL",
description = "Add URL to bookmarks",
plugin = obj.__name
},
del = {
cmd = "del",
fn = obj.choicesDelURLCommand,
name = "Delete URL",
description = "Delete URL from bookmarks",
plugin = obj.__name
}
}
local hotkeys_def = {}
local hotkeys_map = {}
local any_hotkeys = false
for k,v in pairs(self.actions or {}) do
if v.keyword and (not cmds[v.keyword]) then
if v.url ~= nil and v.icon == 'favicon' then
v.icon = obj.favIcon(v.url)
end
cmds[v.keyword] = {
cmd = v.keyword,
fn = hs.fnutils.partial(obj.choicesActionKeyword, k, v),
name = k,
icon = v.icon,
plugin = obj.__name
}
end
if v.hotkey then
local choice = obj.buildChoice(k,v)
hotkeys_def[k] = hs.fnutils.partial(obj.completionCallback, choice)
hotkeys_map[k] = v.hotkey
any_hotkeys = true
end
end
if any_hotkeys then
hs.spoons.bindHotkeysToSpec(hotkeys_def, hotkeys_map)
end
return cmds
end
function obj:bare()
return self.bareActions
end
function obj.buildChoice(action, v)
local icon, kind
local choice=nil
if type(v) == 'table' then
if v.fn then
kind = 'runFunction'
elseif v.url then
kind = 'openURL'
if v.icon == 'favicon' then
v.icon = obj.favIcon(v.url)
end
end
icon = v.icon or obj.default_icon
choice = {}
choice.text = action
choice.type = kind
choice.plugin = obj.__name
choice.image = icon
if v.description then
choice.subText = v.description
end
end
return choice
end
function obj.bareActions(query)
local choices = {}
if query == nil or query == "" then
return choices
end
update_all_actions()
obj.seal:refreshCommandsForPlugin(obj.__basename)
for action,v in pairs(obj.all_actions) do
if string.match(action:lower(), query:lower()) then
local choice = obj.buildChoice(action, v)
if choice then
table.insert(choices, choice)
end
end
end
return choices
end
function obj.favIcon(url)
local query=string.format("http://www.google.com/s2/favicons?sz=64&domain_url=%s", hs.http.encodeForQuery(url))
return hs.image.imageFromURL(query)
end
function obj.choicesAddURLCommand(query)
local choices = {}
if query == ".*" then
query = "<url> <name>"
end
local url,name = string.match(query, "([^%s]+)%s+(.*)")
local subtext = ""
if url then
subtext = string.format("New bookmark '%s' pointing to %s", name,url)
end
local choice = {
text = "add " .. query,
subText = subtext,
url = url,
name = name,
plugin = obj.__name,
type = 'addURL',
}
table.insert(choices, choice)
return choices
end
function obj.choicesDelURLCommand(query)
local choices = {}
for k,v in pairs(obj.stored_actions) do
if string.match(k:lower(), query:lower()) or string.match(v.url:lower(), query:lower()) then
local choice = {
text = string.format("delete '%s'", k),
subText = v.url,
delKey = k,
plugin = obj.__name,
type = 'delURL',
}
if v.encoded_icon then
choice.image = hs.image.imageFromURL(v.encoded_icon)
end
table.insert(choices, choice)
end
end
return choices
end
function obj.choicesActionKeyword(action, def, query)
local choices = {}
if query == ".*" then
query = ""
end
local choice = {
text = def.keyword .. " " .. query,
subText = def.description or action,
actionname = action,
arg = query,
plugin = obj.__name,
image = def.icon,
type = 'invokeKeyword',
}
table.insert(choices, choice)
return choices
end
function obj.openURL(url)
hs.execute(string.format("/usr/bin/open '%s'", url))
end
function obj.completionCallback(row)
update_all_actions()
if row.type == 'runFunction' then
local fn = obj.all_actions[row.text].fn
fn()
elseif row.type == 'openURL' then
local url = obj.all_actions[row.text].url
obj.openURL(url)
elseif row.type == 'addURL' then
obj.stored_actions[row.name] = { url = row.url }
obj.all_actions = nil
if obj.get_favicon then
local ico=obj.favIcon(row.url)
if ico then
obj.stored_actions[row.name]['encoded_icon'] = ico:encodeAsURLString()
end
end
setSetting('stored_actions', obj.stored_actions)
elseif row.type == 'delURL' then
obj.stored_actions[row.delKey] = nil
obj.all_actions = nil
setSetting('stored_actions', obj.stored_actions)
elseif row.type == 'invokeKeyword' then
if obj.actions[row.actionname].fn then
obj.actions[row.actionname].fn(row.arg)
elseif obj.actions[row.actionname].url then
row.arg = hs.http.encodeForQuery(row.arg)
local query = row.arg:gsub("%%", "%%%%")
local url = string.gsub(obj.actions[row.actionname].url, '${query}', query)
obj.openURL(url)
end
end
end
return obj
+266
View File
@@ -0,0 +1,266 @@
local obj = {}
obj.__index = obj
obj.__name = "seal_vpn"
function obj:commands()
return {vpn = {
cmd = "vpn",
fn = obj.choicesVPNCommand,
name = "VPN",
description = "Manage VPN connections",
plugin = obj.__name
}
}
end
function obj:bare()
return nil
end
function obj.getVPNConnections()
connections = {}
code, output, descriptor = hs.osascript.applescript([[
set output to ""
on join_list(the_list, delimiter)
set the_string to ""
set old_delims to AppleScript's text item delimiters
repeat with the_item in the_list
if the_string is equal to "" then
set the_string to the_string & the_item
else
set the_string to the_string & delimiter & the_item
end if
end repeat
set AppleScript's text item delimiters to old_delims
return the_string
end join_list
set vpn_connections to {}
tell application "Viscosity"
repeat with the_connection in connections
set the end of vpn_connections to (name of the_connection) & tab & (state of the_connection) & tab & "Viscosity"
end repeat
end tell
tell application "System Events"
tell current location of network preferences
repeat with vpn in (every service whose (kind is greater than 10 and kind is less than 17))
set state to "Disconnected"
if connected of current configuration of vpn is equal to true then
set state to "Connected"
end if
set the end of vpn_connections to (name of vpn) & tab & state & tab & "macOS"
end repeat
end tell
end tell
return my join_list(vpn_connections, linefeed)
]])
if code == false or output == nil or output == "" then
return connections
end
for line in output:gmatch("[^\r\n]+") do
parts = {}
for part in line:gmatch("%S+") do
table.insert(parts, part)
end
kind = parts[#parts]
table.remove(parts, #parts)
state = parts[#parts]
table.remove(parts, #parts)
name = table.concat(parts, " ")
table.insert(connections, {name=name, state=state, kind=kind})
end
return connections
end
function obj.disconnectVPN(name, kind)
if kind == "Viscosity" then
obj.disconnectViscosity(name)
elseif kind == "macOS" then
obj.disconnectMacOS(name)
end
end
function obj.disconnectViscosity(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "Viscosity"
repeat with the_connection in connections
if (name of the_connection) is equal to vpn_name then
if (state of the_connection) is equal to "Connected" then
return true
else
return false
end if
end if
end repeat
end tell
return false
end vpn_is_active
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
tell application "Viscosity"
disconnect vpn_name
end tell
end if
end run
]], name))
end
function obj.disconnectMacOS(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "System Events"
tell current location of network preferences
return connected of current configuration of service vpn_name
end tell
end tell
end vpn_is_active
on connect_vpn(vpn_name)
tell application "System Events"
tell current location of network preferences
connect service vpn_name
end tell
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
tell application "System Events"
tell current location of network preferences
disconnect service vpn_name
end tell
end tell
end if
end run
]], name))
end
function obj.connectVPN(name, kind)
if kind == "Viscosity" then
obj.connectViscosity(name)
elseif kind == "macOS" then
obj.connectMacOS(name)
end
end
function obj.connectViscosity(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "Viscosity"
repeat with the_connection in connections
if (name of the_connection) is equal to vpn_name then
if (state of the_connection) is equal to "Connected" then
return true
else
return false
end if
end if
end repeat
end tell
return false
end vpn_is_active
-- Connect to specified VPN
on connect_vpn(vpn_name)
tell application "Viscosity"
connect vpn_name
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
log "VPN " & quote & vpn_name & quote & " is already active."
return
end if
connect_vpn(vpn_name)
end run
]], name))
end
function obj.connectMacOS(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "System Events"
tell current location of network preferences
return connected of current configuration of service vpn_name
end tell
end tell
end vpn_is_active
-- Connect to specified VPN
on connect_vpn(vpn_name)
tell application "System Events"
tell current location of network preferences
connect service vpn_name
end tell
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
log "VPN " & quote & vpn_name & quote & " is already active."
return
end if
connect_vpn(vpn_name)
end run
]], name))
end
function obj.choicesVPNCommand(query)
local choices = {}
local connections = obj.getVPNConnections()
local img_connected = hs.image.imageFromPath(obj.seal.spoonPath.."/viscosity_locked.png")
local img_disconnected = hs.image.imageFromPath(obj.seal.spoonPath.."/viscosity_unlocked.png")
for k,v in pairs(connections) do
name = v["name"]
if string.match(name:lower(), query:lower()) then
state = v["state"]
kind = v["kind"]
local choice = {}
choice["text"] = name
choice["subText"] = state .. " (" .. kind .. ")"
if state == "Connected" then
choice["image"] = img_connected
else
choice["image"] = img_disconnected
end
choice["name"] = name
choice["state"] = state
choice["kind"] = kind
choice["plugin"] = obj.__name
choice["type"] = "toggle"
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "toggle" then
if rowInfo["state"] == "Connected" then
obj.disconnectVPN(rowInfo["name"], rowInfo["kind"])
else
obj.connectVPN(rowInfo["name"], rowInfo["kind"])
end
end
end
return obj
+266
View File
@@ -0,0 +1,266 @@
local obj = {}
obj.__index = obj
obj.__name = "seal_vpn"
function obj:commands()
return {vpn = {
cmd = "vpn",
fn = obj.choicesVPNCommand,
name = "VPN",
description = "Manage VPN connections",
plugin = obj.__name
}
}
end
function obj:bare()
return nil
end
function obj.getVPNConnections()
connections = {}
code, output, descriptor = hs.osascript.applescript([[
set output to ""
on join_list(the_list, delimiter)
set the_string to ""
set old_delims to AppleScript's text item delimiters
repeat with the_item in the_list
if the_string is equal to "" then
set the_string to the_string & the_item
else
set the_string to the_string & delimiter & the_item
end if
end repeat
set AppleScript's text item delimiters to old_delims
return the_string
end join_list
set vpn_connections to {}
tell application "Viscosity"
repeat with the_connection in connections
set the end of vpn_connections to (name of the_connection) & tab & (state of the_connection) & tab & "Viscosity"
end repeat
end tell
tell application "System Events"
tell current location of network preferences
repeat with vpn in (every service whose (kind is greater than 10 and kind is less than 17))
set state to "Disconnected"
if connected of current configuration of vpn is equal to true then
set state to "Connected"
end if
set the end of vpn_connections to (name of vpn) & tab & state & tab & "macOS"
end repeat
end tell
end tell
return my join_list(vpn_connections, linefeed)
]])
if code == false or output == nil or output == "" then
return connections
end
for line in output:gmatch("[^\r\n]+") do
parts = {}
for part in line:gmatch("%S+") do
table.insert(parts, part)
end
kind = parts[#parts]
table.remove(parts, #parts)
state = parts[#parts]
table.remove(parts, #parts)
name = table.concat(parts, " ")
table.insert(connections, {name=name, state=state, kind=kind})
end
return connections
end
function obj.disconnectVPN(name, kind)
if kind == "Viscosity" then
obj.disconnectViscosity(name)
elseif kind == "macOS" then
obj.disconnectMacOS(name)
end
end
function obj.disconnectViscosity(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "Viscosity"
repeat with the_connection in connections
if (name of the_connection) is equal to vpn_name then
if (state of the_connection) is equal to "Connected" then
return true
else
return false
end if
end if
end repeat
end tell
return false
end vpn_is_active
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
tell application "Viscosity"
disconnect vpn_name
end tell
end if
end run
]], name))
end
function obj.disconnectMacOS(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "System Events"
tell current location of network preferences
return connected of current configuration of service vpn_name
end tell
end tell
end vpn_is_active
on connect_vpn(vpn_name)
tell application "System Events"
tell current location of network preferences
connect service vpn_name
end tell
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
tell application "System Events"
tell current location of network preferences
disconnect service vpn_name
end tell
end tell
end if
end run
]], name))
end
function obj.connectVPN(name, kind)
if kind == "Viscosity" then
obj.connectViscosity(name)
elseif kind == "macOS" then
obj.connectMacOS(name)
end
end
function obj.connectViscosity(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "Viscosity"
repeat with the_connection in connections
if (name of the_connection) is equal to vpn_name then
if (state of the_connection) is equal to "Connected" then
return true
else
return false
end if
end if
end repeat
end tell
return false
end vpn_is_active
-- Connect to specified VPN
on connect_vpn(vpn_name)
tell application "Viscosity"
connect vpn_name
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
log "VPN " & quote & vpn_name & quote & " is already active."
return
end if
connect_vpn(vpn_name)
end run
]], name))
end
function obj.connectMacOS(name)
code, output, descriptor = hs.osascript.applescript(string.format([[
-- Return true if VPN is active
on vpn_is_active(vpn_name)
tell application "System Events"
tell current location of network preferences
return connected of current configuration of service vpn_name
end tell
end tell
end vpn_is_active
-- Connect to specified VPN
on connect_vpn(vpn_name)
tell application "System Events"
tell current location of network preferences
connect service vpn_name
end tell
end tell
end connect_vpn
on run argv
set vpn_name to "%s"
if my vpn_is_active(vpn_name) then
log "VPN " & quote & vpn_name & quote & " is already active."
return
end if
connect_vpn(vpn_name)
end run
]], name))
end
function obj.choicesVPNCommand(query)
local choices = {}
local connections = obj.getVPNConnections()
local img_connected = hs.image.imageFromPath(obj.seal.spoonPath.."/viscosity_locked.png")
local img_disconnected = hs.image.imageFromPath(obj.seal.spoonPath.."/viscosity_unlocked.png")
for k,v in pairs(connections) do
name = v["name"]
if string.match(name:lower(), query:lower()) then
state = v["state"]
kind = v["kind"]
local choice = {}
choice["text"] = name
choice["subText"] = state .. " (" .. kind .. ")"
if state == "Connected" then
choice["image"] = img_connected
else
choice["image"] = img_disconnected
end
choice["name"] = name
choice["state"] = state
choice["kind"] = kind
choice["plugin"] = obj.__name
choice["type"] = "toggle"
table.insert(choices, choice)
end
end
return choices
end
function obj.completionCallback(rowInfo)
if rowInfo["type"] == "toggle" then
if rowInfo["state"] == "Connected" then
obj.disconnectVPN(rowInfo["name"], rowInfo["kind"])
else
obj.connectVPN(rowInfo["name"], rowInfo["kind"])
end
end
end
return obj
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+39
View File
@@ -0,0 +1,39 @@
[
{
"Command": [],
"Constant": [],
"Constructor": [],
"Deprecated": [],
"Field": [],
"Function": [],
"Method": [
{
"def": "SpeedMenu:rescan()",
"desc": "Redetect the active interface, darkmode …And redraw everything.",
"doc": "Redetect the active interface, darkmode …And redraw everything.\n",
"name": "rescan",
"signature": "SpeedMenu:rescan()",
"stripped_doc": "",
"type": "Method"
}
],
"Variable": [],
"desc": "Menubar netspeed meter",
"doc": "Menubar netspeed meter\n\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip)",
"items": [
{
"def": "SpeedMenu:rescan()",
"desc": "Redetect the active interface, darkmode …And redraw everything.",
"doc": "Redetect the active interface, darkmode …And redraw everything.\n",
"name": "rescan",
"signature": "SpeedMenu:rescan()",
"stripped_doc": "",
"type": "Method"
}
],
"name": "SpeedMenu",
"stripped_doc": "\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip)",
"submodules": [],
"type": "Module"
}
]
+128
View File
@@ -0,0 +1,128 @@
--- === SpeedMenu ===
---
--- Menubar netspeed meter
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpeedMenu.spoon.zip)
local obj={}
obj.__index = obj
-- Metadata
obj.name = "SpeedMenu"
obj.version = "1.0"
obj.author = "ashfinal <ashfinal@gmail.com>"
obj.homepage = "https://github.com/Hammerspoon/Spoons"
obj.license = "MIT - https://opensource.org/licenses/MIT"
function obj:init()
self.menubar = hs.menubar.new(false)
end
function obj:start()
obj.menubar:returnToMenuBar()
obj:rescan()
end
function obj:stop()
obj.menubar:removeFromMenuBar()
obj.timer:stop()
end
function obj:toggle()
if obj.timer:running() then
obj:stop()
else
obj:start()
end
end
local function data_diff()
local in_seq = hs.execute(obj.instr)
local out_seq = hs.execute(obj.outstr)
local in_diff = in_seq - obj.inseq
local out_diff = out_seq - obj.outseq
if in_diff/1024 > 1024 then
obj.kbin = string.format("%6.2f", in_diff/1024/1024) .. ' mb/s'
else
obj.kbin = string.format("%6.2f", in_diff/1024) .. ' kb/s'
end
if out_diff/1024 > 1024 then
obj.kbout = string.format("%6.2f", out_diff/1024/1024) .. ' mb/s'
else
obj.kbout = string.format("%6.2f", out_diff/1024) .. ' kb/s'
end
local disp_str = '' .. obj.kbout .. '\n' .. obj.kbin
if obj.darkmode then
obj.disp_str = hs.styledtext.new(disp_str, {font={size=9.0, color={hex="#FFFFFF"}}})
else
obj.disp_str = hs.styledtext.new(disp_str, {font={size=9.0, color={hex="#000000"}}})
end
obj.menubar:setTitle(obj.disp_str)
obj.inseq = in_seq
obj.outseq = out_seq
end
--- SpeedMenu:rescan()
--- Method
--- Redetect the active interface, darkmode …And redraw everything.
---
function obj:rescan()
obj.interface = hs.network.primaryInterfaces()
obj.darkmode = hs.osascript.applescript('tell application "System Events"\nreturn dark mode of appearance preferences\nend tell')
local menuitems_table = {}
if obj.interface then
-- Inspect active interface and create menuitems
local interface_detail = hs.network.interfaceDetails(obj.interface)
if interface_detail.AirPort then
local ssid = interface_detail.AirPort.SSID
table.insert(menuitems_table, {
title = "SSID: " .. ssid,
tooltip = "Copy SSID to clipboard",
fn = function() hs.pasteboard.setContents(ssid) end
})
end
if interface_detail.IPv4 then
local ipv4 = interface_detail.IPv4.Addresses[1]
table.insert(menuitems_table, {
title = "IPv4: " .. ipv4,
tooltip = "Copy IPv4 to clipboard",
fn = function() hs.pasteboard.setContents(ipv4) end
})
end
if interface_detail.IPv6 then
local ipv6 = interface_detail.IPv6.Addresses[1]
table.insert(menuitems_table, {
title = "IPv6: " .. ipv6,
tooltip = "Copy IPv6 to clipboard",
fn = function() hs.pasteboard.setContents(ipv6) end
})
end
local macaddr = hs.execute('ifconfig ' .. obj.interface .. ' | grep ether | awk \'{print $2}\'')
table.insert(menuitems_table, {
title = "MAC Addr: " .. macaddr,
tooltip = "Copy MAC Address to clipboard",
fn = function() hs.pasteboard.setContents(macaddr) end
})
-- Start watching the netspeed delta
obj.instr = 'netstat -ibn | grep -e ' .. obj.interface .. ' -m 1 | awk \'{print $7}\''
obj.outstr = 'netstat -ibn | grep -e ' .. obj.interface .. ' -m 1 | awk \'{print $10}\''
obj.inseq = hs.execute(obj.instr)
obj.outseq = hs.execute(obj.outstr)
if obj.timer then
obj.timer:stop()
obj.timer = nil
end
obj.timer = hs.timer.doEvery(1, data_diff)
end
table.insert(menuitems_table, {
title = "Rescan Network Interfaces",
fn = function() obj:rescan() end
})
obj.menubar:setTitle("⚠︎")
obj.menubar:setMenu(menuitems_table)
end
return obj
+478
View File
@@ -0,0 +1,478 @@
[
{
"Command": [],
"Constant": [],
"Constructor": [],
"Deprecated": [],
"Field": [],
"Function": [],
"Method": [
{
"def": "SpoonInstall:andUse(name, arg)",
"desc": "Declaratively install, load and configure a Spoon",
"doc": "Declaratively install, load and configure a Spoon\n\nParameters:\n * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.\n * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):\n * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `\"default\"`.\n * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.\n * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `\"default\"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.\n * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.\n * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.\n * start - if `true`, call the Spoon's `start()` method after configuring everything else.\n * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon.\n\nReturns:\n * None",
"name": "andUse",
"parameters": [
" * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.",
" * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):",
" * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `\"default\"`.",
" * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.",
" * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `\"default\"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.",
" * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.",
" * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.",
" * start - if `true`, call the Spoon's `start()` method after configuring everything else.",
" * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon."
],
"returns": [
" * None"
],
"signature": "SpoonInstall:andUse(name, arg)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)",
"desc": "Asynchronously install a Spoon from a registered repository",
"doc": "Asynchronously install a Spoon from a registered repository\n\nParameters:\n * name - Name of the Spoon to install.\n * repo - Name of the repository to use. Defaults to `\"default\"`\n * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:\n * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file\n * success - boolean indicating whether the installation was successful\n\nReturns:\n * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise.",
"name": "asyncInstallSpoonFromRepo",
"parameters": [
" * name - Name of the Spoon to install.",
" * repo - Name of the repository to use. Defaults to `\"default\"`",
" * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:",
" * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file",
" * success - boolean indicating whether the installation was successful"
],
"returns": [
" * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise."
],
"signature": "SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)",
"desc": "Asynchronously download a Spoon zip file and install it.",
"doc": "Asynchronously download a Spoon zip file and install it.\n\nParameters:\n * url - URL of the zip file to install.\n * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:\n * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file\n * success - boolean indicating whether the installation was successful\n\nReturns:\n * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise",
"name": "asyncInstallSpoonFromZipURL",
"parameters": [
" * url - URL of the zip file to install.",
" * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:",
" * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file",
" * success - boolean indicating whether the installation was successful"
],
"returns": [
" * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise"
],
"signature": "SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncUpdateAllRepos()",
"desc": "Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`",
"doc": "Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`\n\nParameters:\n * None\n\nReturns:\n * None\n\nNotes:\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "asyncUpdateAllRepos",
"notes": [
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * None"
],
"returns": [
" * None"
],
"signature": "SpoonInstall:asyncUpdateAllRepos()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncUpdateRepo(repo, callback)",
"desc": "Asynchronously fetch the information about the contents of a Spoon repository",
"doc": "Asynchronously fetch the information about the contents of a Spoon repository\n\nParameters:\n * repo - name of the repository to update. Defaults to `\"default\"`.\n * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:\n * repo - name of the repository\n * success - boolean indicating whether the update succeeded\n\nReturns:\n * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise\n\nNotes:\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "asyncUpdateRepo",
"notes": [
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * repo - name of the repository to update. Defaults to `\"default\"`.",
" * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:",
" * repo - name of the repository",
" * success - boolean indicating whether the update succeeded"
],
"returns": [
" * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise"
],
"signature": "SpoonInstall:asyncUpdateRepo(repo, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:installSpoonFromRepo(name, repo)",
"desc": "Synchronously install a Spoon from a registered repository",
"doc": "Synchronously install a Spoon from a registered repository\n\nParameters:\n * name = Name of the Spoon to install.\n * repo - Name of the repository to use. Defaults to `\"default\"`\n\nReturns:\n * `true` if the installation was successful, `nil` otherwise.",
"name": "installSpoonFromRepo",
"parameters": [
" * name = Name of the Spoon to install.",
" * repo - Name of the repository to use. Defaults to `\"default\"`"
],
"returns": [
" * `true` if the installation was successful, `nil` otherwise."
],
"signature": "SpoonInstall:installSpoonFromRepo(name, repo)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:installSpoonFromZipURL(url)",
"desc": "Synchronously download a Spoon zip file and install it.",
"doc": "Synchronously download a Spoon zip file and install it.\n\nParameters:\n * url - URL of the zip file to install.\n\nReturns:\n * `true` if the installation was successful, `nil` otherwise",
"name": "installSpoonFromZipURL",
"parameters": [
" * url - URL of the zip file to install."
],
"returns": [
" * `true` if the installation was successful, `nil` otherwise"
],
"signature": "SpoonInstall:installSpoonFromZipURL(url)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:repolist()",
"desc": "Return a sorted list of registered Spoon repositories",
"doc": "Return a sorted list of registered Spoon repositories\n\nParameters:\n * None\n\nReturns:\n * Table containing a list of strings with the repository identifiers",
"name": "repolist",
"parameters": [
" * None"
],
"returns": [
" * Table containing a list of strings with the repository identifiers"
],
"signature": "SpoonInstall:repolist()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:search(pat)",
"desc": "Search repositories for a pattern",
"doc": "Search repositories for a pattern\n\nParameters:\n * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern.\n\nReturns:\n * Table containing a list of matching entries. Each entry is a table with the following keys:\n * name - Spoon name\n * desc - description of the spoon\n * repo - identifier in the repository where the match was found",
"name": "search",
"parameters": [
" * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern."
],
"returns": [
" * Table containing a list of matching entries. Each entry is a table with the following keys:",
" * name - Spoon name",
" * desc - description of the spoon",
" * repo - identifier in the repository where the match was found"
],
"signature": "SpoonInstall:search(pat)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:updateAllRepos()",
"desc": "Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`",
"doc": "Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`\n\nParameters:\n * None\n\nReturns:\n * None\n\nNotes:\n * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "updateAllRepos",
"notes": [
" * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.",
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * None"
],
"returns": [
" * None"
],
"signature": "SpoonInstall:updateAllRepos()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:updateRepo(repo)",
"desc": "Synchronously fetch the information about the contents of a Spoon repository",
"doc": "Synchronously fetch the information about the contents of a Spoon repository\n\nParameters:\n * repo - name of the repository to update. Defaults to `\"default\"`.\n\nReturns:\n * `true` if the update was successful, `nil` otherwise\n\nNotes:\n * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "updateRepo",
"notes": [
" * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.",
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * repo - name of the repository to update. Defaults to `\"default\"`."
],
"returns": [
" * `true` if the update was successful, `nil` otherwise"
],
"signature": "SpoonInstall:updateRepo(repo)",
"stripped_doc": "",
"type": "Method"
}
],
"Variable": [
{
"def": "SpoonInstall.logger",
"desc": "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.",
"doc": "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.",
"name": "logger",
"signature": "SpoonInstall.logger",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "SpoonInstall.repos",
"desc": "Table containing the list of available Spoon repositories. The key",
"doc": "Table containing the list of available Spoon repositories. The key\nof each entry is an identifier for the repository, and its value\nis a table with the following entries:\n * desc - Human-readable description for the repository\n * branch - Active git branch for the Spoon files\n * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.\n\nDefault value:\n```\n{\n default = {\n url = \"https://github.com/Hammerspoon/Spoons\",\n desc = \"Main Hammerspoon Spoon repository\",\n branch = \"master\",\n }\n}\n```",
"name": "repos",
"signature": "SpoonInstall.repos",
"stripped_doc": "of each entry is an identifier for the repository, and its value\nis a table with the following entries:\n * desc - Human-readable description for the repository\n * branch - Active git branch for the Spoon files\n * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.\nDefault value:\n```\n{\n default = {\n url = \"https://github.com/Hammerspoon/Spoons\",\n desc = \"Main Hammerspoon Spoon repository\",\n branch = \"master\",\n }\n}\n```",
"type": "Variable"
},
{
"def": "SpoonInstall.use_syncinstall",
"desc": "If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.",
"doc": "If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.\n\nKeep in mind that if you set this to `true`, Hammerspoon will\nblock until all missing Spoons are installed, but the notifications\nwill happen at a more \"human readable\" rate.",
"name": "use_syncinstall",
"signature": "SpoonInstall.use_syncinstall",
"stripped_doc": "Keep in mind that if you set this to `true`, Hammerspoon will\nblock until all missing Spoons are installed, but the notifications\nwill happen at a more \"human readable\" rate.",
"type": "Variable"
}
],
"desc": "Install and manage Spoons and Spoon repositories",
"doc": "Install and manage Spoons and Spoon repositories\n\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip)",
"items": [
{
"def": "SpoonInstall:andUse(name, arg)",
"desc": "Declaratively install, load and configure a Spoon",
"doc": "Declaratively install, load and configure a Spoon\n\nParameters:\n * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.\n * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):\n * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `\"default\"`.\n * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.\n * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `\"default\"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.\n * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.\n * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.\n * start - if `true`, call the Spoon's `start()` method after configuring everything else.\n * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon.\n\nReturns:\n * None",
"name": "andUse",
"parameters": [
" * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.",
" * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):",
" * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `\"default\"`.",
" * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.",
" * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `\"default\"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.",
" * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.",
" * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.",
" * start - if `true`, call the Spoon's `start()` method after configuring everything else.",
" * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon."
],
"returns": [
" * None"
],
"signature": "SpoonInstall:andUse(name, arg)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)",
"desc": "Asynchronously install a Spoon from a registered repository",
"doc": "Asynchronously install a Spoon from a registered repository\n\nParameters:\n * name - Name of the Spoon to install.\n * repo - Name of the repository to use. Defaults to `\"default\"`\n * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:\n * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file\n * success - boolean indicating whether the installation was successful\n\nReturns:\n * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise.",
"name": "asyncInstallSpoonFromRepo",
"parameters": [
" * name - Name of the Spoon to install.",
" * repo - Name of the repository to use. Defaults to `\"default\"`",
" * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:",
" * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file",
" * success - boolean indicating whether the installation was successful"
],
"returns": [
" * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise."
],
"signature": "SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)",
"desc": "Asynchronously download a Spoon zip file and install it.",
"doc": "Asynchronously download a Spoon zip file and install it.\n\nParameters:\n * url - URL of the zip file to install.\n * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:\n * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file\n * success - boolean indicating whether the installation was successful\n\nReturns:\n * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise",
"name": "asyncInstallSpoonFromZipURL",
"parameters": [
" * url - URL of the zip file to install.",
" * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:",
" * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file",
" * success - boolean indicating whether the installation was successful"
],
"returns": [
" * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise"
],
"signature": "SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncUpdateAllRepos()",
"desc": "Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`",
"doc": "Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`\n\nParameters:\n * None\n\nReturns:\n * None\n\nNotes:\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "asyncUpdateAllRepos",
"notes": [
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * None"
],
"returns": [
" * None"
],
"signature": "SpoonInstall:asyncUpdateAllRepos()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:asyncUpdateRepo(repo, callback)",
"desc": "Asynchronously fetch the information about the contents of a Spoon repository",
"doc": "Asynchronously fetch the information about the contents of a Spoon repository\n\nParameters:\n * repo - name of the repository to update. Defaults to `\"default\"`.\n * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:\n * repo - name of the repository\n * success - boolean indicating whether the update succeeded\n\nReturns:\n * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise\n\nNotes:\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "asyncUpdateRepo",
"notes": [
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * repo - name of the repository to update. Defaults to `\"default\"`.",
" * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:",
" * repo - name of the repository",
" * success - boolean indicating whether the update succeeded"
],
"returns": [
" * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise"
],
"signature": "SpoonInstall:asyncUpdateRepo(repo, callback)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:installSpoonFromRepo(name, repo)",
"desc": "Synchronously install a Spoon from a registered repository",
"doc": "Synchronously install a Spoon from a registered repository\n\nParameters:\n * name = Name of the Spoon to install.\n * repo - Name of the repository to use. Defaults to `\"default\"`\n\nReturns:\n * `true` if the installation was successful, `nil` otherwise.",
"name": "installSpoonFromRepo",
"parameters": [
" * name = Name of the Spoon to install.",
" * repo - Name of the repository to use. Defaults to `\"default\"`"
],
"returns": [
" * `true` if the installation was successful, `nil` otherwise."
],
"signature": "SpoonInstall:installSpoonFromRepo(name, repo)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:installSpoonFromZipURL(url)",
"desc": "Synchronously download a Spoon zip file and install it.",
"doc": "Synchronously download a Spoon zip file and install it.\n\nParameters:\n * url - URL of the zip file to install.\n\nReturns:\n * `true` if the installation was successful, `nil` otherwise",
"name": "installSpoonFromZipURL",
"parameters": [
" * url - URL of the zip file to install."
],
"returns": [
" * `true` if the installation was successful, `nil` otherwise"
],
"signature": "SpoonInstall:installSpoonFromZipURL(url)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall.logger",
"desc": "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.",
"doc": "Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.",
"name": "logger",
"signature": "SpoonInstall.logger",
"stripped_doc": "",
"type": "Variable"
},
{
"def": "SpoonInstall:repolist()",
"desc": "Return a sorted list of registered Spoon repositories",
"doc": "Return a sorted list of registered Spoon repositories\n\nParameters:\n * None\n\nReturns:\n * Table containing a list of strings with the repository identifiers",
"name": "repolist",
"parameters": [
" * None"
],
"returns": [
" * Table containing a list of strings with the repository identifiers"
],
"signature": "SpoonInstall:repolist()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall.repos",
"desc": "Table containing the list of available Spoon repositories. The key",
"doc": "Table containing the list of available Spoon repositories. The key\nof each entry is an identifier for the repository, and its value\nis a table with the following entries:\n * desc - Human-readable description for the repository\n * branch - Active git branch for the Spoon files\n * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.\n\nDefault value:\n```\n{\n default = {\n url = \"https://github.com/Hammerspoon/Spoons\",\n desc = \"Main Hammerspoon Spoon repository\",\n branch = \"master\",\n }\n}\n```",
"name": "repos",
"signature": "SpoonInstall.repos",
"stripped_doc": "of each entry is an identifier for the repository, and its value\nis a table with the following entries:\n * desc - Human-readable description for the repository\n * branch - Active git branch for the Spoon files\n * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.\nDefault value:\n```\n{\n default = {\n url = \"https://github.com/Hammerspoon/Spoons\",\n desc = \"Main Hammerspoon Spoon repository\",\n branch = \"master\",\n }\n}\n```",
"type": "Variable"
},
{
"def": "SpoonInstall:search(pat)",
"desc": "Search repositories for a pattern",
"doc": "Search repositories for a pattern\n\nParameters:\n * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern.\n\nReturns:\n * Table containing a list of matching entries. Each entry is a table with the following keys:\n * name - Spoon name\n * desc - description of the spoon\n * repo - identifier in the repository where the match was found",
"name": "search",
"parameters": [
" * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern."
],
"returns": [
" * Table containing a list of matching entries. Each entry is a table with the following keys:",
" * name - Spoon name",
" * desc - description of the spoon",
" * repo - identifier in the repository where the match was found"
],
"signature": "SpoonInstall:search(pat)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:updateAllRepos()",
"desc": "Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`",
"doc": "Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`\n\nParameters:\n * None\n\nReturns:\n * None\n\nNotes:\n * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "updateAllRepos",
"notes": [
" * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.",
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * None"
],
"returns": [
" * None"
],
"signature": "SpoonInstall:updateAllRepos()",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall:updateRepo(repo)",
"desc": "Synchronously fetch the information about the contents of a Spoon repository",
"doc": "Synchronously fetch the information about the contents of a Spoon repository\n\nParameters:\n * repo - name of the repository to update. Defaults to `\"default\"`.\n\nReturns:\n * `true` if the update was successful, `nil` otherwise\n\nNotes:\n * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.\n * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.",
"name": "updateRepo",
"notes": [
" * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.",
" * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions."
],
"parameters": [
" * repo - name of the repository to update. Defaults to `\"default\"`."
],
"returns": [
" * `true` if the update was successful, `nil` otherwise"
],
"signature": "SpoonInstall:updateRepo(repo)",
"stripped_doc": "",
"type": "Method"
},
{
"def": "SpoonInstall.use_syncinstall",
"desc": "If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.",
"doc": "If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.\n\nKeep in mind that if you set this to `true`, Hammerspoon will\nblock until all missing Spoons are installed, but the notifications\nwill happen at a more \"human readable\" rate.",
"name": "use_syncinstall",
"signature": "SpoonInstall.use_syncinstall",
"stripped_doc": "Keep in mind that if you set this to `true`, Hammerspoon will\nblock until all missing Spoons are installed, but the notifications\nwill happen at a more \"human readable\" rate.",
"type": "Variable"
}
],
"name": "SpoonInstall",
"stripped_doc": "\nDownload: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip)",
"submodules": [],
"type": "Module"
}
]
+447
View File
@@ -0,0 +1,447 @@
--- === SpoonInstall ===
---
--- Install and manage Spoons and Spoon repositories
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip)
local obj={}
obj.__index = obj
-- Metadata
obj.name = "SpoonInstall"
obj.version = "0.1"
obj.author = "Diego Zamboni <diego@zzamboni.org>"
obj.homepage = "https://github.com/Hammerspoon/Spoons"
obj.license = "MIT - https://opensource.org/licenses/MIT"
--- SpoonInstall.logger
--- Variable
--- Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.
obj.logger = hs.logger.new('SpoonInstall')
--- SpoonInstall.repos
--- Variable
--- Table containing the list of available Spoon repositories. The key
--- of each entry is an identifier for the repository, and its value
--- is a table with the following entries:
--- * desc - Human-readable description for the repository
--- * branch - Active git branch for the Spoon files
--- * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.
---
--- Default value:
--- ```
--- {
--- default = {
--- url = "https://github.com/Hammerspoon/Spoons",
--- desc = "Main Hammerspoon Spoon repository",
--- branch = "master",
--- }
--- }
--- ```
obj.repos = {
default = {
url = "https://github.com/Hammerspoon/Spoons",
desc = "Main Hammerspoon Spoon repository",
branch = "master",
}
}
--- SpoonInstall.use_syncinstall
--- Variable
--- If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.
---
--- Keep in mind that if you set this to `true`, Hammerspoon will
--- block until all missing Spoons are installed, but the notifications
--- will happen at a more "human readable" rate.
obj.use_syncinstall = false
-- Execute a command and return its output with trailing EOLs trimmed. If the command fails, an error message is logged.
local function _x(cmd, errfmt, ...)
local output, status = hs.execute(cmd)
if status then
local trimstr = string.gsub(output, "\n*$", "")
return trimstr
else
obj.logger.ef(errfmt, ...)
return nil
end
end
-- --------------------------------------------------------------------
-- Spoon repository management
-- Internal callback to process and store the data from docs.json about a repository
-- callback is called with repo as arguments, only if the call is successful
function obj:_storeRepoJSON(repo, callback, status, body, hdrs)
local success=nil
if (status < 100) or (status >= 400) then
self.logger.ef("Error fetching JSON data for repository '%s'. Error code %d: %s", repo, status, body or "<no error message>")
else
local json = hs.json.decode(body)
if json then
self.repos[repo].data = {}
for i,v in ipairs(json) do
v.download_url = self.repos[repo].download_base_url .. v.name .. ".spoon.zip"
self.repos[repo].data[v.name] = v
end
self.logger.df("Updated JSON data for repository '%s'", repo)
success=true
else
self.logger.ef("Invalid JSON received for repository '%s': %s", repo, body)
end
end
if callback then
callback(repo, success)
end
return success
end
-- Internal function to return the URL of the docs.json file based on the URL of a GitHub repo
function obj:_build_repo_json_url(repo)
if self.repos[repo] and self.repos[repo].url then
local branch = self.repos[repo].branch or "master"
self.repos[repo].json_url = string.gsub(self.repos[repo].url, "/$", "") .. "/raw/"..branch.."/docs/docs.json"
self.repos[repo].download_base_url = string.gsub(self.repos[repo].url, "/$", "") .. "/raw/"..branch.."/Spoons/"
return true
else
self.logger.ef("Invalid or unknown repository '%s'", repo)
return nil
end
end
--- SpoonInstall:asyncUpdateRepo(repo, callback)
--- Method
--- Asynchronously fetch the information about the contents of a Spoon repository
---
--- Parameters:
--- * repo - name of the repository to update. Defaults to `"default"`.
--- * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:
--- * repo - name of the repository
--- * success - boolean indicating whether the update succeeded
---
--- Returns:
--- * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise
---
--- Notes:
--- * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
function obj:asyncUpdateRepo(repo, callback)
if not repo then repo = 'default' end
if self:_build_repo_json_url(repo) then
hs.http.asyncGet(self.repos[repo].json_url, nil, hs.fnutils.partial(self._storeRepoJSON, self, repo, callback))
return true
else
return nil
end
end
--- SpoonInstall:updateRepo(repo)
--- Method
--- Synchronously fetch the information about the contents of a Spoon repository
---
--- Parameters:
--- * repo - name of the repository to update. Defaults to `"default"`.
---
--- Returns:
--- * `true` if the update was successful, `nil` otherwise
---
--- Notes:
--- * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.
--- * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
function obj:updateRepo(repo)
if not repo then repo = 'default' end
if self:_build_repo_json_url(repo) then
local a,b,c = hs.http.get(self.repos[repo].json_url)
return self:_storeRepoJSON(repo, nil, a, b, c)
else
return nil
end
end
--- SpoonInstall:asyncUpdateAllRepos()
--- Method
--- Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
function obj:asyncUpdateAllRepos()
for k,v in pairs(self.repos) do
self:asyncUpdateRepo(k)
end
end
--- SpoonInstall:updateAllRepos()
--- Method
--- Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
---
--- Notes:
--- * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.
--- * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
function obj:updateAllRepos()
for k,v in pairs(self.repos) do
self:updateRepo(k)
end
end
--- SpoonInstall:repolist()
--- Method
--- Return a sorted list of registered Spoon repositories
---
--- Parameters:
--- * None
---
--- Returns:
--- * Table containing a list of strings with the repository identifiers
function obj:repolist()
local keys={}
-- Create sorted list of keys
for k,v in pairs(self.repos) do table.insert(keys, k) end
table.sort(keys)
return keys
end
--- SpoonInstall:search(pat)
--- Method
--- Search repositories for a pattern
---
--- Parameters:
--- * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern.
---
--- Returns:
--- * Table containing a list of matching entries. Each entry is a table with the following keys:
--- * name - Spoon name
--- * desc - description of the spoon
--- * repo - identifier in the repository where the match was found
function obj:search(pat)
local res={}
for repo,v in pairs(self.repos) do
if v.data then
for spoon,rec in pairs(v.data) do
if string.find(string.lower(rec.name .. "\n" .. rec.desc), pat) then
table.insert(res, { name = rec.name, desc = rec.desc, repo = repo })
end
end
else
self.logger.ef("Repository data for '%s' not available - call spoon.SpoonInstall:updateRepo('%s'), then try again.", repo, repo)
end
end
return res
end
-- --------------------------------------------------------------------
-- Spoon installation
-- Internal callback function to finalize the installation of a spoon after the zip file has been downloaded.
-- callback, if given, is called with (urlparts, success) as arguments
function obj:_installSpoonFromZipURLgetCallback(urlparts, callback, status, body, headers)
local success=nil
if (status < 100) or (status >= 400) then
self.logger.ef("Error downloading %s. Error code %d: %s", urlparts.absoluteString, status, body or "<none>")
else
-- Write the zip file to disk in a temporary directory
local tmpdir=_x("/usr/bin/mktemp -d", "Error creating temporary directory to download new spoon.")
if tmpdir then
local outfile = string.format("%s/%s", tmpdir, urlparts.lastPathComponent)
local f=assert(io.open(outfile, "w"))
f:write(body)
f:close()
-- Check its contents - only one *.spoon directory should be in there
output = _x(string.format("/usr/bin/unzip -l %s '*.spoon/' | /usr/bin/awk '$NF ~ /\\.spoon\\/$/ { print $NF }' | /usr/bin/wc -l", outfile),
"Error examining downloaded zip file %s, leaving it in place for your examination.", outfile)
if output then
if (tonumber(output) or 0) == 1 then
-- Uncompress the zip file
local outdir = string.format("%s/Spoons", hs.configdir)
if _x(string.format("/usr/bin/unzip -o %s -d %s 2>&1", outfile, outdir),
"Error uncompressing file %s, leaving it in place for your examination.", outfile) then
-- And finally, install it using Hammerspoon itself
self.logger.f("Downloaded and installed %s", urlparts.absoluteString)
_x(string.format("/bin/rm -rf '%s'", tmpdir), "Error removing directory %s", tmpdir)
success=true
end
else
self.logger.ef("The downloaded zip file %s is invalid - it should contain exactly one spoon. Leaving it in place for your examination.", outfile)
end
end
end
end
if callback then
callback(urlparts, success)
end
return success
end
--- SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)
--- Method
--- Asynchronously download a Spoon zip file and install it.
---
--- Parameters:
--- * url - URL of the zip file to install.
--- * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:
--- * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file
--- * success - boolean indicating whether the installation was successful
---
--- Returns:
--- * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise
function obj:asyncInstallSpoonFromZipURL(url, callback)
local urlparts = hs.http.urlParts(url)
local dlfile = urlparts.lastPathComponent
if dlfile and dlfile ~= "" and urlparts.pathExtension == "zip" then
hs.http.asyncGet(url, nil, hs.fnutils.partial(self._installSpoonFromZipURLgetCallback, self, urlparts, callback))
return true
else
self.logger.ef("Invalid URL %s, must point to a zip file", url)
return nil
end
end
--- SpoonInstall:installSpoonFromZipURL(url)
--- Method
--- Synchronously download a Spoon zip file and install it.
---
--- Parameters:
--- * url - URL of the zip file to install.
---
--- Returns:
--- * `true` if the installation was successful, `nil` otherwise
function obj:installSpoonFromZipURL(url)
local urlparts = hs.http.urlParts(url)
local dlfile = urlparts.lastPathComponent
if dlfile and dlfile ~= "" and urlparts.pathExtension == "zip" then
a,b,c=hs.http.get(url)
return self:_installSpoonFromZipURLgetCallback(urlparts, nil, a, b, c)
else
self.logger.ef("Invalid URL %s, must point to a zip file", url)
return nil
end
end
-- Internal function to check if a Spoon/Repo combination is valid
function obj:_is_valid_spoon(name, repo)
if self.repos[repo] then
if self.repos[repo].data then
if self.repos[repo].data[name] then
return true
else
self.logger.ef("Spoon '%s' does not exist in repository '%s'. Please check and try again.", name, repo)
end
else
self.logger.ef("Repository data for '%s' not available - call spoon.SpoonInstall:updateRepo('%s'), then try again.", repo, repo)
end
else
self.logger.ef("Invalid or unknown repository '%s'", repo)
end
return nil
end
--- SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)
--- Method
--- Asynchronously install a Spoon from a registered repository
---
--- Parameters:
--- * name - Name of the Spoon to install.
--- * repo - Name of the repository to use. Defaults to `"default"`
--- * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:
--- * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file
--- * success - boolean indicating whether the installation was successful
---
--- Returns:
--- * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise.
function obj:asyncInstallSpoonFromRepo(name, repo, callback)
if not repo then repo = 'default' end
if self:_is_valid_spoon(name, repo) then
self:asyncInstallSpoonFromZipURL(self.repos[repo].data[name].download_url, callback)
end
return nil
end
--- SpoonInstall:installSpoonFromRepo(name, repo)
--- Method
--- Synchronously install a Spoon from a registered repository
---
--- Parameters:
--- * name = Name of the Spoon to install.
--- * repo - Name of the repository to use. Defaults to `"default"`
---
--- Returns:
--- * `true` if the installation was successful, `nil` otherwise.
function obj:installSpoonFromRepo(name, repo, callback)
if not repo then repo = 'default' end
if self:_is_valid_spoon(name, repo) then
return self:installSpoonFromZipURL(self.repos[repo].data[name].download_url)
end
return nil
end
--- SpoonInstall:andUse(name, arg)
--- Method
--- Declaratively install, load and configure a Spoon
---
--- Parameters:
--- * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.
--- * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):
--- * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `"default"`.
--- * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.
--- * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `"default"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.
--- * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.
--- * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.
--- * start - if `true`, call the Spoon's `start()` method after configuring everything else.
--- * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon.
---
--- Returns:
--- * None
function obj:andUse(name, arg)
if not arg then arg = {} end
if arg.disable then return true end
if hs.spoons.use(name, arg, true) then
return true
else
local repo = arg.repo or "default"
if self.repos[repo] then
if self.repos[repo].data then
local load_and_config = function(_, success)
if success then
hs.notify.show("Spoon installed by SpoonInstall", name .. ".spoon is now available", "")
hs.spoons.use(name, arg)
else
obj.logger.ef("Error installing Spoon '%s' from repo '%s'", name, repo)
end
end
if self.use_syncinstall then
return load_and_config(nil, self:installSpoonFromRepo(name, repo))
else
self:asyncInstallSpoonFromRepo(name, repo, load_and_config)
end
else
local update_repo_and_continue = function(_, success)
if success then
obj:andUse(name, arg)
else
obj.logger.ef("Error updating repository '%s'", repo)
end
end
if self.use_syncinstall then
return update_repo_and_continue(nil, self:updateRepo(repo))
else
self:asyncUpdateRepo(repo, update_repo_and_continue)
end
end
else
obj.logger.ef("Unknown repository '%s' for Spoon", repo, name)
end
end
end
return obj
+16
View File
@@ -0,0 +1,16 @@
-- System_Tweaks.lua
-- Disable Time Machine throttling silently
local function disableTMThrottling()
-- Use hs.execute to run the command directly via sudo
-- Since we added the NOPASSWD rule, this will be silent and instant
local output, status, type, rc = hs.execute("sudo /usr/sbin/sysctl debug.lowpri_throttle_enabled=0", true)
if status then
hs.notify.new({title="Hammerspoon", informativeText="Time Machine unthrottled (Silent)."}):send()
else
print("Sudo Error: " .. output)
end
end
-- Run on Hammerspoon load/reload
disableTMThrottling()
+202
View File
@@ -0,0 +1,202 @@
local obj = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
obj.isRescued = false
obj.isTransitioning = false
obj.lastScreenCount = #hs.screen.allScreens()
obj.lastSavedTime = "Never"
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
}
-- ==========================================
-- INTERNAL UTILITIES
-- ==========================================
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)
if hs.caffeinate.get("displayIdle") or obj.isTransitioning or #hs.screen.allScreens() ~= obj.lastScreenCount then
return
end
local screens = hs.screen.allScreens()
local layout = {
saveTime = os.date("%H:%M:%S"),
screenCount = #screens,
windows = {}
}
for _, win in ipairs(hs.window.allWindows()) do
local app = win:application()
local screen = win:screen()
if app and screen and win:isVisible() and win:frame().w > 0 then
local appName = app:name() or ""
if not (ignoreListItems[appName] or appName:find("TheBoringNotch")) then
table.insert(layout.windows, {
appName = appName,
bundleID = app:bundleID(),
winTitle = win:title(),
screenName = screen:name(),
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
hs.json.write(layout, obj.layoutFile, true, true)
obj.lastSavedTime = layout.saveTime
if not silent then hs.alert.show("Layout Saved", 1.5) end
end
function obj.restoreLayout()
local data = hs.json.read(obj.layoutFile)
if not data then return end
obj.isRescued = false
local appWindowTracker = {}
for _, winData in ipairs(data.windows or {}) do
local app = hs.application.get(winData.bundleID) or hs.application.get(winData.appName)
if app then
local appName = app:name()
appWindowTracker[appName] = appWindowTracker[appName] or 1
local validWins = {}
for _, w in ipairs(app:allWindows()) do if w:isVisible() then table.insert(validWins, w) end end
local win = validWins[appWindowTracker[appName]]
if win then
if win:isMinimized() then win:unminimize() end
local x, y, w, h = winData.x, winData.y, winData.w, winData.h
local isStubborn = stubbornAppsList[appName] or (app:path() or ""):find("Electron")
local function moveAction()
if isStubborn then
local winTarget = (appName == "Gemini") and "window 1" or string.format('first window whose name is "%s"', win:title():gsub('"', '\\"'))
local script = string.format([[
tell application "System Events" to tell (first process whose unix id is %d)
try
set targetWin to %s
set position of targetWin to {%d, %d}
set size of targetWin to {%d, %d}
end try
end tell
]], app:pid(), winTarget, x, y, w, h)
hs.applescript.applescript(script)
else
win:setFrame({x=x, y=y, w=w, h=h}, 0)
end
end
moveAction()
hs.timer.doAfter(0.5, moveAction)
hs.timer.doAfter(1.5, moveAction)
appWindowTracker[appName] = appWindowTracker[appName] + 1
end
end
end
hs.alert.show("Layout Restored", 1.5)
end
function obj.rescueWindowsToLaptop()
local primary = hs.screen.primaryScreen()
for _, win in ipairs(hs.window.allWindows()) do
if win:isVisible() then win:moveToScreen(primary, false, true) end
end
obj.isRescued = true
hs.alert.show("Rescued to Laptop", 1.5)
end
-- ==========================================
-- MENUBAR & TIMERS
-- ==========================================
local saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local screens = hs.screen.allScreens()
local minutes, seconds = math.floor(saveCountdown / 60), saveCountdown % 60
timerMenu:setTitle(string.format("💠 %d:%02d", minutes, seconds))
local data = hs.json.read(obj.layoutFile)
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); saveCountdown = obj.saveInterval end },
{ title = "🔄 Restore Layout (⇧⌘R)", fn = obj.restoreLayout },
{ title = "🚀 Rescue Windows (Bring to Laptop) (⇧⌘⌃L)", fn = obj.rescueWindowsToLaptop },
{ title = "-" },
{ title = "📦 Saved Apps:", disabled = true }
}
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
end
-- ==========================================
-- WATCHERS & HOTKEYS
-- ==========================================
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); saveCountdown = obj.saveInterval end)
hs.hotkey.bind({"shift", "cmd"}, "R", obj.restoreLayout)
hs.hotkey.bind({"shift", "cmd", "ctrl"}, "L", obj.rescueWindowsToLaptop)
obj.screenWatcher = hs.screen.watcher.new(function()
obj.isTransitioning = true
hs.timer.doAfter(30, 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); saveCountdown = obj.saveInterval end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then saveCountdown = obj.saveInterval end
updateMenu()
end)
updateMenu()
return obj
+341
View File
@@ -0,0 +1,341 @@
-- 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.isRescued = false
obj.isTransitioning = false
obj.isRestoring = false
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
-- NEW LOGIC: Start at top-left corner (50px offset) instead of center
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 saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local screens = hs.screen.allScreens()
timerMenu:setTitle(string.format("💠 %d:%02d", math.floor(saveCountdown / 60), 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); 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
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
if obj.autoSaveTimer then obj.autoSaveTimer: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.")
saveCountdown = obj.saveInterval
if obj.autoSaveTimer then obj.autoSaveTimer: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
end
end
end):start()
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); 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()
-- NEW GUARD: If count hasn't changed, ignore ghost/handshake events
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)
saveCountdown = obj.saveInterval
end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then saveCountdown = obj.saveInterval end
updateMenu()
end)
updateMenu()
return obj
+72
View File
@@ -0,0 +1,72 @@
local obj = {}
local function getBrowserURL()
local app = hs.application.frontmostApplication()
local name = app:name()
local script = ""
if name == "Safari" then
script = 'tell app "Safari" to return URL of front document'
elseif name == "Google Chrome" or name == "Brave Browser" or name == "Microsoft Edge" then
script = 'tell app "' .. name .. '" to return URL of active tab of front window'
else
return nil
end
local ok, url = hs.applescript(script)
return ok and url or nil
end
function obj.clip()
hs.timer.usleep(200000)
local url = getBrowserURL()
hs.alert.show("Clipping to AFFiNE...", 1.5)
hs.pasteboard.clearContents()
hs.eventtap.keyStroke({"cmd"}, "c")
hs.timer.usleep(800000)
local clippedText = hs.pasteboard.getContents()
if not clippedText or clippedText == "" then
hs.alert.show("Nothing selected! ❌", 2)
return
end
hs.urlevent.openURL("affine://")
hs.timer.doAfter(1.2, function()
local affine = hs.application.get("AFFiNE")
if affine then
affine:activate()
hs.eventtap.keyStroke({"cmd"}, "n")
hs.timer.usleep(1000000)
hs.eventtap.keyStroke({}, "return")
hs.timer.usleep(500000)
if url then
hs.pasteboard.setContents("Source: " .. url .. "\n\n")
hs.timer.usleep(300000)
hs.eventtap.keyStroke({"cmd"}, "v")
hs.timer.usleep(800000)
end
hs.pasteboard.setContents(clippedText)
hs.timer.usleep(300000)
hs.eventtap.keyStroke({"cmd"}, "v")
hs.alert.show("Clipped Successfully ✅", 1.5)
else
hs.alert.show("AFFiNE App not found! ❌", 3)
end
end)
end
function obj.init()
-- Replaced the binding to use your Hyper Key + C
hs.hotkey.bind(hyper, "C", function()
obj.clip()
end)
end
return obj
+57
View File
@@ -0,0 +1,57 @@
-- ~/.hammerspoon/affine_quick_note.lua
local obj = {}
-- CONFIGURATION
local NODE_PATH = "/opt/homebrew/bin/node"
local SYNC_SCRIPT = os.getenv("HOME") .. "/.hammerspoon/scripts/sync_note.js"
local function affineQuickNote(text)
local syncNotify = hs.notify.new({
title="AFFiNE Sync",
informativeText="Syncing large note... Please wait."
}):send()
local task = hs.task.new(NODE_PATH, function(exitCode, stdOut, stdErr)
syncNotify:withdraw()
if exitCode == 0 then
hs.notify.new({title="AFFiNE Success", informativeText="Multi-line note appended."}):send()
else
hs.notify.new({title="AFFiNE Error", informativeText="Check Console."}):send()
print("Sync Error: " .. stdErr)
end
end, {SYNC_SCRIPT, text})
task:start()
end
-- Function to trigger a proper macOS multi-line text box
local function getMultiLineInput()
local appleScript = [[
tell application "System Events"
activate
set theResponse to display dialog "Enter your detailed note:" default answer "" with title "AFFiNE Multi-Line Capture" buttons {"Cancel", "Send"} default button "Send"
return text returned of theResponse
end tell
]]
local ok, result = hs.applescript(appleScript)
if ok and result ~= "" then
return result
end
return nil
end
function obj.init()
if hyper then
hs.hotkey.bind(hyper, "N", function()
local text = getMultiLineInput()
if text then
affineQuickNote(text)
end
end)
else
print("AFFiNE Error: 'hyper' variable not found.")
end
end
return obj
+72
View File
@@ -0,0 +1,72 @@
local obj = {}
-- Helper to handle the UI automation for creating and pasting into a new page
local function createAndPaste(content)
hs.alert.show("Sending to AFFiNE...", 1.5)
-- 1. Open/Focus the AFFiNE Desktop App
hs.urlevent.openURL("affine://")
-- 2. Wait for the app to come to the front and settle
hs.timer.doAfter(1.2, function()
local affine = hs.application.get("AFFiNE")
if affine then
affine:activate()
-- Create a new page (Cmd+N)
hs.eventtap.keyStroke({"cmd"}, "n")
hs.timer.usleep(1000000) -- Wait 1s for the editor to load
-- Move from Title to Body (Return)
hs.eventtap.keyStroke({}, "return")
hs.timer.usleep(500000)
-- Set the clipboard to our note and paste (Cmd+V)
hs.pasteboard.setContents(content)
hs.timer.usleep(300000)
hs.eventtap.keyStroke({"cmd"}, "v")
hs.alert.show("Note Created ✅", 1.5)
else
hs.alert.show("AFFiNE App not found! ❌", 3)
end
end)
end
-- Function to get multi-line text input
local function getNoteInput()
local script = [[
tell application "System Events"
activate
set prompt to "Enter your quick note:"
set theResponse to display dialog prompt default answer " " with title "AFFiNE Quick Note" buttons {"Cancel", "Send"} default button "Send"
return text returned of theResponse
end tell
]]
local ok, result = hs.applescript(script)
if ok and result and result ~= "" then
createAndPaste(result)
end
end
function obj.init()
if not hyper then return end
-- 1. Hyper + N: Manual text input dialog
hs.hotkey.bind(hyper, "N", function()
getNoteInput()
end)
-- 2. Hyper + V: Instantly send current clipboard contents to a new page
hs.hotkey.bind(hyper, "V", function()
local clipboard = hs.pasteboard.getContents()
if clipboard and clipboard ~= "" then
createAndPaste(clipboard)
else
hs.alert.show("Clipboard is empty!")
end
end)
end
return obj
+113
View File
@@ -0,0 +1,113 @@
-- ~~~~~~~~~~ CONFIGURATION & STATE ~~~~~~~~~~
local bento = {}
local k = _G.hyper or {"alt"}
_G.BentoState = _G.BentoState or {
gridSize = "3x2",
autoRestore = true,
windowTracker = {},
isSnapping = false -- LOCK: Prevents the bounce
}
if _G.BentoResources then
for _, item in ipairs(_G.BentoResources) do
if item.delete then item:delete() end
if item.stop then item:stop() end
end
end
_G.BentoResources = {}
hs.grid.setGrid(_G.BentoState.gridSize)
hs.grid.setMargins({5, 5})
hs.window.animationDuration = 0 -- Set to 0 to prevent movement lag during snaps
-- ~~~~~~~~~~ UTILITIES ~~~~~~~~~~
local function isWindowInGrid(win)
local cell = hs.grid.get(win)
local frame = win:frame()
local screen = win:screen()
local gridFrame = hs.grid.getCell(cell, screen)
-- Using a wider 20px buffer to account for macOS window shadows/borders
return math.abs(frame.x - gridFrame.x) < 20 and
math.abs(frame.y - gridFrame.y) < 20 and
math.abs(frame.w - gridFrame.w) < 20 and
math.abs(frame.h - gridFrame.h) < 20
end
local function manage(action)
local win = hs.window.focusedWindow()
if not win then return end
local id = win:id()
if not _G.BentoState.windowTracker[id] then
_G.BentoState.windowTracker[id] = win:frame()
end
-- ENABLE LOCK
_G.BentoState.isSnapping = true
if action == "ui" then
hs.grid.show()
else
action(win)
end
-- RELEASE LOCK after a short delay
hs.timer.doAfter(0.3, function()
_G.BentoState.isSnapping = false
end)
end
-- ~~~~~~~~~~ DYNAMIC MENU BAR ~~~~~~~~~~
local menu = hs.menubar.new()
table.insert(_G.BentoResources, menu)
local function updateMenu()
return {
{ title = "Grid: " .. _G.BentoState.gridSize, disabled = true },
{ title = "-" },
{ title = "Standard (2x2)", fn = function() _G.BentoState.gridSize = "2x2"; hs.reload() end, checked = (_G.BentoState.gridSize == "2x2") },
{ title = "Bento (3x2)", fn = function() _G.BentoState.gridSize = "3x2"; hs.reload() end, checked = (_G.BentoState.gridSize == "3x2") },
{ title = "-" },
{ title = "Reload Bento Only", fn = function() package.loaded["bento"] = nil; require("bento") end },
{ title = "Hard Reload", fn = hs.reload }
}
end
menu:setTitle("")
menu:setMenu(updateMenu)
-- ~~~~~~~~~~ BINDINGS & WATCHERS ~~~~~~~~~~
local function bind(key, fn)
local b = hs.hotkey.bind(k, key, fn)
if b then table.insert(_G.BentoResources, b) end
end
bind("G", function() manage("ui") end)
bind("Left", function() manage(hs.grid.pushWindowLeft) end)
bind("Right", function() manage(hs.grid.pushWindowRight) end)
bind("Up", function() manage(hs.grid.pushWindowUp) end)
bind("Down", function() manage(hs.grid.pushWindowDown) end)
if _G.BentoState.autoRestore then
local wf = hs.window.filter.new()
wf:subscribe(hs.window.filter.windowMoved, function(win)
-- ONLY trigger if we aren't currently snapping via keyboard
if not _G.BentoState.isSnapping then
local id = win:id()
if _G.BentoState.windowTracker[id] and not isWindowInGrid(win) then
win:setFrame(_G.BentoState.windowTracker[id])
_G.BentoState.windowTracker[id] = nil
hs.alert.show("Restored", 0.5)
end
end
end)
table.insert(_G.BentoResources, wf)
end
hs.alert.show("Bento Ready", 1)
return bento
+81
View File
@@ -0,0 +1,81 @@
-- ~/.hammerspoon/bump.lua
local bump = {}
-- Persistence and State
_G.BumpState = _G.BumpState or {
gridSize = "3x2", -- 3 columns, 2 rows
enabled = true
}
-- Cleanup existing resources
if _G.BumpResources then
for _, item in ipairs(_G.BumpResources) do
if item.stop then item:stop() end
if item.delete then item:delete() end
end
end
_G.BumpResources = {}
-- ~~~~~~~~~~ MANUAL BUMP LOGIC ~~~~~~~~~~
local bentoWatcher = hs.window.filter.new()
bentoWatcher:subscribe(hs.window.filter.windowMoved, function(newWin)
if not _G.BumpState.enabled then return end
local f1 = newWin:frame()
local screen = newWin:screen()
local maxW = screen:frame().w
local allWindows = hs.window.visibleWindows()
-- Calculate how wide one "slot" is (e.g., 1/3 of screen)
local cols = tonumber(_G.BumpState.gridSize:sub(1,1))
local slotWidth = maxW / cols
for _, oldWin in ipairs(allWindows) do
if oldWin:id() ~= newWin:id() and oldWin:screen() == screen and oldWin:isStandard() then
local f2 = oldWin:frame()
-- Manual Intersection check
local dx = math.max(0, math.min(f1.x + f1.w, f2.x + f2.w) - math.max(f1.x, f2.x))
local dy = math.max(0, math.min(f1.y + f1.h, f2.y + f2.h) - math.max(f1.y, f2.y))
-- If they overlap significantly
if (dx * dy) > (f1.w * f1.h * 0.4) then
local newX = f2.x + slotWidth
-- If pushing right stays on screen, move it.
-- Otherwise, move it to the start of the next row (y + height)
if (newX + 50) > maxW then
oldWin:setFrame({x = 0, y = f2.y + f2.h, w = f2.w, h = f2.h})
else
oldWin:setFrame({x = newX, y = f2.y, w = f2.w, h = f2.h})
end
hs.alert.show("Bumping " .. oldWin:application():name())
end
end
end
end)
table.insert(_G.BumpResources, bentoWatcher)
-- ~~~~~~~~~~ MENU BAR ~~~~~~~~~~
local menu = hs.menubar.new()
table.insert(_G.BumpResources, menu)
local function updateMenu()
return {
{ title = "Bump Status: " .. (_G.BumpState.enabled and "ON" or "OFF"), disabled = true },
{ title = "-" },
{ title = "Grid: 2x2", fn = function() _G.BumpState.gridSize = "2x2"; hs.reload() end, checked = (_G.BumpState.gridSize == "2x2") },
{ title = "Grid: 3x2", fn = function() _G.BumpState.gridSize = "3x2"; hs.reload() end, checked = (_G.BumpState.gridSize == "3x2") },
{ title = "-" },
{ title = "Reload Bump Only", fn = function() package.loaded["bump"] = nil; require("bump") end },
{ title = "Hard Reload All", fn = hs.reload }
}
end
menu:setTitle("")
menu:setMenu(updateMenu)
return bump
+33
View File
@@ -0,0 +1,33 @@
{
"stubbornAppsList": {
"Gemini": true,
"AFFiNE": true,
"Terminal": true,
"System Settings": true,
"Hammerspoon": true
},
"ignoreListItems": {
"TheBoringNotch": true,
"The Boring Notch": true,
"theboringteam.boringnotch": true,
"boring.notch": true,
"Control Center": true,
"Notification Center": true,
"Dock": true,
"BetterDisplay": true,
"pro.betterdisplay.BetterDisplay": true,
"Stats": true,
"DockDoor": true,
"Bartender 6": true,
"com.surteesstudios.Bartender": true,
"Bartender": true,
"Arq": true,
"Arq Agent": true,
"com.apple.controlcenter": true,
"com.apple.notificationcenterui": true,
"TypeWhisper": true,
"com.typewhisper.mac": true,
"dk.heyiam.monocle": true,
"Monocle": true
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "gen-lang-client-0842463959",
"private_key_id": "cc31094c10c8d69ed840136f7ecab21fa560e25f",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8tstAGMMtc38q\n580yI8PGDSrOcolm4eKVxOBSuzHwYjN1Q1Ay7ZKhuK+l7pv1vY/04b+Er/bPIviB\n9+A3S3tx+S+o28eEBbs+a7i2rAlNNJX/JHY18/L70s+x37qyIoPagoRDYkGrKsZI\nVLmHwQZCkdoMyvCNuzbFfkR5FurJSB9bxWVfx6I6L/fSPiBsZyvUzCoA3aJiN1SO\ngn+GdCOlqc/DQFIP9tCwGb2J8a29ma915ZTrKi6A0IiFkNuL+56EB9TwwZOT2KVC\nOSbAnJBHAbPBoirmS2qfpfhTvs/FZMaxbIkiSnzLujV4QwVjQG15KT2JWmabK7A/\nEMH6cJkbAgMBAAECggEACmVwKVXz5PfUDYDpE7rE9GpatqQBjhdGw05SkoDG+2Gi\nJGClGbST+4v0smUNxHxlq7arfoBBcrHZOFfAy2sKsl0gHFCgbZ6DyKhokIH853rx\nJsguu4sJEokGcZKVTkodV3J/Qo5/m1seC+EycRhFmnGH94lPJCIDwt5jxLrXwvlw\nZIMKkyDg8HnSosadr8DABK+W4SfqkTOFuZkIh4jpuhyLwByh8kFfOxqHliIiK9hf\n+QoXT3Ds1oula5Rdlu2ZZKYTThNVJbrpixMEcN5bl7NGKGQ47r4qt2ViDjjs24S9\nPE/JlYCrOO6+ovWdW29ZN/HqtM9U9pvXXdiGN41MaQKBgQDgHa5rZNs87Gnj5H4g\nIOFeGPLp9mRjU/S/tAXuTOrN2yG6lqNdwIUiAE9R6ip3uOPmzdnPCAUsgUVTtDEg\nHuziEvXB+4p7+at2PFhO5aH6Ye+npyRHzhcLKAMkheMZvSU2TSNeJpnKLnU6oBtP\n5VmTbPfsaTPwC6mkaFRh+69AlQKBgQDXj8XS5iqi0I+dRWmBANP31LvlR4cc4YW5\naXrjDNg5d7bw3+LflVrTjeeVqiunFYlrfMIy30TA9u4KisRdoJe2mYZIGy+CfkQL\n7PNdGv2XdfNQjRv0FYDMBkqRgUBFG9Y0mHsc+VKA99QZHy9QXdpd1JU1DmFup1s2\nQKLdXwyW7wKBgDC6I1cUOZqYaDl1T3ray0UzNXVq7c6uzVL06Ck0rgSN9VplMCXN\nGuUWnihYOl2HZH2lGgsqWj2f6ZvWXKv4LVbF+orvjt9/nCj729NjmAEhVALmkzvN\ncjMpwu0o8wSAnFufD+aDjAJqcXCKqQWI/x3PnmPXR8SUNJEbYeVf3G21AoGAcQJI\n3dYZCB57DCJ1u1HpzoXSs9MZ/IQnDRtFd38mZIpkeEeHs1ujsEE25fm+xOu/jYBs\ndysh6mAKT7CMXeFxaCN4iJjoAWuc1Pu2YltiE2Oc2eAAhag4S74Indu7DAAZ/pzp\n/jifjklfAoSc029AqexnBNezMMXAReMA/zlzajUCgYA2LU0JWqGjtZyk2u0BMOLy\nlpCzdzXMAN5A4TGQU8Mv2fvKQup8weRUyDctf+SE/jy2XkcjuViJU6c0PtAzn3t9\nne3cfB4Ee0D2Lv9dAJVHzgDqv9NMFQdGasKd53Ik6J5ryxRltQmjnjlS+KFmrssX\nyTMsGo5T5u36ZA4CZsxIag==\n-----END PRIVATE KEY-----\n",
"client_email": "ais-gemini-key-be1e93e9d76147d@423785749889.iam.gserviceaccount.com",
"client_id": "108179228765412243710",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/ais-gemini-key-be1e93e9d76147d%40423785749889.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
+13
View File
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "gen-lang-client-0842463959",
"private_key_id": "f488586958fba6d2205f937e63be3c5dcdc57446",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwnPNxJcufUhaF\nC7aE2xWe8IJbQoZiXo7gR9Vlw8eIGVsaRPaFdlDJeAiL1bKqyfLER1LuLv4obFuZ\n6cZYnZ39UfnifHBEfzmLOFGE1O4FOfcaWcC7CKrqprUrDtZl12du2sqzSVFGW7om\nUFkfOFcLQVUKoOW+BRMl5cY96Ve9LJXeEuxaPlGMhPKtUaIKPikN4l0Flmattx7v\nfezaPFOKHrxSMNgDfoWxbLvGCMOmOiwec9zBmNiedkDIuFRiVSVlz5NLNkXSqhTk\nTy6tL3gH6M/yDS0LBYGEB06LidTsKb9qyoMOISndKOzGM4aCaNpfeMDMt1CFf55j\nPPy3WGu5AgMBAAECggEACyqJK7SviI2oldXVPtYZKljbbQiqNWr0DpMMF2iG44By\n5ZjJm0uNNQetRqYGq0ykWCuIPz3iXMzpB7SjkTO56uu1aE/kyzfd/ALeCI9r0xHE\noTpeeJTGNZq/po7EGGFDjVsw6J/wjwm6nbSDxfkKa9BAH7FnhRmOCd3aqExp+zBN\nDXHaFsTL2hNxiqQEKJnhvrgo3YgZJbtlO2wtlHGEQzPrwqMcHWcpEOZKQmIp3klj\n25qSSwzMD7uhf6tGzBj/iCZKlpqWy/nwxa2ueHXhszs5urGstNtjijzZRmpnopo2\nTOzGSmX1chFyPc9EKMkaTRoBMIQjtuvUsW9olbytTwKBgQDav4Gm7Ol8Agj24XPJ\nrr+ihgOS84AG0YaLJDV5W96JAD+JI1ktZeHzz5aI9W8dyTUbV0aULKy9pJbVOnze\nsLRNKUpCmjYnlWgiBgAaDhu8GVn4gQtvrpU7YDpMadauYIvtx2gR0gOLF2vWatBO\nyqO6y9oQy34BEwkAqAaUNsXj2wKBgQDOsIl0GNl51MqoWj49Zae2tzPMWEaV3pDW\nl+J6MItIMVMjlDy6jhZBIJ4ewWAUvKj/ybbhvE2/UYAyirGPnqxj45A8drpHKrU4\nrSm5JGJX/XPQT3bbQynJDSIVXGIOevHB/59oymsrDNuuk2TTcKQYPh+cmn0KraYu\nsJxB0RdM+wKBgGPYqfthhCmAXEskGU/jncE3XoZC8xsppDn6qxXb4zWxkU6tfdUE\n/h/ljxawwKld4Am8ypBz290sNVTav4h+K65UvHquHS3wOnndN0qtSeePwst1S50M\nmT1i7PlYDg/GLdi4/j20GL3yUNysIKz2PcnTppOn7rna0G8mQuqAHc+lAoGBAJpF\nQhMZiRlaLnlCAjqpaWkDjPH04ZpzKQBDFZPGL++OgVOJDVrhOtMZmWuzQhe4SRvC\nX3JVrmIiXuFai/V6pYlZDQtUQu1jfyfyd7Xs5kpursbIyRoXI1UhLFNtRPMx6Mi4\nFy+MBjl3u3CuKw92f8RoegfVd4dE3Uj0IW0ut4mJAoGACa26f6sk5NQQo918jArJ\nx+WZm22k4ktFbumV4QRejvccH5XOFSTnTjnNV9C2VefRR/1klydzsw/M5kMFms1T\n668vzglAx7zNp4X2WxZ1wpcn8eaRjneDnlo370jms72GOcRonx0gsrCO8QNWqYkJ\n+AILNz2hv4FoIubDyZkD0KI=\n-----END PRIVATE KEY-----\n",
"client_email": "ais-gemini-key-be1e93e9d76147d@423785749889.iam.gserviceaccount.com",
"client_id": "108179228765412243710",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/ais-gemini-key-be1e93e9d76147d%40423785749889.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
+52
View File
@@ -0,0 +1,52 @@
import time
import json
import os
import urllib.request
import urllib.parse
try:
import jwt
from cryptography.hazmat.primitives import serialization
except ImportError:
print("Error: Missing dependencies. Run: pip3 install PyJWT cryptography")
exit(1)
base_path = os.path.expanduser("~/.hammerspoon/")
key_file = os.path.join(base_path, "google-key.json")
if not os.path.exists(key_file):
print(f"Error: {key_file} not found")
exit(1)
with open(key_file) as f:
key_data = json.load(f)
iat = int(time.time())
exp = iat + 3600
payload = {
'iss': key_data['client_email'],
'sub': key_data['client_email'],
'aud': 'https://oauth2.googleapis.com/token',
'iat': iat,
'exp': exp,
'scope': 'https://www.googleapis.com/auth/monitoring.read'
}
try:
# Sign the JWT using the private key from JSON
signed_jwt = jwt.encode(payload, key_data['private_key'], algorithm='RS256')
# Exchange for Access Token
url = 'https://oauth2.googleapis.com/token'
params = urllib.parse.urlencode({
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion': signed_jwt
}).encode('utf-8')
req = urllib.request.Request(url, data=params, method='POST')
with urllib.request.urlopen(req) as response:
res_data = json.loads(response.read().decode('utf-8'))
print(res_data['access_token'])
except Exception as e:
print(f"Auth Error: {str(e)}")
exit(1)
+13
View File
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "gen-lang-client-0842463959",
"private_key_id": "5b48241cc6b22f409552d1d71a58a649c0436c3b",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDTUKVfH+AveJdX\nUSMUwpC040n3aVzuIlLofgYAA3z+k2on00WnC1Iz1N//XJKbcZlYQpZC9aCIGeI/\nq/nC+yHGvZnXBBUmrOV/a0VM/075+vGTSR+N7ZDoAN3jTpOZs8VXDgcChu4JHxaq\nnrpa6JTclLTlsADlClmHq+FxlDYOILZ34T3JT6IDksvlDMYRXkTYiX1gVHlBeCKb\n3v4tKsowBAmjxchWxnwy0vOXw7lqEMqojymAwhoF8KyzaKJd4cOEHaMnQCS+nLc1\nWRAKOWs11Qe6Odch6r/bO7b69PE9mHdppNyNHy4C+TCxfM+ZuF/f8CFtu8YfjRr6\nr1nBiI67AgMBAAECggEAFKH4M6yKGDh/FnB+q2U/+dmDfFvWTg4d6EN91HyCtNyx\n7ceMMn5BfXwYjvd7/R9/aIW/5DPVg9R7Kdph3a7lomEa50qnwgn5spJHxvfavz/I\nXKg0I4iBsJV9FNe0Q7qw1wowlweTsGJPtUMgUQYKmusOKqsfuz1idoPK1GszR/eu\nNpc7g6ran4SZh+RyWIM/0NDlnIle+h3nA4wZ57MSCMAYeT926H54WGKrzuFX+tl0\naFNeyhcolj1Yryf36VKOQKa+BiM+AkAQXqqTRt89sUsD4t5ntBEMnKqOuEjji052\nWVOVcbhNPxFSwqRtHqseKPUNvrQGrxEQi5Qf1vGyCQKBgQD3v/7LYg0cBW7+ata9\n8qnZRKxLlxOXgVdER/sRnrlOugD0htdToXsIVb9Tclo1S6wqXVh1ZgWih1EZxGLj\n+j+d59NmXYqIHGp1tR3eXMiEiwscs8KGAQtdXlXNJEqLzBe2M8Rrmc3/uDnXYdza\nAVPF4O0yEb9hJyTC40cTJ+SJPwKBgQDaWg1UUpoV4V3PTEjnt2zBgicb4qh6PBzS\nln8Cm5s4LXm7Z77h2M4ek3bp0Mr6FuT+9RbQXtQ0EXLuFnuE9/U3hmgilrDYYhp3\no+EFsVdGQr6CVWMCxv0KzjlZ3MMggTmjLJkUSKkdrLMxoE9bNRaEJlQ9V1q2swv8\nNhr3i4l/hQKBgEoGph0jGQOsY/PE/JEY9sMij9CuPX5heS+/yjcDlB7/2NU3jRNC\nBr8A0AAhBO8zwyeNaKb7auebQxJN6bZwyZ1m7XWCsaflxbGSAnC0jH1+Bj33QEJG\nAZ1OWJjJJTUMMHGSmjgEZtbntvCyHwlMqDlR8c2qG+LtjUBmJJCGtPSFAoGBAJTI\nJ6z9W+Ds328RG9xIL+LrPJrTptkjfMBBq+mq/ekZk4kO+BIMGObctHo9uxEN6JuR\nhSoWc6HHAfkZeLDyBDUBcJOg+n922XIMSJgIbt+BOy6z3/NUg7eJLar9sjfD1fJJ\nwUUA/bsqoi9+fJQ5aE5Dj8L8cuNqvQ/uDhH/EoHBAoGBAOwyoBPYWO9GVdgAt5eq\nD9wxCONE9flrhn5xpgZ+ROGyg6T0NG5pn4k9bz1HOJ+pQJEBgkpX+4TqSAmKuYvy\nq2QwAfOrTNmgie8oLoYEA3Ha1baiTRCL2R9bvD2VLo8XaMiLGoHc6oHkwi562Vyq\n2J/VKhTdmXl4wC64Lmt3CH4d\n-----END PRIVATE KEY-----\n",
"client_email": "hammerspoon-monitor@gen-lang-client-0842463959.iam.gserviceaccount.com",
"client_id": "110951543695511627948",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/hammerspoon-monitor%40gen-lang-client-0842463959.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}
+171
View File
@@ -0,0 +1,171 @@
local obj = {}
obj.__index = obj
-- CONFIGURATION
local PROJECT_ID = "gen-lang-client-0842463959"
local PROJECT_NAME = "Google API Monitor"
local menu = hs.menubar.new()
local accessToken = nil
-- Data storage
local history = {}
local lastMetrics = {
requests = 0,
throughput = "0 B",
errors = 0,
latency = "0 ms",
quotaPercent = 0
}
-- Helper: Format bytes
local function formatBytes(bytes)
if not bytes or bytes == 0 then return "0 B" end
local units = {"B", "KB", "MB", "GB"}
local i = 1
while bytes > 1024 and i < #units do
bytes = bytes / 1024
i = i + 1
end
return string.format("%.2f %s", bytes, units[i])
end
-- Helper: ASCII Trend Graph (Traffic)
local function getTrendString()
if #history < 2 then return "▂ Loading..." end
local bars = {" ", "", "", "", "", "", "", ""}
local maxVal = 0
for _, v in ipairs(history) do if v > maxVal then maxVal = v end end
local res = ""
for _, v in ipairs(history) do
local idx = 1
if maxVal > 0 then
idx = math.floor((v / maxVal) * 7) + 1
end
res = res .. bars[idx]
end
return res
end
-- Helper: Token Refresh
local function refreshAuth(callback)
local scriptPath = os.getenv("HOME") .. "/.hammerspoon/get_token.py"
hs.task.new("/usr/bin/python3", function(exitCode, stdOut, stdErr)
if exitCode == 0 then
accessToken = stdOut:gsub("%s+", "")
if callback then callback() end
else
print("Python Auth Error: " .. (stdErr or "Unknown"))
end
end, {scriptPath}):start()
end
-- Generic Fetch
local function fetchMetric(metricType, callback)
if not accessToken then return end
local now = os.date("!%Y-%m-%dT%H:%M:%SZ")
local start = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time() - 3600)
local baseUrl = "https://monitoring.googleapis.com/v3/projects/" .. PROJECT_ID .. "/timeSeries"
local fullUrl = baseUrl .. "?" ..
"filter=" .. hs.http.encodeForQuery('metric.type="' .. metricType .. '"') ..
"&interval.startTime=" .. hs.http.encodeForQuery(start) ..
"&interval.endTime=" .. hs.http.encodeForQuery(now)
hs.http.asyncGet(fullUrl, {Authorization = "Bearer " .. accessToken}, function(status, body)
if status == 200 then
local data = hs.json.decode(body)
local total, count = 0, 0
if data and data.timeSeries then
for _, series in ipairs(data.timeSeries) do
for _, point in ipairs(series.points or {}) do
local val = point.value.int64Value or point.value.doubleValue or 0
total = total + tonumber(val)
count = count + 1
end
end
end
callback(total, count)
elseif status == 401 or status == 403 then
accessToken = nil
end
end)
end
function obj.updateMenu()
local trend = getTrendString()
local qColor = {white=1}
if lastMetrics.quotaPercent >= 90 then qColor = {red=1}
elseif lastMetrics.quotaPercent >= 75 then qColor = {orange=1} end
menu:setMenu({
{ title = "Project: " .. PROJECT_NAME, disabled = true },
{ title = "Traffic Trend: " .. trend, disabled = true },
{ title = "-" },
{ title = "Quota Utilization: " .. lastMetrics.quotaPercent .. "%", font={color=qColor} },
{ title = "Total Requests (1h): " .. lastMetrics.requests },
{ title = "Average Latency: " .. lastMetrics.latency },
{ title = "Data Throughput: " .. lastMetrics.throughput },
{ title = "Errors (1h): " .. lastMetrics.errors, font = { color = (lastMetrics.errors > 0 and {red=1} or {white=1}) } },
{ title = "-" },
{ title = "Refresh Now", fn = function() obj.updateQuota() end },
{ title = "Open Google Console", fn = function() hs.urlevent.openURL("https://console.cloud.google.com/apis/dashboard?project=" .. PROJECT_ID) end }
})
end
function obj.updateQuota()
if not accessToken then
refreshAuth(function() obj.updateQuota() end)
return
end
-- 1. Fetch Traffic
fetchMetric("serviceruntime.googleapis.com/api/request_count", function(reqCount)
lastMetrics.requests = reqCount
-- Update history for trend bars
table.insert(history, reqCount)
if #history > 10 then table.remove(history, 1) end
-- 2. Fetch Quota Usage vs Limit
fetchMetric("serviceruntime.googleapis.com/quota/allocation/usage", function(usage)
fetchMetric("serviceruntime.googleapis.com/quota/limit", function(limit)
lastMetrics.quotaPercent = (limit > 0) and math.floor((usage / limit) * 100) or 0
-- 3. Fetch Throughput and Latency
fetchMetric("serviceruntime.googleapis.com/api/response_sizes", function(bytes)
lastMetrics.throughput = formatBytes(bytes)
fetchMetric("serviceruntime.googleapis.com/api/request_latencies", function(totalLat, count)
lastMetrics.latency = count > 0 and string.format("%.2f ms", (totalLat / count) * 1000) or "0 ms"
-- 4. Errors
local errFilter = 'serviceruntime.googleapis.com/api/request_count" AND metric.labels.response_code_class!="2xx'
fetchMetric(errFilter, function(errCount)
lastMetrics.errors = errCount
-- Title Icon Logic
local statusIcon = ""
if errCount > 0 then statusIcon = "🔴 "
elseif lastMetrics.quotaPercent >= 80 then statusIcon = "⚠️ "
end
menu:setTitle(statusIcon .. "G-API: " .. reqCount)
obj.updateMenu()
end)
end)
end)
end)
end)
end)
end
function obj.init()
if menu then
menu:setTitle("G-API: ...")
obj.updateMenu()
-- Polling every 5 minutes
obj.timer = hs.timer.doEvery(300, function() obj.updateQuota() end)
obj.updateQuota()
end
end
return obj
+99
View File
@@ -0,0 +1,99 @@
-- ~/.hammerspoon/init.lua
-- Load Config First
-- config = require("Config")
--
-- Load your HyperKey
require("HyperKey")
require('SearchWindows')
require('Caffeine')
require('AppBorders')
require('LayoutSelector')
require('System_Tweaks') -- Used for Time Machine Throttle Disable
-- require("Focus") -- Does not work with layout saver
Network = require("NetworkCenter")
-- Load the window management module
local windowMgr = require("WindowManager")
local productivity = require("productivity")
-- For Affine
local quickNote = require("affine_quick_note")
quickNote.init()
require("affine_clipper"):init()
---
-- Load Google API Monitor
-- local googleMonitor = require("google_monitor")
-- googleMonitor.init()
--
-- Load Spoon Files
hs.loadSpoon('SpoonInstall')
hs.loadSpoon('SpeedMenu')
hs.loadSpoon('BrewInfo')
-- hs.loadSpoon('Seal')
--- 3. Run/Configure Spoons
---- SpeedMenu Config
if spoon.SpeedMenu then
-- 1. Define the Fix Function (includes MAC and IPv6)
local function applyFullMenuFix()
local interface = spoon.SpeedMenu.interface or "en0"
local ssid = hs.wifi.currentNetwork() or "Disconnected"
local details = hs.network.interfaceDetails(interface)
local ipv4 = (details and details.IPv4) and details.IPv4.Addresses[1] or "N/A"
local ipv6 = (details and details.IPv6) and details.IPv6.Addresses[1] or "N/A"
-- Get MAC Address via shell
local macaddr = hs.execute('ifconfig ' .. interface .. ' | grep ether | awk \'{print $2}\''):gsub("%s+", "")
local menuitems = {
{ title = "SSID: " .. ssid, fn = function() hs.pasteboard.setContents(ssid) end },
{ title = "IPv4: " .. ipv4, fn = function() hs.pasteboard.setContents(ipv4) end },
{ title = "IPv6: " .. ipv6, fn = function() hs.pasteboard.setContents(ipv6) end },
{ title = "MAC: " .. macaddr, fn = function() hs.pasteboard.setContents(macaddr) end },
{ title = "-" },
{ title = "Rescan Network Interfaces", fn = function() spoon.SpeedMenu:rescan() end }
}
spoon.SpeedMenu.menubar:setMenu(menuitems)
end
-- 2. Hook the rescan method
local oldRescan = spoon.SpeedMenu.rescan
spoon.SpeedMenu.rescan = function(self)
oldRescan(self)
applyFullMenuFix()
end
-- 3. Toggle Logic (Starts as OFF)
local speedMenuRunning = false
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
if speedMenuRunning then
spoon.SpeedMenu:stop()
hs.alert.show("SpeedMenu Stopped")
else
spoon.SpeedMenu:start() -- This puts it in the menu bar
applyFullMenuFix() -- This populates the data
hs.alert.show("SpeedMenu Started")
end
speedMenuRunning = not speedMenuRunning
end)
end
---- SpeedMenu Config END
---- BrewInfo
if spoon.BrewInfo then
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "I", function()
spoon.BrewInfo:showBrewInfo()
end)
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "J", function()
spoon.BrewInfo:showBrewInfoCurSel()
end)
end
---- BrewInfo END
-- Add this line to the module section of your init.lua
hs.alert.show("Hammerspoon Config Reloaded")
BIN
View File
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
{
"windows" : [
{
"x" : 1920,
"winTitle" : "AFFiNE",
"y" : 0,
"appName" : "AFFiNE",
"h" : 1080,
"w" : 960,
"bundleID" : "pro.affine.app"
},
{
"x" : 0,
"winTitle" : "TrueNAS - 192.168.1.135 - Google Chrome",
"y" : 30,
"appName" : "Google Chrome",
"h" : 957,
"w" : 1920,
"bundleID" : "com.google.Chrome"
},
{
"x" : 2880,
"winTitle" : "francop — -zsh — 134×33",
"y" : 536,
"appName" : "Terminal",
"h" : 544,
"w" : 958,
"bundleID" : "com.apple.Terminal"
},
{
"x" : 2880,
"winTitle" : "francop — tail -f \/tmp\/litellm.out.log — 134×33",
"y" : 0,
"appName" : "Terminal",
"h" : 544,
"w" : 958,
"bundleID" : "com.apple.Terminal"
},
{
"x" : 0,
"winTitle" : "WindowManager.lua — .hammerspoon",
"y" : 30,
"appName" : "Code",
"h" : 956,
"w" : 1920,
"bundleID" : "com.microsoft.VSCode"
}
],
"screenCount" : 3,
"saveTime" : "2026-05-08 23:19:07",
"mode" : "Docked"
}
+52
View File
@@ -0,0 +1,52 @@
{
"screenCount" : 1,
"windows" : [
{
"x" : 0,
"winTitle" : "AFFiNE",
"appName" : "AFFiNE",
"y" : 33,
"h" : 855,
"w" : 756,
"bundleID" : "pro.affine.app"
},
{
"x" : 296,
"winTitle" : "2026",
"appName" : "Finder",
"y" : 138,
"h" : 436,
"w" : 920,
"bundleID" : "com.apple.finder"
},
{
"x" : 19,
"winTitle" : "Getting Started - Getting Started - Google Chrome",
"appName" : "Google Chrome",
"y" : 55,
"h" : 856,
"w" : 1512,
"bundleID" : "com.google.Chrome"
},
{
"x" : 1107,
"winTitle" : "Gemini Mini Chat — Integrate Ollama with Spark AI",
"appName" : "Gemini",
"y" : 388,
"h" : 500,
"w" : 400,
"bundleID" : "com.google.GeminiMacOS"
},
{
"x" : 0,
"winTitle" : "Laptop Coding.json — .hammerspoon",
"appName" : "Code",
"y" : 33,
"h" : 856,
"w" : 1512,
"bundleID" : "com.microsoft.VSCode"
}
],
"saveTime" : "2026-05-08 17:45:56",
"mode" : "Laptop"
}
+335
View File
@@ -0,0 +1,335 @@
[2026-05-05 16:56:37]
hello, my name is Franco Pellicciotti
line 2
-------------------
[2026-05-05 16:57:45]
What's updated:
1. LOG_DIR: Added a dedicated variable for the logs folder.
2. hs.fs.mkdir: Added the command to make sure the folder exists.
3. RECOVERY_FILE: Changed path from the root to the logs folder.
4. Full Logic: Re-inserted the showNoteInput() (Chooser) and the full AppleScript dialog functions so your file is back to its original "dual-input" power.
Just update your JavaScript screenshot path (from my previous message) and you're set.
-------------------
[2026-05-05 17:51:22]
local obj = {}
local json = require("hs.json")
local styledtext = require("hs.styledtext")
-- ==========================================
-- CONFIGURATION
-- ==========================================
obj.layoutFile = os.getenv("HOME") .. "/.hammerspoon/saved_layout.json"
obj.saveInterval = 300
obj.isRescued = false
obj.isTransitioning = false
obj.isRestoring = false
obj.wakeTimer = nil
obj.lastScreenCount = #hs.screen.allScreens()
obj.lastSavedTime = "Never"
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
}
-- ==========================================
-- 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 obj.isTransitioning or obj.isRestoring then
log("Save BLOCKED: System busy.")
return
end
if currentScreens ~= obj.lastScreenCount then
log(string.format("Screen mismatch (%d vs %d). Syncing count...", currentScreens, obj.lastScreenCount))
obj.lastScreenCount = currentScreens
return
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 ""
if not ignoreListItems[appName] then
table.insert(layout.windows, {
appName = appName,
bundleID = app: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()
-- Manual trigger now clears busy flags to force execution
obj.isRestoring = false
obj.isTransitioning = false
local data = hs.json.read(obj.layoutFile)
if not data or not data.windows then
log("Restore FAILED: No data found in JSON.")
return
end
obj.isRestoring = true
obj.isRescued = false
hs.screen.restoreGamma()
local savedByApp = {}
for _, winData in ipairs(data.windows) do
if not ignoreListItems[winData.appName] 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
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()
local appName = app and app:name() or ""
if win and win:isVisible() and win:frame().w > 0 and not ignoreListItems[appName] 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 + (maxFrame.w / 2) - (f.w / 2) + staggerOffset
f.y = maxFrame.y + (maxFrame.h / 2) - (f.h / 2) + staggerOffset
win:setFrame(f, 0)
staggerOffset = staggerOffset + 30
if staggerOffset > 150 then staggerOffset = 0 end
end
end)
end
obj.isRescued = true
hs.alert.show("Windows Cascaded", 1.5)
end
-- ==========================================
-- MENUBAR & WATCHERS
-- ==========================================
local saveCountdown = obj.saveInterval
local timerMenu = hs.menubar.new()
function updateMenu()
if timerMenu then
local screens = hs.screen.allScreens()
timerMenu:setTitle(string.format("💠 %d:%02d", math.floor(saveCountdown / 60), 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); 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
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
if obj.autoSaveTimer then obj.autoSaveTimer: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.")
saveCountdown = obj.saveInterval
if obj.autoSaveTimer then obj.autoSaveTimer:start() end
if obj.wakeTimer then obj.wakeTimer:stop() end
obj.wakeTimer = hs.timer.doAfter(12, function()
obj.isTransitioning = false
obj.isRestoring = false
obj.lastScreenCount = #hs.screen.allScreens()
obj.restoreLayout()
obj.wakeTimer = nil
end)
end
end):start()
hs.hotkey.bind({"shift", "cmd"}, "W", function() obj.saveLayout(false); 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
log("DOCK EVENT: Detected.")
obj.isTransitioning = true
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)
saveCountdown = obj.saveInterval
end)
obj.clockTimer = hs.timer.doEvery(1, function()
saveCountdown = saveCountdown - 1
if saveCountdown < 0 then saveCountdown = obj.saveInterval end
updateMenu()
end)
updateMenu()
return obj
-------------------
[2026-05-05 17:53:18]
dkhjfkdjflkadjkf dakjf das
dfjdskfjdkalfja
-------------------
[2026-05-05 17:58:28]
dhfjdhfjkhdsjkfdjfhjdahfa
dfdhjlfhdkjhfdjfg
dfjhdkjfhd
-------------------
[2026-05-05 18:00:05]
fgfgfgff
-------------------
+129
View File
@@ -0,0 +1,129 @@
-- ~/.hammerspoon/Monocle.lua
local Monocle = {}
-- CONFIGURATION
local blurRadius = 20
local dimAlpha = 0.3
local shakeTrigger = 250 -- Energy required (higher = harder shake)
local overlay = nil
local filter = nil
local mouseTimer = nil
local clickWatcher = nil
local energy = 0
function Monocle.show()
local screen = hs.screen.mainScreen()
local res = screen:fullFrame()
local win = hs.window.focusedWindow()
if not win then return end
if not overlay then
overlay = hs.canvas.new(res)
overlay:level(101)
overlay:behavior({
hs.canvas.windowBehaviors.canJoinAllSpaces,
hs.canvas.windowBehaviors.ignoresMouseEvents
})
-- Layer 1: Background Dimming
overlay[1] = {
type = "rectangle",
action = "fill",
fillColor = { black = 1, alpha = dimAlpha },
frame = { x = 0, y = 0, w = "100%", h = "100%" },
}
-- APPLY THE BLUR FILTER TO THE WHOLE CANVAS
overlay:setFilter({
name = "CIGaussianBlur",
inputRadius = blurRadius
})
end
overlay:frame(res)
overlay:show()
win:raise()
end
function Monocle.hide()
if overlay then overlay:hide() end
energy = 0
end
function Monocle.toggle()
if overlay and overlay:isShowing() then
Monocle.hide()
else
Monocle.show()
end
end
function Monocle.init()
-- 1. HYPERKEY BINDING
if hyper then
hs.hotkey.bind(hyper, "f", function()
Monocle.toggle()
end)
end
-- 2. Focus Follower
filter = hs.window.filter.new(nil)
filter:subscribe(hs.window.filter.windowFocused, function()
if overlay and overlay:isShowing() then
Monocle.show()
end
end)
-- 3. Click Outside to Disable
clickWatcher = hs.eventtap.new({1}, function(event)
if overlay and overlay:isShowing() then
local clickPoint = event:location()
local win = hs.window.focusedWindow()
if win then
local f = win:frame()
local isInside = (clickPoint.x >= f.x and clickPoint.x <= (f.x + f.w) and
clickPoint.y >= f.y and clickPoint.y <= (f.y + f.h))
if not isInside then
Monocle.hide()
end
end
end
return false
end):start()
-- 4. Vector-Based Shake Detection
local lastPos = hs.mouse.absolutePosition()
local lastDx, lastDy = 0, 0
mouseTimer = hs.timer.doEvery(0.02, function()
local newPos = hs.mouse.absolutePosition()
local dx = newPos.x - lastPos.x
local dy = newPos.y - lastPos.y
-- If current direction (dx) is opposite of last direction (lastDx)
-- That indicates a shake reversal.
local reversedX = (dx * lastDx < 0)
local reversedY = (dy * lastDy < 0)
if reversedX or reversedY then
local speed = math.sqrt(dx*dx + dy*dy)
if speed > 15 then
energy = energy + (speed * 2)
end
else
-- Constant decay (15 units per cycle)
energy = math.max(0, energy - 15)
end
if energy > shakeTrigger then
Monocle.toggle()
energy = 0
end
lastDx, lastDy = dx, dy
lastPos = newPos
end)
end
return Monocle
+85
View File
@@ -0,0 +1,85 @@
local prod = {}
-- ==========================================
-- 1. SMART APP SWITCHER (Focus & Mouse Center)
-- ==========================================
function prod.focusApp(appName)
local app = hs.application.get(appName)
if app then
local window = app:mainWindow()
if window then
window:unminimize()
window:focus()
local f = window:frame()
-- Centers mouse on the focused window
hs.mouse.absolutePosition({x = f.x + f.w/2, y = f.y + f.h/2})
end
else
hs.application.launchOrFocus(appName)
end
end
-- KEY BINDINGS for Item 1
hs.hotkey.bind({"alt"}, "T", function() prod.focusApp("Terminal") end)
hs.hotkey.bind({"alt"}, "G", function() prod.focusApp("Gemini") end)
hs.hotkey.bind({"alt"}, "V", function() prod.focusApp("Visual Studio Code") end)
-- ==========================================
-- 2. GHOST HUNTER (Cleanup & Panic Reset)
-- ==========================================
function prod.ghostHunter()
local windows = hs.window.allWindows()
local zombies = 0
local rescued = 0
for _, win in ipairs(windows) do
local app = win:application()
local frame = win:frame()
-- Identify Zombie (No app or no title)
if not app or (win:title() == "" and not win:isStandard()) then
zombies = zombies + 1
end
-- Rescuing windows stuck in "Deep Space" (off-screen coordinates)
if frame.x < -5000 or frame.y < -5000 or frame.x > 10000 or frame.y > 10000 then
win:moveToScreen(hs.screen.primaryScreen())
rescued = rescued + 1
end
end
-- FIX: Replaced forceRefresh() with the default window filter refresh
hs.window.filter.default:getWindows()
hs.alert.show(string.format("Hunter: %d Ghosts | %d Rescued", zombies, rescued), 2)
end
-- KEY BINDING for Item 2 (Panic Key)
hs.hotkey.bind({"shift", "alt"}, "G", prod.ghostHunter)
-- ==========================================
-- 3. CONTEXTUAL MODES (Workspaces)
-- ==========================================
function prod.modeArchitecture()
hs.alert.show("Mode: Architecture (Deep Work)", 2)
hs.application.launchOrFocus("Visual Studio Code")
hs.application.launchOrFocus("Terminal")
-- Hide distractions
local teams = hs.application.get("Microsoft Teams")
if teams then teams:hide() end
end
function prod.modeCommunication()
hs.alert.show("Mode: Communication (Email/Teams)", 2)
hs.application.launchOrFocus("Microsoft Outlook")
hs.application.launchOrFocus("Microsoft Teams")
end
-- KEY BINDINGS for Item 3
hs.hotkey.bind({"shift", "alt"}, "A", prod.modeArchitecture)
hs.hotkey.bind({"shift", "alt"}, "C", prod.modeCommunication)
-- Loading notification
hs.alert.show("🚀 Productivity Tools Loaded", 1.5)
return prod
+51
View File
@@ -0,0 +1,51 @@
{
"windows" : [
{
"x" : 0,
"bundleID" : "pro.affine.app",
"y" : 33,
"appName" : "AFFiNE",
"h" : 855,
"w" : 756,
"winTitle" : "AFFiNE"
},
{
"x" : 0,
"bundleID" : "com.google.Chrome",
"y" : 33,
"appName" : "Google Chrome",
"h" : 856,
"w" : 1512,
"winTitle" : "gitea vscode - Google Search - Google Chrome"
},
{
"x" : 483,
"bundleID" : "com.apple.Terminal",
"y" : 293,
"appName" : "Terminal",
"h" : 514,
"w" : 909,
"winTitle" : "francop — -zsh — 127×31"
},
{
"x" : 45,
"bundleID" : "com.apple.Terminal",
"y" : 62,
"appName" : "Terminal",
"h" : 514,
"w" : 909,
"winTitle" : "francop — francop@mediabox3: \/mnt\/gdrive\/Media\/Audiobooks — ssh francop@192.168.1.101 — 127×31"
},
{
"x" : 0,
"bundleID" : "com.microsoft.VSCode",
"y" : 33,
"appName" : "Code",
"h" : 855,
"w" : 1512,
"winTitle" : "Extension: Gitea for VS Code — .hammerspoon"
}
],
"screenCount" : 1,
"saveTime" : "15:35:58"
}
BIN
View File
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
{
"cookies": [
{
"name": "affine_session",
"value": "63cc1db4-6544-499c-858b-e3a82a1855f5",
"domain": "myaff.duckdns.org",
"path": "/",
"expires": 1779294796.897129,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": "affine_csrf_token",
"value": "e4ecf66b-b50c-4726-b73a-79375f912853",
"domain": "myaff.duckdns.org",
"path": "/",
"expires": 1779294796.897768,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "affine_user_id",
"value": "402ce565-c2bf-499c-94ee-c5e89f3cbb9c",
"domain": "myaff.duckdns.org",
"path": "/",
"expires": -1,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://myaff.duckdns.org:3010",
"localStorage": [
{
"name": "global-cache:workspace-information:fc2caf2b-3e3e-48cc-9533-4eb63b4753cf",
"value": "{\"name\":\"Franco's Workspace\",\"isOwner\":true,\"isAdmin\":false,\"isTeam\":false,\"isEmpty\":false}"
},
{
"name": "global-state:workspace-state:aG5wUrCkEh2MGImWPbHBp:recent-pages",
"value": "[\"yOPjnOSCGj\"]"
},
{
"name": "global-state:cloud-workspace:402ce565-c2bf-499c-94ee-c5e89f3cbb9c",
"value": "[{\"id\":\"fc2caf2b-3e3e-48cc-9533-4eb63b4753cf\",\"flavour\":\"affine-cloud\",\"initialized\":true}]"
},
{
"name": "affine_telemetry_session_number",
"value": "1"
},
{
"name": "global-cache:i18n_lng",
"value": "\"en\""
},
{
"name": "global-cache:subscription:402ce565-c2bf-499c-94ee-c5e89f3cbb9c",
"value": "[]"
},
{
"name": "global-cache:workspace-information:aG5wUrCkEh2MGImWPbHBp",
"value": "{\"name\":\"Demo Workspace\",\"isOwner\":true}"
},
{
"name": "global-state:workspace-state:fc2caf2b-3e3e-48cc-9533-4eb63b4753cf:recent-pages",
"value": "[\"M5HIySO_xVmitXRc3ZjdQ\"]"
},
{
"name": "global-state:affine-cloud-auth",
"value": "{\"account\":{\"id\":\"402ce565-c2bf-499c-94ee-c5e89f3cbb9c\",\"email\":\"franco.pellicciotti@gmail.com\",\"label\":\"Franco\",\"avatar\":null,\"info\":{\"id\":\"402ce565-c2bf-499c-94ee-c5e89f3cbb9c\",\"email\":\"franco.pellicciotti@gmail.com\",\"avatarUrl\":null,\"name\":\"Franco\",\"disabled\":false,\"hasPassword\":true,\"emailVerified\":false}}}"
},
{
"name": "global-state:workspace-state:aG5wUrCkEh2MGImWPbHBp:permission",
"value": "{\"isOwner\":true,\"isAdmin\":false,\"isTeam\":false}"
},
{
"name": "last_page_id",
"value": "M5HIySO_xVmitXRc3ZjdQ"
},
{
"name": "is-first-open",
"value": "false"
},
{
"name": "affine_telemetry_client_id",
"value": "hZf8xS8AYmKB4fDZVibca"
},
{
"name": "global-state:workspace-state:fc2caf2b-3e3e-48cc-9533-4eb63b4753cf:permission",
"value": "{\"isOwner\":true,\"isAdmin\":false,\"isTeam\":false}"
},
{
"name": "global-state:serverConfig:affine-cloud",
"value": "{\"credentialsRequirement\":{\"password\":{\"minLength\":8,\"maxLength\":32}},\"features\":[\"Comment\",\"LocalWorkspace\",\"CopilotEmbedding\",\"Copilot\"],\"oauthProviders\":[],\"serverName\":\"AFFiNE SelfHosted Cloud\",\"type\":\"Selfhosted\",\"version\":\"0.26.6\",\"initialized\":true}"
},
{
"name": "last_workspace_id",
"value": "fc2caf2b-3e3e-48cc-9533-4eb63b4753cf"
},
{
"name": "affine-local-workspace",
"value": "[\"aG5wUrCkEh2MGImWPbHBp\"]"
},
{
"name": "global-state:workspaceSelectorOpen",
"value": "false"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../playwright/cli.js
+1
View File
@@ -0,0 +1 @@
../playwright-core/cli.js
+38
View File
@@ -0,0 +1,38 @@
{
"name": "scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
+3
View File
@@ -0,0 +1,3 @@
# playwright-core
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
# check if running on Windows Server
if ($osInfo.ProductType -eq 3) {
Install-WindowsFeature Server-Media-Foundation
}
+33
View File
@@ -0,0 +1,33 @@
$ErrorActionPreference = 'Stop'
# This script sets up a WSL distribution that will be used to run WebKit.
$Distribution = "playwright"
$Username = "pwuser"
$distributions = (wsl --list --quiet) -split "\r?\n"
if ($distributions -contains $Distribution) {
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
} else {
Write-Host "Installing new WSL distribution '$Distribution'..."
$VhdSize = "10GB"
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
}
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
$initScript = @"
if [ ! -f "/home/$Username/node/bin/node" ]; then
mkdir -p /home/$Username/node
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
fi
/home/$Username/node/bin/node cli.js install-deps webkit
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
"@ -replace "\r\n", "`n"
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
Write-Host "Done!"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome-beta
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome beta from dl.google.com and install it.
cd /tmp
curl -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
apt-get install -y ./google-chrome-beta_current_amd64.deb
rm -rf ./google-chrome-beta_current_amd64.deb
cd -
google-chrome-beta --version
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome Beta.app"
cd /tmp
curl --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
hdiutil detach /Volumes/googlechromebeta.dmg
rm -rf /tmp/googlechromebeta.dmg
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
+24
View File
@@ -0,0 +1,24 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
Write-Host "Downloading Google Chrome Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
apt-get remove -y google-chrome
fi
# 2. Update apt lists (needed to install curl and chrome dependencies)
apt-get update
# 3. Install curl to download chrome
if ! command -v curl >/dev/null; then
apt-get install -y curl
fi
# 4. download chrome stable from dl.google.com and install it.
cd /tmp
curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt-get install -y ./google-chrome-stable_current_amd64.deb
rm -rf ./google-chrome-stable_current_amd64.deb
cd -
google-chrome --version
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
set -x
rm -rf "/Applications/Google Chrome.app"
cd /tmp
curl --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
hdiutil detach /Volumes/googlechrome.dmg
rm -rf /tmp/googlechrome.dmg
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
@@ -0,0 +1,24 @@
$ErrorActionPreference = 'Stop'
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\google-chrome.msi"
Write-Host "Downloading Google Chrome"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Google Chrome"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Google Chrome."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old beta if any.
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-beta
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-beta
microsoft-edge-beta --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl --retry 3 -o ./msedge_beta.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_beta.pkg -target /
rm -rf /tmp/msedge_beta.pkg
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
+23
View File
@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Beta"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Beta"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old dev if any.
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-dev
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-dev
microsoft-edge-dev --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl --retry 3 -o ./msedge_dev.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_dev.pkg -target /
rm -rf /tmp/msedge_dev.pkg
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
+23
View File
@@ -0,0 +1,23 @@
$ErrorActionPreference = 'Stop'
$url = $args[0]
Write-Host "Downloading Microsoft Edge Dev"
$wc = New-Object net.webclient
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
$wc.Downloadfile($url, $msiInstaller)
Write-Host "Installing Microsoft Edge Dev"
$arguments = "/i `"$msiInstaller`" /quiet"
Start-Process msiexec.exe -ArgumentList $arguments -Wait
Remove-Item $msiInstaller
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
} else {
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
exit 1
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -e
set -x
if [[ $(arch) == "aarch64" ]]; then
echo "ERROR: not supported on Linux Arm64"
exit 1
fi
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
if [[ ! -f "/etc/os-release" ]]; then
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
exit 1
fi
ID=$(bash -c 'source /etc/os-release && echo $ID')
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
exit 1
fi
fi
# 1. make sure to remove old stable if any.
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
apt-get remove -y microsoft-edge-stable
fi
# 2. Install curl to download Microsoft gpg key
if ! command -v curl >/dev/null; then
apt-get update
apt-get install -y curl
fi
# GnuPG is not preinstalled in slim images
if ! command -v gpg >/dev/null; then
apt-get update
apt-get install -y gpg
fi
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
rm /tmp/microsoft.gpg
apt-get update && apt-get install -y microsoft-edge-stable
microsoft-edge-stable --version
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
set -x
cd /tmp
curl --retry 3 -o ./msedge_stable.pkg "$1"
# Note: there's no way to uninstall previously installed MSEdge.
# However, running PKG again seems to update installation.
sudo installer -pkg /tmp/msedge_stable.pkg -target /
rm -rf /tmp/msedge_stable.pkg
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version

Some files were not shown because too many files have changed in this diff Show More