feat: add ip-monitor plugin (#207)
* Add ip-monitor plugin * fix: removed typo * feat: increase default refresh interval to 60 seconds * fix: prevent shell injection in IP monitor gateway lookup. * docs: fix author casing in README * feat: add configurable click actions * feat: add desktop widget support and refactor core logic - Extracted IP fetching and data parsing into a shared `utils.luau` module to prevent code duplication. - Created `desktop.luau` to display network details explicitly in a tabulated UI. - Added specific configuration options for font sizes and colors for the new desktop widget * feat: add hidden_fields configuration to allow filtering tooltip and desktop widget details * fix: remove raw HTML from README * refactor(utils): use bit32 and fix glob pattern escaping & gateway parsing * refactor(utils): centralize shared UI builder and display name resolution * refactor(widget): use shared utils helpers and clean up duplicate logic * refactor(desktop): use shared utils helpers and add IPC click action parity * docs: update README with desktop widget settings * feat: remove unused IPC copy actions from widget logic
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
-------------------------------------------------------------------------------
|
||||
-- IP Monitor Shared Utilities
|
||||
-------------------------------------------------------------------------------
|
||||
local utils = {}
|
||||
|
||||
--- Calculates IPv4 subnet mask string (e.g. "255.255.255.0") from a CIDR prefix length (0-32).
|
||||
function utils.cidrToSubnetMask(prefix)
|
||||
local n = tonumber(prefix)
|
||||
if not n or n < 0 or n > 32 then return "" end
|
||||
|
||||
local mask32 = (n == 0) and 0 or bit32.lshift(0xFFFFFFFF, 32 - n)
|
||||
local o1 = bit32.rshift(mask32, 24)
|
||||
local o2 = bit32.band(bit32.rshift(mask32, 16), 0xFF)
|
||||
local o3 = bit32.band(bit32.rshift(mask32, 8), 0xFF)
|
||||
local o4 = bit32.band(mask32, 0xFF)
|
||||
|
||||
return string.format("%d.%d.%d.%d", o1, o2, o3, o4)
|
||||
end
|
||||
|
||||
--- Converts a simple glob pattern (e.g. "wlan*", "eth0") into a standard Lua pattern.
|
||||
function utils.globToPattern(glob)
|
||||
local escaped = glob:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%1")
|
||||
local pattern = escaped:gsub("%%%*", ".*")
|
||||
return "^" .. pattern .. "$"
|
||||
end
|
||||
|
||||
--- Calculates network address (e.g. "192.168.1.0/24") from IP, Subnet Mask, and CIDR prefix.
|
||||
function utils.calculateNetwork(ipStr, maskStr, cidrStr)
|
||||
local a, b, c, d = ipStr:match("(%d+)%.(%d+)%.(%d+)%.(%d+)")
|
||||
local m1, m2, m3, m4 = maskStr:match("(%d+)%.(%d+)%.(%d+)%.(%d+)")
|
||||
if not (a and b and c and d and m1 and m2 and m3 and m4) then return "" end
|
||||
|
||||
local net1 = bit32.band(tonumber(a), tonumber(m1))
|
||||
local net2 = bit32.band(tonumber(b), tonumber(m2))
|
||||
local net3 = bit32.band(tonumber(c), tonumber(m3))
|
||||
local net4 = bit32.band(tonumber(d), tonumber(m4))
|
||||
|
||||
return string.format("%d.%d.%d.%d/%s", net1, net2, net3, net4, cidrStr)
|
||||
end
|
||||
|
||||
--- Fetches IPv4 address and detailed network interface metadata.
|
||||
function utils.fetchInterfaceIp(ifaceSetting, callback)
|
||||
if not ifaceSetting or ifaceSetting == "" then
|
||||
callback("", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local pattern = utils.globToPattern(ifaceSetting)
|
||||
|
||||
noctalia.runAsync("ip -4 -o addr show", function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.log("ip-monitor: failed to get ip addr: " .. result.stderr)
|
||||
callback("", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local matchedIface = nil
|
||||
local foundIp = ""
|
||||
local foundCidr = ""
|
||||
local foundBrd = ""
|
||||
|
||||
for line in result.stdout:gmatch("[^\r\n]+") do
|
||||
local ifaceName, inetInfo = line:match("%d+:%s+([^%s]+)%s+inet%s+([^%s]+)")
|
||||
if ifaceName and inetInfo and ifaceName:match(pattern) then
|
||||
local ipOnly, cidr = inetInfo:match("([^/]+)/(%d+)")
|
||||
if ipOnly then
|
||||
matchedIface = ifaceName
|
||||
foundIp = ipOnly
|
||||
foundCidr = cidr or ""
|
||||
foundBrd = line:match("%s+brd%s+([^%s]+)") or ""
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not matchedIface or foundIp == "" then
|
||||
callback("", nil)
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate Mask and Network Range
|
||||
local mask = utils.cidrToSubnetMask(foundCidr)
|
||||
local network = ""
|
||||
if foundIp ~= "" and mask ~= "" and foundCidr ~= "" then
|
||||
network = utils.calculateNetwork(foundIp, mask, foundCidr)
|
||||
end
|
||||
|
||||
-- Fetch gateway
|
||||
noctalia.runAsync("ip route show", function(routeResult)
|
||||
local gateway = ""
|
||||
if routeResult.exitCode == 0 then
|
||||
-- 1. Try finding default route for matched interface
|
||||
for line in routeResult.stdout:gmatch("[^\r\n]+") do
|
||||
local lineDev = line:match("%sdev%s+([^%s]+)")
|
||||
if lineDev == matchedIface then
|
||||
local g = line:match("default via ([^%s]+)")
|
||||
if g then
|
||||
gateway = g
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 2. Fallback: find any route with via for matched interface
|
||||
if gateway == "" then
|
||||
for line in routeResult.stdout:gmatch("[^\r\n]+") do
|
||||
local lineDev = line:match("%sdev%s+([^%s]+)")
|
||||
if lineDev == matchedIface then
|
||||
local g = line:match("via ([^%s]+)")
|
||||
if g then
|
||||
gateway = g
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local tooltip = {
|
||||
iface = matchedIface,
|
||||
ip = foundIp,
|
||||
network = network,
|
||||
gateway = gateway,
|
||||
mask = mask,
|
||||
broadcast = foundBrd
|
||||
}
|
||||
callback(foundIp, tooltip)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Fetches IP address by executing a custom shell command.
|
||||
function utils.fetchCustomCommandIp(cmd, callback)
|
||||
if not cmd or cmd == "" then
|
||||
callback("", nil)
|
||||
return
|
||||
end
|
||||
|
||||
noctalia.runAsync(cmd, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.log("ip-monitor: custom command failed: " .. result.stderr)
|
||||
callback("", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local newIp = noctalia.string.trim(result.stdout)
|
||||
local tooltip = { ip = newIp }
|
||||
callback(newIp, tooltip)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Parses an IPC payload and extracts data if it matches the configured ID.
|
||||
function utils.handleIpcPayload(payload, myId)
|
||||
local ok, data = pcall(noctalia.json.decode, payload)
|
||||
if not ok or type(data) ~= "table" then return nil end
|
||||
|
||||
local targetId = data.id or "default"
|
||||
if targetId ~= myId then return nil end
|
||||
|
||||
local tt = (type(data.tooltip) == "table") and data.tooltip or {}
|
||||
local tooltip = {
|
||||
iface = tt.iface or data.iface or data.interface or nil,
|
||||
ip = tt.ip or data.ip or nil,
|
||||
network = tt.network or data.network or nil,
|
||||
gateway = tt.gateway or data.gateway or data.default_route or nil,
|
||||
mask = tt.mask or data.mask or nil,
|
||||
broadcast = tt.broadcast or data.broadcast or nil
|
||||
}
|
||||
|
||||
return {
|
||||
ip = data.ip or "",
|
||||
name = data.name or "",
|
||||
tooltip = tooltip
|
||||
}
|
||||
end
|
||||
|
||||
--- Parses a comma-separated string of hidden fields into a Set for O(1) lookups.
|
||||
-- Also maps some common aliases (e.g., "net" to "network").
|
||||
function utils.parseHiddenFields(configStr)
|
||||
local hidden = {}
|
||||
if type(configStr) ~= "string" or configStr == "" then
|
||||
return hidden
|
||||
end
|
||||
|
||||
local aliases = {
|
||||
["net"] = "network",
|
||||
["gw"] = "gateway",
|
||||
["def"] = "gateway",
|
||||
["route"] = "gateway"
|
||||
}
|
||||
|
||||
for field in configStr:gmatch("[^,]+") do
|
||||
local cleanField = field:match("^%s*(.-)%s*$"):lower()
|
||||
if cleanField ~= "" then
|
||||
local actualField = aliases[cleanField] or cleanField
|
||||
hidden[actualField] = true
|
||||
end
|
||||
end
|
||||
return hidden
|
||||
end
|
||||
|
||||
--- Resolves the active display name according to the active mode and IPC state.
|
||||
function utils.getDisplayName(mode, currentIpcName, staticName)
|
||||
if mode == "ipc" and currentIpcName and currentIpcName ~= "" then
|
||||
return currentIpcName
|
||||
end
|
||||
return staticName or ""
|
||||
end
|
||||
|
||||
--- Builds the top row UI components (Glyph, IP Label, Separator, Name Label).
|
||||
-- @param currentIp string The IP address string
|
||||
-- @param displayName string The resolved display name
|
||||
-- @param fontSize integer|nil Optional font size for desktop widget
|
||||
-- @return table List of UI component elements
|
||||
function utils.buildTopRow(currentIp, displayName, fontSize)
|
||||
local content = {}
|
||||
|
||||
local glyph = noctalia.getConfig("glyph")
|
||||
if glyph and glyph ~= "" then
|
||||
local glyphOpts = {
|
||||
name = glyph,
|
||||
color = noctalia.getConfig("glyph_color")
|
||||
}
|
||||
if fontSize then
|
||||
glyphOpts.size = fontSize
|
||||
end
|
||||
table.insert(content, ui.glyph(glyphOpts))
|
||||
end
|
||||
|
||||
local hasIp = currentIp and currentIp ~= ""
|
||||
local hasName = displayName and displayName ~= ""
|
||||
|
||||
local function addLabel(text, colorKey)
|
||||
if text and text ~= "" then
|
||||
local labelOpts = {
|
||||
text = text,
|
||||
color = noctalia.getConfig(colorKey)
|
||||
}
|
||||
if fontSize then
|
||||
labelOpts.fontSize = fontSize
|
||||
end
|
||||
table.insert(content, ui.label(labelOpts))
|
||||
end
|
||||
end
|
||||
|
||||
if hasIp then
|
||||
addLabel(currentIp, "text_color")
|
||||
end
|
||||
|
||||
if hasIp and hasName then
|
||||
local sep = noctalia.getConfig("separator")
|
||||
if sep == nil then sep = "-" end
|
||||
addLabel(sep, "separator_color")
|
||||
end
|
||||
|
||||
if hasName then
|
||||
addLabel(displayName, "name_color")
|
||||
end
|
||||
|
||||
return content
|
||||
end
|
||||
|
||||
return utils
|
||||
Reference in New Issue
Block a user