326 lines
13 KiB
Lua
326 lines
13 KiB
Lua
-- ~/.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 |