From 0305bf4f09bed0e631907b05da005501ee5af5e6 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sun, 2 Aug 2026 00:56:07 +0100 Subject: [PATCH 01/15] Add ip-monitor plugin --- ip-monitor/README.md | 90 ++++++++ ip-monitor/plugin.toml | 120 ++++++++++ ip-monitor/thumbnail.webp | Bin 0 -> 55574 bytes ip-monitor/translations/en.json | 78 +++++++ ip-monitor/widget.luau | 376 ++++++++++++++++++++++++++++++++ 5 files changed, 664 insertions(+) create mode 100644 ip-monitor/README.md create mode 100644 ip-monitor/plugin.toml create mode 100644 ip-monitor/thumbnail.webp create mode 100644 ip-monitor/translations/en.json create mode 100644 ip-monitor/widget.luau diff --git a/ip-monitor/README.md b/ip-monitor/README.md new file mode 100644 index 00000000..add6270b --- /dev/null +++ b/ip-monitor/README.md @@ -0,0 +1,90 @@ +# IP Monitor + +A Noctalia bar widget to monitor network IPs. +It supports fetching IPs from network interfaces, custom commands, or via IPC. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `3ri4ng0ld/ip-monitor` | +| Entries | Bar widget: `widget` | + +## Requirements + +- `ip` (part of `iproute2`): Required to resolve IP and gateway in Interface mode. +- `curl`: Required if using the default custom command to fetch public IPs. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `glyph` | `glyph` | `network` | The icon glyph displayed on the widget | +| `glyph_color` | `color` | `on_surface` | Color of the icon | +| `mode` | `select` | `interface` | Operating mode: Interface, Custom Command, or IPC | +| `iface` | `string` | `wlan*` | Network interface wildcard to fetch IP from | +| `custom_command` | `string` | `curl -s ifconfig.me` | Shell command to execute in custom command mode | +| `text_color` | `color` | `on_surface` | Color of the IP text | +| `name` | `string` | `""` | A custom name to display alongside the IP | +| `ipc_id` | `string` | `default` | Identifier used to target this specific widget via IPC | +| `name_color` | `color` | `on_surface_variant` | Color of the name text | +| `separator` | `string` | `-` | Separator symbol between IP and Name | +| `separator_color` | `color` | `on_surface_variant` | Color of the separator | +| `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | +| `refresh_interval` | `int` | `10` | Refresh interval in seconds (max 3600) | + +## Usage + +Add the widget to a bar from *Settings → Bar*. Plugin options live in *Settings → Plugins*. + +### Modes + +1. **Interface**: Fetches the IP from a local network interface. The `Interface` setting supports wildcards, for example `wlan*` or `eth*`, and will select the first matching interface that has a valid IP address. +2. **Custom Command**: Runs a shell command to fetch the IP. For example, `curl -s ifconfig.me` for the public IP. +3. **IPC**: Listens to external events to set the IP and name. + +### IPC + +The widget listens to the `set` event. By default, the widget is assigned the IPC ID `default`. To set the IP and name for a default widget, use the following command (you don't need to specify an ID in the JSON, it defaults to `default`): + +```sh +noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"ip":"192.168.1.5","name":"MyIP"}' +``` + +If you have multiple `ip-monitor` widgets in IPC mode and want to update them independently, you can change the **IPC ID** setting for each widget. Then, target them by passing their ID string in the JSON payload: + +```sh +noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"id":"my_vpn","ip":"100.64.0.1","name":"VPN"}' +noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"id":"my_local_iface","ip":"192.168.1.5","name":"Ethernet"}' +``` + +### Tooltips + +Hovering over the widget displays detailed network information if available: +- **Interface**: Network interface name (e.g. `eth0`) +- **IP**: IPv4 address +- **Network**: Network range (e.g. `192.168.1.0/24`) +- **Default route**: Gateway address (e.g. `192.168.1.1`) +- **Mask**: Subnet mask (e.g. `255.255.255.0`) +- **Broadcast**: Broadcast address (e.g. `192.168.1.255`) + +In **IPC Mode**, tooltip fields can be passed optionally in the JSON payload (either as top-level keys or nested inside a `tooltip` object): + +```sh +noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{ + "id": "my_vpn", + "ip": "100.64.0.2", + "name": "Tailscale", + "iface": "tailscale0", + "network": "100.64.0.0/10", + "gateway": "100.64.0.1", + "mask": "255.192.0.0" +}' +``` + +### Click Actions + +You can configure what happens when you left-click or right-click the widget: +- Copy IP +- Copy Name +- None diff --git a/ip-monitor/plugin.toml b/ip-monitor/plugin.toml new file mode 100644 index 00000000..7c28c4dc --- /dev/null +++ b/ip-monitor/plugin.toml @@ -0,0 +1,120 @@ +# ip-monitor — monitor network IPs + +id = "3ri4ng0ld/ip-monitor" +name = "IP Monitor" +version = "0.0.1" +plugin_api = 3 +author = "3ri4nG0ld" +license = "MIT" +deprecated = false +icon = "network" +description = "Bar widget to monitor IPs from interfaces, custom commands, or IPC." +tags = ["network"] +dependencies = ["ip", "curl"] + +[[widget]] +id = "widget" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "network" + + [[widget.setting]] + key = "glyph_color" + type = "color" + label_key = "settings.glyph_color.label" + description_key = "settings.glyph_color.description" + default = "on_surface" + + [[widget.setting]] + key = "mode" + type = "select" + label_key = "settings.mode.label" + description_key = "settings.mode.description" + default = "interface" + options = [ + { value = "interface", label_key = "settings.mode.options.interface" }, + { value = "ipc", label_key = "settings.mode.options.ipc" }, + { value = "custom_command", label_key = "settings.mode.options.custom_command" }, + ] + + [[widget.setting]] + key = "iface" + type = "string" + label_key = "settings.iface.label" + description_key = "settings.iface.description" + default = "wlan*" + visible_when = { key = "mode", values = ["interface"] } + + [[widget.setting]] + key = "custom_command" + type = "string" + label_key = "settings.custom_command.label" + description_key = "settings.custom_command.description" + default = "curl -s ifconfig.me" + visible_when = { key = "mode", values = ["custom_command"] } + + [[widget.setting]] + key = "text_color" + type = "color" + label_key = "settings.text_color.label" + description_key = "settings.text_color.description" + default = "on_surface" + + [[widget.setting]] + key = "name" + type = "string" + label_key = "settings.name.label" + description_key = "settings.name.description" + default = "" + visible_when = { key = "mode", values = ["interface", "custom_command"] } + + [[widget.setting]] + key = "ipc_id" + type = "string" + label_key = "settings.ipc_id.label" + description_key = "settings.ipc_id.description" + default = "default" + visible_when = { key = "mode", values = ["ipc"] } + + [[widget.setting]] + key = "name_color" + type = "color" + label_key = "settings.name_color.label" + description_key = "settings.name_color.description" + default = "on_surface_variant" + + [[widget.setting]] + key = "separator" + type = "string" + label_key = "settings.separator.label" + description_key = "settings.separator.description" + default = "-" + + [[widget.setting]] + key = "separator_color" + type = "color" + label_key = "settings.separator_color.label" + description_key = "settings.separator_color.description" + default = "on_surface_variant" + + [[widget.setting]] + key = "hide_on_empty" + type = "bool" + label_key = "settings.hide_on_empty.label" + description_key = "settings.hide_on_empty.description" + default = true + + [[widget.setting]] + key = "refresh_interval" + type = "int" + label_key = "settings.refresh_interval.label" + description_key = "settings.refresh_interval.description" + default = 10 + min = 1 + max = 3600 + visible_when = { key = "mode", values = ["interface", "custom_command"] } diff --git a/ip-monitor/thumbnail.webp b/ip-monitor/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..d6b84bd7130b703133f750c525288a7e0fdc6972 GIT binary patch literal 55574 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

yqcTWYFL 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 literal 0 HcmV?d00001 diff --git a/ip-monitor/translations/en.json b/ip-monitor/translations/en.json new file mode 100644 index 00000000..41919696 --- /dev/null +++ b/ip-monitor/translations/en.json @@ -0,0 +1,78 @@ +{ + "name": "IP Monitor", + "description": "Bar widget to monitor IPs from interfaces, custom commands, or IPC.", + "tooltip": { + "iface": "Interface", + "ip": "IP", + "network": "Network", + "gateway": "Default route", + "mask": "Mask", + "broadcast": "Broadcast" + }, + "settings": { + "glyph": { + "label": "Icon", + "description": "Icon to show before the IP." + }, + "glyph_color": { + "label": "Icon Color", + "description": "Color of the icon." + }, + "mode": { + "label": "Mode", + "description": "How to fetch the IP.", + "options": { + "interface": "Network Interface", + "ipc": "IPC", + "custom_command": "Custom Command" + } + }, + "iface": { + "label": "Interface", + "description": "Pattern to match network interfaces (e.g. wlan*, eth0)." + }, + "custom_command": { + "label": "Custom Command", + "description": "Command to run to fetch the IP." + }, + "text_color": { + "label": "IP Text Color", + "description": "Color of the IP text." + }, + "name": { + "label": "Name", + "description": "Optional name to show next to the IP." + }, + "name_color": { + "label": "Name Text Color", + "description": "Color of the name text." + }, + "separator": { + "label": "Separator", + "description": "Character to separate the IP and the name (e.g. -)." + }, + "ipc_id": { + "label": "IPC ID", + "description": "String ID used to target this specific widget via IPC JSON payload." + }, + "separator_color": { + "label": "Separator Color", + "description": "Color of the separator." + }, + "hide_on_empty": { + "label": "Hide on Empty", + "description": "Hide the widget entirely when no IP is found." + }, + "refresh_interval": { + "label": "Refresh Interval", + "description": "How often to refresh the IP (in seconds)." + }, + "click": { + "options": { + "copy_ip": "Copy IP", + "copy_name": "Copy Name", + "none": "None" + } + } + } +} diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau new file mode 100644 index 00000000..5fd77428 --- /dev/null +++ b/ip-monitor/widget.luau @@ -0,0 +1,376 @@ +------------------------------------------------------------------------------- +-- IP Monitor Widget for Noctalia +-- +-- Monitors network IP addresses from network interfaces (with glob matching), +-- custom shell commands, or external IPC events. +------------------------------------------------------------------------------- + +-- Current runtime state +local currentIp = "" +local currentIpcName = "" +local currentTooltip = nil + +--- Calculates IPv4 subnet mask string (e.g. "255.255.255.0") from a CIDR prefix length (0-32). +-- @param prefix string|number CIDR prefix length +-- @return string The formatted subnet mask string +local function cidrToSubnetMask(prefix) + local n = tonumber(prefix) + if not n or n < 0 or n > 32 then return "" end + + local octets = {} + for i = 1, 4 do + if n >= 8 then + table.insert(octets, 255) + n = n - 8 + elseif n > 0 then + local octet = 256 - (2 ^ (8 - n)) + table.insert(octets, math.floor(octet)) + n = 0 + else + table.insert(octets, 0) + end + end + return table.concat(octets, ".") +end + +------------------------------------------------------------------------------- +-- HELPER FUNCTIONS +------------------------------------------------------------------------------- + +--- Resolves the active display name according to the active mode and IPC state. +-- @return string The resolved display name to render or copy +local function getDisplayName() + local mode = noctalia.getConfig("mode") + if mode == "ipc" then + if currentIpcName ~= "" then + return currentIpcName + end + end + return noctalia.getConfig("name") or "" +end + +--- Converts a simple glob pattern (e.g. "wlan*", "eth0") into a standard Lua pattern. +-- @param glob string The glob pattern to convert +-- @return string The converted Lua pattern +local function globToPattern(glob) + local pattern = glob:gsub("%.", "%%."):gsub("%*", ".*") + return "^" .. pattern .. "$" +end + +--- Calculates network address (e.g. "192.168.1.0/24") from IP, Subnet Mask, and CIDR prefix. +-- @param ipStr string IP address (e.g. "192.168.1.33") +-- @param maskStr string Subnet mask (e.g. "255.255.255.0") +-- @param cidrStr string CIDR prefix string (e.g. "24") +-- @return string The calculated network range string +local function 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 + + a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d) + m1, m2, m3, m4 = tonumber(m1), tonumber(m2), tonumber(m3), tonumber(m4) + + local function applyMask(ipPart, maskPart) + return (maskPart == 255) and ipPart or (maskPart == 0 and 0 or (ipPart - (ipPart % (256 - maskPart)))) + end + + local net1 = applyMask(a, m1) + local net2 = applyMask(b, m2) + local net3 = applyMask(c, m3) + local net4 = applyMask(d, m4) + + return string.format("%d.%d.%d.%d/%s", net1, net2, net3, net4, cidrStr) +end + +--- Updates the widget hover tooltip from the currentTooltip table. +local function updateTooltip() + if not currentTooltip then + barWidget.setTooltip(nil) + return + end + + local rows = {} + local fields = { "iface", "ip", "network", "gateway", "mask", "broadcast" } + for _, field in ipairs(fields) do + local val = currentTooltip[field] + if val and val ~= "" then + table.insert(rows, { key = noctalia.tr("tooltip." .. field), value = val }) + end + end + + if #rows > 0 then + barWidget.setTooltip(rows) + else + barWidget.setTooltip(nil) + end +end + +------------------------------------------------------------------------------- +-- UI RENDERING +------------------------------------------------------------------------------- + +--- Renders the bar widget UI based on current state and user settings. +local function render() + local hideOnEmpty = noctalia.getConfig("hide_on_empty") + if hideOnEmpty == nil then hideOnEmpty = true end + + -- Omit widget rendering completely if no IP is present and hide_on_empty is active + if hideOnEmpty and (not currentIp or currentIp == "") then + barWidget.setTooltip(nil) + barWidget.render(ui.row({}, {})) + return + end + + -- Update widget hover tooltip + updateTooltip() + + local content = {} + local displayName = getDisplayName() + + -- 1. Icon / Glyph + local glyph = noctalia.getConfig("glyph") + if glyph and glyph ~= "" then + table.insert(content, ui.glyph({ + name = glyph, + color = noctalia.getConfig("glyph_color") + })) + end + + local hasIp = currentIp and currentIp ~= "" + local hasName = displayName and displayName ~= "" + + local function addLabel(text, colorConfig) + if text and text ~= "" then + table.insert(content, ui.label({ + text = text, + color = noctalia.getConfig(colorConfig) + })) + end + end + + -- 2. IP Text Label + if hasIp then + addLabel(currentIp, "text_color") + end + + -- 3. Separator Label (rendered only when both IP and Name exist) + if hasIp and hasName then + local sep = noctalia.getConfig("separator") + if sep == nil then sep = "-" end + addLabel(sep, "separator_color") + end + + -- 4. Name Text Label + if hasName then + addLabel(displayName, "name_color") + end + + -- Layout container according to bar orientation (Vertical -> column, Horizontal -> row) + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, content)) +end + +------------------------------------------------------------------------------- +-- ACTIONS & HANDLERS +------------------------------------------------------------------------------- + +local function copyNotifyAndLog(text, optionKey, logMessage) + if text and text ~= "" then + noctalia.copyToClipboard(text, "text/plain") + noctalia.notify(noctalia.tr("name"), noctalia.tr(optionKey) .. ": " .. text) + noctalia.log("ip-monitor: " .. logMessage .. ": " .. text) + end +end + +--- Executes click / IPC actions (e.g. copy IP or copy Name to clipboard). +-- @param action string The action identifier ("copy_ip" or "copy_name") +local function applyClickAction(action) + if action == "copy_ip" then + copyNotifyAndLog(currentIp, "settings.click.options.copy_ip", "copied IP to clipboard") + elseif action == "copy_name" then + copyNotifyAndLog(getDisplayName(), "settings.click.options.copy_name", "copied name to clipboard") + end +end + +--- Handler for left click action on the bar widget. +function onClick() + applyClickAction("copy_ip") +end + +--- Handler for right click action on the bar widget. +function onRightClick() + applyClickAction("copy_name") +end + +--- Handler for IPC messages dispatched to this widget. +-- @param event string The IPC event name +-- @param payload string The JSON payload string +function onIpc(event, payload) + if event == "copy_ip" then + applyClickAction("copy_ip") + return + elseif event == "copy_name" then + applyClickAction("copy_name") + return + end + + if noctalia.getConfig("mode") ~= "ipc" then return end + + if event == "set" then + local data = noctalia.json.decode(payload) + if data then + local targetId = data.id or "default" + local myId = noctalia.getConfig("ipc_id") or "default" + + if targetId == myId then + currentIp = data.ip or "" + currentIpcName = data.name or "" + + -- Parse optional tooltip data from payload (supports nested object or top-level keys) + local tt = (type(data.tooltip) == "table") and data.tooltip or {} + currentTooltip = { + 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 + } + + render() + end + end + end +end + +------------------------------------------------------------------------------- +-- IP DATA FETCHERS +------------------------------------------------------------------------------- + +--- Fetches IPv4 address and detailed network interface metadata using `ip -4 -o addr show`. +local function fetchInterfaceIp() + local ifaceSetting = noctalia.getConfig("iface") or "" + if ifaceSetting == "" then return end + + local pattern = 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) + 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 + currentIp = "" + currentTooltip = nil + render() + return + end + + -- Calculate Mask and Network Range + local mask = cidrToSubnetMask(foundCidr) + local network = "" + if foundIp ~= "" and mask ~= "" and foundCidr ~= "" then + network = calculateNetwork(foundIp, mask, foundCidr) + end + + -- Fetch gateway via `ip route show dev ` + noctalia.runAsync("ip route show dev " .. matchedIface, function(routeResult) + local gateway = "" + if routeResult.exitCode == 0 then + for line in routeResult.stdout:gmatch("[^\r\n]+") do + local g = line:match("default via ([^%s]+)") or line:match("via ([^%s]+)") + if g then + gateway = g + break + end + end + end + + currentIp = foundIp + currentTooltip = { + iface = matchedIface, + ip = foundIp, + network = network, + gateway = gateway, + mask = mask, + broadcast = foundBrd + } + render() + end) + end) +end + +--- Fetches IP address by executing a custom shell command. +local function fetchCustomCommandIp() + local cmd = noctalia.getConfig("custom_command") or "" + if cmd == "" then return end + + noctalia.runAsync(cmd, function(result) + if result.exitCode ~= 0 then + noctalia.log("ip-monitor: custom command failed: " .. result.stderr) + return + end + + local newIp = noctalia.string.trim(result.stdout) + currentIp = newIp + currentTooltip = { + ip = newIp + } + render() + end) +end + +------------------------------------------------------------------------------- +-- LIFECYCLE HOOKS +------------------------------------------------------------------------------- + +--- Primary update entry point called periodically by Noctalia script runtime. +function update() + local mode = noctalia.getConfig("mode") + if mode == "interface" then + fetchInterfaceIp() + elseif mode == "custom_command" then + fetchCustomCommandIp() + end +end + +--- Config change handler called automatically when user alters widget settings. +function onConfigChanged() + local mode = noctalia.getConfig("mode") + currentIp = "" + currentIpcName = "" + currentTooltip = nil< + + if mode == "interface" or mode == "custom_command" then + local interval = noctalia.getConfig("refresh_interval") or 10 + noctalia.setUpdateInterval(interval * 1000) + update() + else + -- IPC mode doesn't need periodic updates + noctalia.setUpdateInterval(0) + render() + end +end + +-- Initial setup upon loading widget +onConfigChanged() From edd68e908353d4a8fa0a44d795cc5399384090c7 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sun, 2 Aug 2026 03:30:36 +0100 Subject: [PATCH 02/15] fix: removed typo --- ip-monitor/widget.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index 5fd77428..01c22c0b 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -359,7 +359,7 @@ function onConfigChanged() local mode = noctalia.getConfig("mode") currentIp = "" currentIpcName = "" - currentTooltip = nil< + currentTooltip = nil if mode == "interface" or mode == "custom_command" then local interval = noctalia.getConfig("refresh_interval") or 10 From 42f61138150ddc5a65350bf986509b68ce03d8c2 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sun, 2 Aug 2026 03:57:19 +0100 Subject: [PATCH 03/15] feat: increase default refresh interval to 60 seconds --- ip-monitor/plugin.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ip-monitor/plugin.toml b/ip-monitor/plugin.toml index 7c28c4dc..eb2b9c3d 100644 --- a/ip-monitor/plugin.toml +++ b/ip-monitor/plugin.toml @@ -114,7 +114,7 @@ entry = "widget.luau" type = "int" label_key = "settings.refresh_interval.label" description_key = "settings.refresh_interval.description" - default = 10 - min = 1 + default = 60 + min = 10 max = 3600 visible_when = { key = "mode", values = ["interface", "custom_command"] } From 61ea65a4b9e1511d29486460fd4e91a5728d9378 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sun, 2 Aug 2026 23:06:29 +0100 Subject: [PATCH 04/15] fix: prevent shell injection in IP monitor gateway lookup. --- ip-monitor/widget.luau | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index 01c22c0b..f15a5575 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -293,15 +293,19 @@ local function fetchInterfaceIp() network = calculateNetwork(foundIp, mask, foundCidr) end - -- Fetch gateway via `ip route show dev ` - noctalia.runAsync("ip route show dev " .. matchedIface, function(routeResult) + -- Fetch gateway from the full route table, filtered in Lua by matchedIface. + -- Avoids interpolating matchedIface into a shell command (injection risk). + noctalia.runAsync("ip route show", function(routeResult) local gateway = "" if routeResult.exitCode == 0 then for line in routeResult.stdout:gmatch("[^\r\n]+") do - local g = line:match("default via ([^%s]+)") or line:match("via ([^%s]+)") - if g then - gateway = g - break + local lineDev = line:match("%sdev%s+([^%s]+)") + if lineDev == matchedIface then + local g = line:match("default via ([^%s]+)") or line:match("via ([^%s]+)") + if g then + gateway = g + break + end end end end From 873274c8ef8c5ebe120a2effda45f99d96686aa7 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Sun, 2 Aug 2026 23:13:11 +0100 Subject: [PATCH 05/15] docs: fix author casing in README --- ip-monitor/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ip-monitor/README.md b/ip-monitor/README.md index add6270b..c438c46f 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -48,14 +48,14 @@ Add the widget to a bar from *Settings → Bar*. Plugin options live in *Setting The widget listens to the `set` event. By default, the widget is assigned the IPC ID `default`. To set the IP and name for a default widget, use the following command (you don't need to specify an ID in the JSON, it defaults to `default`): ```sh -noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"ip":"192.168.1.5","name":"MyIP"}' +noctalia msg plugin 3ri4ng0ld/ip-monitor:widget all set '{"ip":"192.168.1.5","name":"MyIP"}' ``` If you have multiple `ip-monitor` widgets in IPC mode and want to update them independently, you can change the **IPC ID** setting for each widget. Then, target them by passing their ID string in the JSON payload: ```sh -noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"id":"my_vpn","ip":"100.64.0.1","name":"VPN"}' -noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{"id":"my_local_iface","ip":"192.168.1.5","name":"Ethernet"}' +noctalia msg plugin 3ri4ng0ld/ip-monitor:widget all set '{"id":"my_vpn","ip":"100.64.0.1","name":"VPN"}' +noctalia msg plugin 3ri4ng0ld/ip-monitor:widget all set '{"id":"my_local_iface","ip":"192.168.1.5","name":"Ethernet"}' ``` ### Tooltips From 23605f70cda308f97102ce81967d89ccae2dfa42 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 00:49:47 +0100 Subject: [PATCH 06/15] feat: add configurable click actions --- ip-monitor/README.md | 13 +++--- ip-monitor/plugin.toml | 82 ++++++++++++++++++++++----------- ip-monitor/translations/en.json | 8 ++++ ip-monitor/widget.luau | 10 +++- 4 files changed, 77 insertions(+), 36 deletions(-) diff --git a/ip-monitor/README.md b/ip-monitor/README.md index c438c46f..99a277d3 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -31,7 +31,9 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | `separator` | `string` | `-` | Separator symbol between IP and Name | | `separator_color` | `color` | `on_surface_variant` | Color of the separator | | `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | -| `refresh_interval` | `int` | `10` | Refresh interval in seconds (max 3600) | +| `refresh_interval` | `int` | `60` | Refresh interval in seconds (max 3600) | +| `left_click_action` | `select` | `copy_ip` | Action to perform on left click (Copy IP, Copy Name, None) | +| `right_click_action` | `select` | `copy_name` | Action to perform on right click (Copy IP, Copy Name, None) | ## Usage @@ -71,7 +73,7 @@ Hovering over the widget displays detailed network information if available: In **IPC Mode**, tooltip fields can be passed optionally in the JSON payload (either as top-level keys or nested inside a `tooltip` object): ```sh -noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{ +noctalia msg plugin 3ri4ng0ld/ip-monitor:widget all set '{ "id": "my_vpn", "ip": "100.64.0.2", "name": "Tailscale", @@ -84,7 +86,6 @@ noctalia msg plugin 3ri4nG0ld/ip-monitor:widget all set '{ ### Click Actions -You can configure what happens when you left-click or right-click the widget: -- Copy IP -- Copy Name -- None +By default, left-clicking the widget copies the IP address to the clipboard and right-clicking copies the display name. + +These bindings can be changed or disabled from the **Settings** section in the widget's bar settings. diff --git a/ip-monitor/plugin.toml b/ip-monitor/plugin.toml index eb2b9c3d..bcddfb01 100644 --- a/ip-monitor/plugin.toml +++ b/ip-monitor/plugin.toml @@ -16,19 +16,7 @@ dependencies = ["ip", "curl"] id = "widget" entry = "widget.luau" - [[widget.setting]] - key = "glyph" - type = "glyph" - label_key = "settings.glyph.label" - description_key = "settings.glyph.description" - default = "network" - [[widget.setting]] - key = "glyph_color" - type = "color" - label_key = "settings.glyph_color.label" - description_key = "settings.glyph_color.description" - default = "on_surface" [[widget.setting]] key = "mode" @@ -58,6 +46,38 @@ entry = "widget.luau" default = "curl -s ifconfig.me" visible_when = { key = "mode", values = ["custom_command"] } + [[widget.setting]] + key = "ipc_id" + type = "string" + label_key = "settings.ipc_id.label" + description_key = "settings.ipc_id.description" + default = "default" + visible_when = { key = "mode", values = ["ipc"] } + + [[widget.setting]] + key = "refresh_interval" + type = "int" + label_key = "settings.refresh_interval.label" + description_key = "settings.refresh_interval.description" + default = 60 + min = 10 + max = 3600 + visible_when = { key = "mode", values = ["interface", "custom_command"] } + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "network" + + [[widget.setting]] + key = "glyph_color" + type = "color" + label_key = "settings.glyph_color.label" + description_key = "settings.glyph_color.description" + default = "on_surface" + [[widget.setting]] key = "text_color" type = "color" @@ -73,14 +93,6 @@ entry = "widget.luau" default = "" visible_when = { key = "mode", values = ["interface", "custom_command"] } - [[widget.setting]] - key = "ipc_id" - type = "string" - label_key = "settings.ipc_id.label" - description_key = "settings.ipc_id.description" - default = "default" - visible_when = { key = "mode", values = ["ipc"] } - [[widget.setting]] key = "name_color" type = "color" @@ -110,11 +122,25 @@ entry = "widget.luau" default = true [[widget.setting]] - key = "refresh_interval" - type = "int" - label_key = "settings.refresh_interval.label" - description_key = "settings.refresh_interval.description" - default = 60 - min = 10 - max = 3600 - visible_when = { key = "mode", values = ["interface", "custom_command"] } + key = "left_click_action" + type = "select" + label_key = "settings.left_click_action.label" + description_key = "settings.left_click_action.description" + default = "copy_ip" + options = [ + { value = "copy_ip", label_key = "settings.click.options.copy_ip" }, + { value = "copy_name", label_key = "settings.click.options.copy_name" }, + { value = "none", label_key = "settings.click.options.none" }, + ] + + [[widget.setting]] + key = "right_click_action" + type = "select" + label_key = "settings.right_click_action.label" + description_key = "settings.right_click_action.description" + default = "copy_name" + options = [ + { value = "copy_ip", label_key = "settings.click.options.copy_ip" }, + { value = "copy_name", label_key = "settings.click.options.copy_name" }, + { value = "none", label_key = "settings.click.options.none" }, + ] diff --git a/ip-monitor/translations/en.json b/ip-monitor/translations/en.json index 41919696..de32e45f 100644 --- a/ip-monitor/translations/en.json +++ b/ip-monitor/translations/en.json @@ -10,6 +10,14 @@ "broadcast": "Broadcast" }, "settings": { + "left_click_action": { + "label": "Left Click Action", + "description": "Action to perform on left click." + }, + "right_click_action": { + "label": "Right Click Action", + "description": "Action to perform on right click." + }, "glyph": { "label": "Icon", "description": "Icon to show before the IP." diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index f15a5575..b52ef734 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -194,12 +194,18 @@ end --- Handler for left click action on the bar widget. function onClick() - applyClickAction("copy_ip") + local action = noctalia.getConfig("left_click_action") or "copy_ip" + if action ~= "none" then + applyClickAction(action) + end end --- Handler for right click action on the bar widget. function onRightClick() - applyClickAction("copy_name") + local action = noctalia.getConfig("right_click_action") or "copy_name" + if action ~= "none" then + applyClickAction(action) + end end --- Handler for IPC messages dispatched to this widget. From 25f4925eadfacae7f17d9d6a6d98264079e1c899 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 01:27:40 +0100 Subject: [PATCH 07/15] 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 --- ip-monitor/README.md | 8 +- ip-monitor/desktop.luau | 184 +++++++++++++++++++++++++++++ ip-monitor/plugin.toml | 146 ++++++++++++++++++++++- ip-monitor/translations/en.json | 16 +++ ip-monitor/utils.luau | 175 ++++++++++++++++++++++++++++ ip-monitor/widget.luau | 198 +++----------------------------- 6 files changed, 539 insertions(+), 188 deletions(-) create mode 100644 ip-monitor/desktop.luau create mode 100644 ip-monitor/utils.luau diff --git a/ip-monitor/README.md b/ip-monitor/README.md index 99a277d3..722e6b90 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -1,6 +1,6 @@ # IP Monitor -A Noctalia bar widget to monitor network IPs. +A Noctalia bar and desktop widget to monitor network IPs. It supports fetching IPs from network interfaces, custom commands, or via IPC. ## Plugin @@ -8,7 +8,7 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | Field | Value | | --- | --- | | ID | `3ri4ng0ld/ip-monitor` | -| Entries | Bar widget: `widget` | +| Entries | Bar widget: `widget`
Desktop widget: `desktop` | ## Requirements @@ -27,9 +27,9 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | `text_color` | `color` | `on_surface` | Color of the IP text | | `name` | `string` | `""` | A custom name to display alongside the IP | | `ipc_id` | `string` | `default` | Identifier used to target this specific widget via IPC | -| `name_color` | `color` | `on_surface_variant` | Color of the name text | +| `name_color` | `color` | `on_surface` | Color of the name text | | `separator` | `string` | `-` | Separator symbol between IP and Name | -| `separator_color` | `color` | `on_surface_variant` | Color of the separator | +| `separator_color` | `color` | `on_surface` | Color of the separator | | `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | | `refresh_interval` | `int` | `60` | Refresh interval in seconds (max 3600) | | `left_click_action` | `select` | `copy_ip` | Action to perform on left click (Copy IP, Copy Name, None) | diff --git a/ip-monitor/desktop.luau b/ip-monitor/desktop.luau new file mode 100644 index 00000000..cefeb660 --- /dev/null +++ b/ip-monitor/desktop.luau @@ -0,0 +1,184 @@ +------------------------------------------------------------------------------- +-- IP Monitor Desktop Widget for Noctalia +-- +-- Monitors network IP addresses and displays details on the desktop. +------------------------------------------------------------------------------- + +local utils = require("./utils.luau") + +-- Current runtime state +local currentIp = "" +local currentIpcName = "" +local currentTooltip = nil + +------------------------------------------------------------------------------- +-- HELPER FUNCTIONS +------------------------------------------------------------------------------- + +--- Resolves the active display name according to the active mode and IPC state. +local function getDisplayName() + local mode = noctalia.getConfig("mode") + if mode == "ipc" then + if currentIpcName ~= "" then + return currentIpcName + end + end + return noctalia.getConfig("name") or "" +end + +------------------------------------------------------------------------------- +-- UI RENDERING +------------------------------------------------------------------------------- + +--- Renders the desktop widget UI based on current state and user settings. +local function render() + local hideOnEmpty = noctalia.getConfig("hide_on_empty") + if hideOnEmpty == nil then hideOnEmpty = true end + + -- Omit rendering completely if no IP is present and hide_on_empty is active + if hideOnEmpty and (not currentIp or currentIp == "") then + desktopWidget.render(ui.row({}, {})) + return + end + + local ipFontSize = noctalia.getConfig("ip_font_size") or 32 + local detailsFontSize = noctalia.getConfig("details_font_size") or 16 + + local displayName = getDisplayName() + local hasIp = currentIp and currentIp ~= "" + local hasName = displayName and displayName ~= "" + + local topRowContent = {} + + -- 1. Icon / Glyph + local glyph = noctalia.getConfig("glyph") + if glyph and glyph ~= "" then + table.insert(topRowContent, ui.glyph({ + name = glyph, + size = ipFontSize, + color = noctalia.getConfig("glyph_color") + })) + end + + -- 2. IP Text Label + if hasIp then + table.insert(topRowContent, ui.label({ + text = currentIp, + fontSize = ipFontSize, + color = noctalia.getConfig("text_color") + })) + end + + -- 3. Separator Label + if hasIp and hasName then + local sep = noctalia.getConfig("separator") + if sep == nil then sep = "-" end + table.insert(topRowContent, ui.label({ + text = sep, + fontSize = ipFontSize, + color = noctalia.getConfig("separator_color") + })) + end + + -- 4. Name Text Label + if hasName then + table.insert(topRowContent, ui.label({ + text = displayName, + fontSize = ipFontSize, + color = noctalia.getConfig("name_color") + })) + end + + local content = {} + table.insert(content, ui.row({ gap = 8, align = "center", justify = "center" }, topRowContent)) + + -- Add Details + if currentTooltip then + local colKeys = {} + local colValues = {} + local fields = { "iface", "ip", "network", "gateway", "mask", "broadcast" } + for _, field in ipairs(fields) do + local val = currentTooltip[field] + if val and val ~= "" then + table.insert(colKeys, ui.label({ + text = noctalia.tr("tooltip." .. field), + fontSize = detailsFontSize, + color = noctalia.getConfig("details_key_color") or "primary" + })) + table.insert(colValues, ui.label({ + text = val, + fontSize = detailsFontSize, + color = noctalia.getConfig("details_value_color") or "on_surface" + })) + end + end + + if #colKeys > 0 then + table.insert(content, ui.row({ gap = 12, align = "center", justify = "center" }, { + ui.column({ gap = 4, align = "start" }, colKeys), + ui.column({ gap = 4, align = "start" }, colValues) + })) + end + end + + desktopWidget.render(ui.column({ gap = 12, align = "center" }, content)) +end + +------------------------------------------------------------------------------- +-- LIFECYCLE HOOKS +------------------------------------------------------------------------------- + +--- Handler for IPC messages dispatched to this widget. +function onIpc(event, payload) + if noctalia.getConfig("mode") ~= "ipc" then return end + + if event == "set" then + local myId = noctalia.getConfig("ipc_id") or "default" + local data = utils.handleIpcPayload(payload, myId) + if data then + currentIp = data.ip + currentIpcName = data.name + currentTooltip = data.tooltip + render() + end + end +end + +--- Primary update entry point called periodically by Noctalia script runtime. +function update() + local mode = noctalia.getConfig("mode") + if mode == "interface" then + utils.fetchInterfaceIp(noctalia.getConfig("iface"), function(ip, tooltip) + currentIp = ip + currentTooltip = tooltip + render() + end) + elseif mode == "custom_command" then + utils.fetchCustomCommandIp(noctalia.getConfig("custom_command"), function(ip, tooltip) + currentIp = ip + currentTooltip = tooltip + render() + end) + end +end + +--- Config change handler called automatically when user alters widget settings. +function onConfigChanged() + local mode = noctalia.getConfig("mode") + currentIp = "" + currentIpcName = "" + currentTooltip = nil + + if mode == "interface" or mode == "custom_command" then + local interval = noctalia.getConfig("refresh_interval") or 10 + noctalia.setUpdateInterval(interval * 1000) + update() + else + -- IPC mode doesn't need periodic updates + noctalia.setUpdateInterval(0) + render() + end +end + +-- Initial setup upon loading widget +onConfigChanged() diff --git a/ip-monitor/plugin.toml b/ip-monitor/plugin.toml index bcddfb01..e2f459fb 100644 --- a/ip-monitor/plugin.toml +++ b/ip-monitor/plugin.toml @@ -16,8 +16,6 @@ dependencies = ["ip", "curl"] id = "widget" entry = "widget.luau" - - [[widget.setting]] key = "mode" type = "select" @@ -98,7 +96,7 @@ entry = "widget.luau" type = "color" label_key = "settings.name_color.label" description_key = "settings.name_color.description" - default = "on_surface_variant" + default = "on_surface" [[widget.setting]] key = "separator" @@ -112,7 +110,7 @@ entry = "widget.luau" type = "color" label_key = "settings.separator_color.label" description_key = "settings.separator_color.description" - default = "on_surface_variant" + default = "on_surface" [[widget.setting]] key = "hide_on_empty" @@ -144,3 +142,143 @@ entry = "widget.luau" { value = "copy_name", label_key = "settings.click.options.copy_name" }, { value = "none", label_key = "settings.click.options.none" }, ] + + +[[desktop_widget]] +id = "desktop" +entry = "desktop.luau" + + [[desktop_widget.setting]] + key = "mode" + type = "select" + label_key = "settings.mode.label" + description_key = "settings.mode.description" + default = "interface" + options = [ + { value = "interface", label_key = "settings.mode.options.interface" }, + { value = "ipc", label_key = "settings.mode.options.ipc" }, + { value = "custom_command", label_key = "settings.mode.options.custom_command" }, + ] + + [[desktop_widget.setting]] + key = "iface" + type = "string" + label_key = "settings.iface.label" + description_key = "settings.iface.description" + default = "wlan*" + visible_when = { key = "mode", values = ["interface"] } + + [[desktop_widget.setting]] + key = "custom_command" + type = "string" + label_key = "settings.custom_command.label" + description_key = "settings.custom_command.description" + default = "curl -s ifconfig.me" + visible_when = { key = "mode", values = ["custom_command"] } + + [[desktop_widget.setting]] + key = "ipc_id" + type = "string" + label_key = "settings.ipc_id.label" + description_key = "settings.ipc_id.description" + default = "default" + visible_when = { key = "mode", values = ["ipc"] } + + [[desktop_widget.setting]] + key = "refresh_interval" + type = "int" + label_key = "settings.refresh_interval.label" + description_key = "settings.refresh_interval.description" + default = 60 + min = 10 + max = 3600 + visible_when = { key = "mode", values = ["interface", "custom_command"] } + + [[desktop_widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "network" + + [[desktop_widget.setting]] + key = "glyph_color" + type = "color" + label_key = "settings.glyph_color.label" + description_key = "settings.glyph_color.description" + default = "on_surface" + + [[desktop_widget.setting]] + key = "text_color" + type = "color" + label_key = "settings.text_color.label" + description_key = "settings.text_color.description" + default = "on_surface" + + [[desktop_widget.setting]] + key = "name" + type = "string" + label_key = "settings.name.label" + description_key = "settings.name.description" + default = "" + visible_when = { key = "mode", values = ["interface", "custom_command"] } + + [[desktop_widget.setting]] + key = "name_color" + type = "color" + label_key = "settings.name_color.label" + description_key = "settings.name_color.description" + default = "on_surface" + + [[desktop_widget.setting]] + key = "separator" + type = "string" + label_key = "settings.separator.label" + description_key = "settings.separator.description" + default = "-" + + [[desktop_widget.setting]] + key = "separator_color" + type = "color" + label_key = "settings.separator_color.label" + description_key = "settings.separator_color.description" + default = "on_surface" + + [[desktop_widget.setting]] + key = "hide_on_empty" + type = "bool" + label_key = "settings.hide_on_empty.label" + description_key = "settings.hide_on_empty.description" + default = true + + [[desktop_widget.setting]] + key = "ip_font_size" + type = "int" + label_key = "settings.ip_font_size.label" + description_key = "settings.ip_font_size.description" + default = 32 + min = 10 + max = 100 + + [[desktop_widget.setting]] + key = "details_font_size" + type = "int" + label_key = "settings.details_font_size.label" + description_key = "settings.details_font_size.description" + default = 16 + min = 10 + max = 100 + + [[desktop_widget.setting]] + key = "details_key_color" + type = "color" + label_key = "settings.details_key_color.label" + description_key = "settings.details_key_color.description" + default = "primary" + + [[desktop_widget.setting]] + key = "details_value_color" + type = "color" + label_key = "settings.details_value_color.label" + description_key = "settings.details_value_color.description" + default = "on_surface" diff --git a/ip-monitor/translations/en.json b/ip-monitor/translations/en.json index de32e45f..4e85c4c8 100644 --- a/ip-monitor/translations/en.json +++ b/ip-monitor/translations/en.json @@ -81,6 +81,22 @@ "copy_name": "Copy Name", "none": "None" } + }, + "ip_font_size": { + "label": "IP Font Size", + "description": "Font size for the IP and name text." + }, + "details_font_size": { + "label": "Details Font Size", + "description": "Font size for the network details text." + }, + "details_key_color": { + "label": "Details Key Color", + "description": "Color for the detail labels (e.g. Network, Mask)." + }, + "details_value_color": { + "label": "Details Value Color", + "description": "Color for the detail values." } } } diff --git a/ip-monitor/utils.luau b/ip-monitor/utils.luau new file mode 100644 index 00000000..cdf743a6 --- /dev/null +++ b/ip-monitor/utils.luau @@ -0,0 +1,175 @@ +------------------------------------------------------------------------------- +-- 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 octets = {} + for i = 1, 4 do + if n >= 8 then + table.insert(octets, 255) + n = n - 8 + elseif n > 0 then + local octet = 256 - (2 ^ (8 - n)) + table.insert(octets, math.floor(octet)) + n = 0 + else + table.insert(octets, 0) + end + end + return table.concat(octets, ".") +end + +--- Converts a simple glob pattern (e.g. "wlan*", "eth0") into a standard Lua pattern. +function utils.globToPattern(glob) + local pattern = glob:gsub("%.", "%%."):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 + + a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d) + m1, m2, m3, m4 = tonumber(m1), tonumber(m2), tonumber(m3), tonumber(m4) + + local function applyMask(ipPart, maskPart) + return (maskPart == 255) and ipPart or (maskPart == 0 and 0 or (ipPart - (ipPart % (256 - maskPart)))) + end + + local net1 = applyMask(a, m1) + local net2 = applyMask(b, m2) + local net3 = applyMask(c, m3) + local net4 = applyMask(d, 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 + 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]+)") or line:match("via ([^%s]+)") + if g then + gateway = g + break + 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 data = noctalia.json.decode(payload) + if not data 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 + +return utils diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index b52ef734..9a6985e2 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -5,34 +5,13 @@ -- custom shell commands, or external IPC events. ------------------------------------------------------------------------------- +local utils = require("./utils.luau") + -- Current runtime state local currentIp = "" local currentIpcName = "" local currentTooltip = nil ---- Calculates IPv4 subnet mask string (e.g. "255.255.255.0") from a CIDR prefix length (0-32). --- @param prefix string|number CIDR prefix length --- @return string The formatted subnet mask string -local function cidrToSubnetMask(prefix) - local n = tonumber(prefix) - if not n or n < 0 or n > 32 then return "" end - - local octets = {} - for i = 1, 4 do - if n >= 8 then - table.insert(octets, 255) - n = n - 8 - elseif n > 0 then - local octet = 256 - (2 ^ (8 - n)) - table.insert(octets, math.floor(octet)) - n = 0 - else - table.insert(octets, 0) - end - end - return table.concat(octets, ".") -end - ------------------------------------------------------------------------------- -- HELPER FUNCTIONS ------------------------------------------------------------------------------- @@ -49,39 +28,6 @@ local function getDisplayName() return noctalia.getConfig("name") or "" end ---- Converts a simple glob pattern (e.g. "wlan*", "eth0") into a standard Lua pattern. --- @param glob string The glob pattern to convert --- @return string The converted Lua pattern -local function globToPattern(glob) - local pattern = glob:gsub("%.", "%%."):gsub("%*", ".*") - return "^" .. pattern .. "$" -end - ---- Calculates network address (e.g. "192.168.1.0/24") from IP, Subnet Mask, and CIDR prefix. --- @param ipStr string IP address (e.g. "192.168.1.33") --- @param maskStr string Subnet mask (e.g. "255.255.255.0") --- @param cidrStr string CIDR prefix string (e.g. "24") --- @return string The calculated network range string -local function 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 - - a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d) - m1, m2, m3, m4 = tonumber(m1), tonumber(m2), tonumber(m3), tonumber(m4) - - local function applyMask(ipPart, maskPart) - return (maskPart == 255) and ipPart or (maskPart == 0 and 0 or (ipPart - (ipPart % (256 - maskPart)))) - end - - local net1 = applyMask(a, m1) - local net2 = applyMask(b, m2) - local net3 = applyMask(c, m3) - local net4 = applyMask(d, m4) - - return string.format("%d.%d.%d.%d/%s", net1, net2, net3, net4, cidrStr) -end - --- Updates the widget hover tooltip from the currentTooltip table. local function updateTooltip() if not currentTooltip then @@ -223,131 +169,15 @@ function onIpc(event, payload) if noctalia.getConfig("mode") ~= "ipc" then return end if event == "set" then - local data = noctalia.json.decode(payload) + local myId = noctalia.getConfig("ipc_id") or "default" + local data = utils.handleIpcPayload(payload, myId) if data then - local targetId = data.id or "default" - local myId = noctalia.getConfig("ipc_id") or "default" - - if targetId == myId then - currentIp = data.ip or "" - currentIpcName = data.name or "" - - -- Parse optional tooltip data from payload (supports nested object or top-level keys) - local tt = (type(data.tooltip) == "table") and data.tooltip or {} - currentTooltip = { - 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 - } - - render() - end - end - end -end - -------------------------------------------------------------------------------- --- IP DATA FETCHERS -------------------------------------------------------------------------------- - ---- Fetches IPv4 address and detailed network interface metadata using `ip -4 -o addr show`. -local function fetchInterfaceIp() - local ifaceSetting = noctalia.getConfig("iface") or "" - if ifaceSetting == "" then return end - - local pattern = 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) - 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 - currentIp = "" - currentTooltip = nil - render() - return - end - - -- Calculate Mask and Network Range - local mask = cidrToSubnetMask(foundCidr) - local network = "" - if foundIp ~= "" and mask ~= "" and foundCidr ~= "" then - network = calculateNetwork(foundIp, mask, foundCidr) - end - - -- Fetch gateway from the full route table, filtered in Lua by matchedIface. - -- Avoids interpolating matchedIface into a shell command (injection risk). - noctalia.runAsync("ip route show", function(routeResult) - local gateway = "" - if routeResult.exitCode == 0 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("default via ([^%s]+)") or line:match("via ([^%s]+)") - if g then - gateway = g - break - end - end - end - end - - currentIp = foundIp - currentTooltip = { - iface = matchedIface, - ip = foundIp, - network = network, - gateway = gateway, - mask = mask, - broadcast = foundBrd - } + currentIp = data.ip + currentIpcName = data.name + currentTooltip = data.tooltip render() - end) - end) -end - ---- Fetches IP address by executing a custom shell command. -local function fetchCustomCommandIp() - local cmd = noctalia.getConfig("custom_command") or "" - if cmd == "" then return end - - noctalia.runAsync(cmd, function(result) - if result.exitCode ~= 0 then - noctalia.log("ip-monitor: custom command failed: " .. result.stderr) - return end - - local newIp = noctalia.string.trim(result.stdout) - currentIp = newIp - currentTooltip = { - ip = newIp - } - render() - end) + end end ------------------------------------------------------------------------------- @@ -358,9 +188,17 @@ end function update() local mode = noctalia.getConfig("mode") if mode == "interface" then - fetchInterfaceIp() + utils.fetchInterfaceIp(noctalia.getConfig("iface"), function(ip, tooltip) + currentIp = ip + currentTooltip = tooltip + render() + end) elseif mode == "custom_command" then - fetchCustomCommandIp() + utils.fetchCustomCommandIp(noctalia.getConfig("custom_command"), function(ip, tooltip) + currentIp = ip + currentTooltip = tooltip + render() + end) end end From f1ead8639068f442bb1c256334c03b26e5bf3b57 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 01:44:38 +0100 Subject: [PATCH 08/15] feat: add hidden_fields configuration to allow filtering tooltip and desktop widget details --- ip-monitor/README.md | 4 ++++ ip-monitor/desktop.luau | 29 +++++++++++++++++------------ ip-monitor/plugin.toml | 14 ++++++++++++++ ip-monitor/translations/en.json | 8 ++++++++ ip-monitor/utils.luau | 25 +++++++++++++++++++++++++ ip-monitor/widget.luau | 11 ++++++++--- 6 files changed, 76 insertions(+), 15 deletions(-) diff --git a/ip-monitor/README.md b/ip-monitor/README.md index 722e6b90..d07d53d1 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -32,6 +32,7 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | `separator_color` | `color` | `on_surface` | Color of the separator | | `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | | `refresh_interval` | `int` | `60` | Refresh interval in seconds (max 3600) | +| `hidden_fields` | `string` | `""` | Comma-separated fields to hide (e.g. `network,mask`). Options: `iface`, `ip`, `network`, `gateway`, `mask`, `broadcast` | | `left_click_action` | `select` | `copy_ip` | Action to perform on left click (Copy IP, Copy Name, None) | | `right_click_action` | `select` | `copy_name` | Action to perform on right click (Copy IP, Copy Name, None) | @@ -70,6 +71,9 @@ Hovering over the widget displays detailed network information if available: - **Mask**: Subnet mask (e.g. `255.255.255.0`) - **Broadcast**: Broadcast address (e.g. `192.168.1.255`) +> [!TIP] +> You can selectively hide any of these fields by listing their internal keys (`iface, ip, network, gateway, mask, broadcast`) separated by commas in the **Hide Tooltip Options** (for the bar widget) or **Hide Details Options** (for the desktop widget) configuration. + In **IPC Mode**, tooltip fields can be passed optionally in the JSON payload (either as top-level keys or nested inside a `tooltip` object): ```sh diff --git a/ip-monitor/desktop.luau b/ip-monitor/desktop.luau index cefeb660..08440bcd 100644 --- a/ip-monitor/desktop.luau +++ b/ip-monitor/desktop.luau @@ -94,22 +94,27 @@ local function render() -- Add Details if currentTooltip then + local hiddenConfig = noctalia.getConfig("hidden_fields") + local hiddenFields = utils.parseHiddenFields(hiddenConfig) + local colKeys = {} local colValues = {} local fields = { "iface", "ip", "network", "gateway", "mask", "broadcast" } for _, field in ipairs(fields) do - local val = currentTooltip[field] - if val and val ~= "" then - table.insert(colKeys, ui.label({ - text = noctalia.tr("tooltip." .. field), - fontSize = detailsFontSize, - color = noctalia.getConfig("details_key_color") or "primary" - })) - table.insert(colValues, ui.label({ - text = val, - fontSize = detailsFontSize, - color = noctalia.getConfig("details_value_color") or "on_surface" - })) + if not hiddenFields[field] then + local val = currentTooltip[field] + if val and val ~= "" then + table.insert(colKeys, ui.label({ + text = noctalia.tr("tooltip." .. field), + fontSize = detailsFontSize, + color = noctalia.getConfig("details_key_color") or "primary" + })) + table.insert(colValues, ui.label({ + text = val, + fontSize = detailsFontSize, + color = noctalia.getConfig("details_value_color") or "on_surface" + })) + end end end diff --git a/ip-monitor/plugin.toml b/ip-monitor/plugin.toml index e2f459fb..2e106e62 100644 --- a/ip-monitor/plugin.toml +++ b/ip-monitor/plugin.toml @@ -143,6 +143,13 @@ entry = "widget.luau" { value = "none", label_key = "settings.click.options.none" }, ] + [[widget.setting]] + key = "hidden_fields" + type = "string" + label_key = "settings.hide_tooltip_options.label" + description_key = "settings.hide_tooltip_options.description" + default = "" + [[desktop_widget]] id = "desktop" @@ -282,3 +289,10 @@ entry = "desktop.luau" label_key = "settings.details_value_color.label" description_key = "settings.details_value_color.description" default = "on_surface" + + [[desktop_widget.setting]] + key = "hidden_fields" + type = "string" + label_key = "settings.hide_details_options.label" + description_key = "settings.hide_details_options.description" + default = "" diff --git a/ip-monitor/translations/en.json b/ip-monitor/translations/en.json index 4e85c4c8..5e0bd25e 100644 --- a/ip-monitor/translations/en.json +++ b/ip-monitor/translations/en.json @@ -97,6 +97,14 @@ "details_value_color": { "label": "Details Value Color", "description": "Color for the detail values." + }, + "hide_tooltip_options": { + "label": "Hide Tooltip Options", + "description": "Comma-separated list of fields to hide in the tooltip (e.g. network,mask,iface)." + }, + "hide_details_options": { + "label": "Hide Details Options", + "description": "Comma-separated list of fields to hide in the details table (e.g. network,mask,iface)." } } } diff --git a/ip-monitor/utils.luau b/ip-monitor/utils.luau index cdf743a6..4d999dc1 100644 --- a/ip-monitor/utils.luau +++ b/ip-monitor/utils.luau @@ -172,4 +172,29 @@ function utils.handleIpcPayload(payload, myId) } 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 + return utils diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index 9a6985e2..c8d468e8 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -35,12 +35,17 @@ local function updateTooltip() return end + local hiddenConfig = noctalia.getConfig("hidden_fields") + local hiddenFields = utils.parseHiddenFields(hiddenConfig) + local rows = {} local fields = { "iface", "ip", "network", "gateway", "mask", "broadcast" } for _, field in ipairs(fields) do - local val = currentTooltip[field] - if val and val ~= "" then - table.insert(rows, { key = noctalia.tr("tooltip." .. field), value = val }) + if not hiddenFields[field] then + local val = currentTooltip[field] + if val and val ~= "" then + table.insert(rows, { key = noctalia.tr("tooltip." .. field), value = val }) + end end end From 5de4d9b3286bf3339b37e5be6f250a8984dcd6d3 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 01:50:32 +0100 Subject: [PATCH 09/15] fix: remove raw HTML from README --- ip-monitor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ip-monitor/README.md b/ip-monitor/README.md index d07d53d1..2b28c6dd 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -8,7 +8,7 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | Field | Value | | --- | --- | | ID | `3ri4ng0ld/ip-monitor` | -| Entries | Bar widget: `widget`
Desktop widget: `desktop` | +| Entries | Bar widget: `widget`, Desktop widget: `desktop` | ## Requirements From a916ca289e21e82e6bda3fd5257954af1c966bbc Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:11:40 +0100 Subject: [PATCH 10/15] refactor(utils): use bit32 and fix glob pattern escaping & gateway parsing --- ip-monitor/utils.luau | 59 ++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/ip-monitor/utils.luau b/ip-monitor/utils.luau index 4d999dc1..c976a015 100644 --- a/ip-monitor/utils.luau +++ b/ip-monitor/utils.luau @@ -8,25 +8,19 @@ function utils.cidrToSubnetMask(prefix) local n = tonumber(prefix) if not n or n < 0 or n > 32 then return "" end - local octets = {} - for i = 1, 4 do - if n >= 8 then - table.insert(octets, 255) - n = n - 8 - elseif n > 0 then - local octet = 256 - (2 ^ (8 - n)) - table.insert(octets, math.floor(octet)) - n = 0 - else - table.insert(octets, 0) - end - end - return table.concat(octets, ".") + 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 pattern = glob:gsub("%.", "%%."):gsub("%*", ".*") + local escaped = glob:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%1") + local pattern = escaped:gsub("%%%*", ".*") return "^" .. pattern .. "$" end @@ -36,17 +30,10 @@ function utils.calculateNetwork(ipStr, maskStr, cidrStr) 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 - a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d) - m1, m2, m3, m4 = tonumber(m1), tonumber(m2), tonumber(m3), tonumber(m4) - - local function applyMask(ipPart, maskPart) - return (maskPart == 255) and ipPart or (maskPart == 0 and 0 or (ipPart - (ipPart % (256 - maskPart)))) - end - - local net1 = applyMask(a, m1) - local net2 = applyMask(b, m2) - local net3 = applyMask(c, m3) - local net4 = applyMask(d, m4) + 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 @@ -102,16 +89,30 @@ function utils.fetchInterfaceIp(ifaceSetting, callback) 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]+)") or line:match("via ([^%s]+)") + 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 = { @@ -149,8 +150,8 @@ end --- Parses an IPC payload and extracts data if it matches the configured ID. function utils.handleIpcPayload(payload, myId) - local data = noctalia.json.decode(payload) - if not data then return nil end + 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 From 5012c6a44061aaf5651e161eb9fd03c74b3fe861 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:16:24 +0100 Subject: [PATCH 11/15] refactor(utils): centralize shared UI builder and display name resolution --- ip-monitor/utils.luau | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/ip-monitor/utils.luau b/ip-monitor/utils.luau index c976a015..6485eb7d 100644 --- a/ip-monitor/utils.luau +++ b/ip-monitor/utils.luau @@ -198,4 +198,65 @@ function utils.parseHiddenFields(configStr) 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 From 28f4933911b04c1725195be05e342d4300ec887c Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:18:56 +0100 Subject: [PATCH 12/15] refactor(widget): use shared utils helpers and clean up duplicate logic --- ip-monitor/widget.luau | 53 ++++-------------------------------------- 1 file changed, 4 insertions(+), 49 deletions(-) diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index c8d468e8..f067d28f 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -16,16 +16,9 @@ local currentTooltip = nil -- HELPER FUNCTIONS ------------------------------------------------------------------------------- ---- Resolves the active display name according to the active mode and IPC state. --- @return string The resolved display name to render or copy +--- Resolves the active display name according to active mode and IPC state. local function getDisplayName() - local mode = noctalia.getConfig("mode") - if mode == "ipc" then - if currentIpcName ~= "" then - return currentIpcName - end - end - return noctalia.getConfig("name") or "" + return utils.getDisplayName(noctalia.getConfig("mode"), currentIpcName, noctalia.getConfig("name")) end --- Updates the widget hover tooltip from the currentTooltip table. @@ -75,46 +68,8 @@ local function render() -- Update widget hover tooltip updateTooltip() - local content = {} local displayName = getDisplayName() - - -- 1. Icon / Glyph - local glyph = noctalia.getConfig("glyph") - if glyph and glyph ~= "" then - table.insert(content, ui.glyph({ - name = glyph, - color = noctalia.getConfig("glyph_color") - })) - end - - local hasIp = currentIp and currentIp ~= "" - local hasName = displayName and displayName ~= "" - - local function addLabel(text, colorConfig) - if text and text ~= "" then - table.insert(content, ui.label({ - text = text, - color = noctalia.getConfig(colorConfig) - })) - end - end - - -- 2. IP Text Label - if hasIp then - addLabel(currentIp, "text_color") - end - - -- 3. Separator Label (rendered only when both IP and Name exist) - if hasIp and hasName then - local sep = noctalia.getConfig("separator") - if sep == nil then sep = "-" end - addLabel(sep, "separator_color") - end - - -- 4. Name Text Label - if hasName then - addLabel(displayName, "name_color") - end + local content = utils.buildTopRow(currentIp, displayName) -- Layout container according to bar orientation (Vertical -> column, Horizontal -> row) local container = barWidget.isVertical() and ui.column or ui.row @@ -215,7 +170,7 @@ function onConfigChanged() currentTooltip = nil if mode == "interface" or mode == "custom_command" then - local interval = noctalia.getConfig("refresh_interval") or 10 + local interval = noctalia.getConfig("refresh_interval") or 60 noctalia.setUpdateInterval(interval * 1000) update() else From bb7f890d8c9548405f14ab281b8b182cda62cd0c Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:20:15 +0100 Subject: [PATCH 13/15] refactor(desktop): use shared utils helpers and add IPC click action parity --- ip-monitor/desktop.luau | 78 ++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 51 deletions(-) diff --git a/ip-monitor/desktop.luau b/ip-monitor/desktop.luau index 08440bcd..76dbac45 100644 --- a/ip-monitor/desktop.luau +++ b/ip-monitor/desktop.luau @@ -15,15 +15,25 @@ local currentTooltip = nil -- HELPER FUNCTIONS ------------------------------------------------------------------------------- ---- Resolves the active display name according to the active mode and IPC state. +--- Resolves the active display name according to active mode and IPC state. local function getDisplayName() - local mode = noctalia.getConfig("mode") - if mode == "ipc" then - if currentIpcName ~= "" then - return currentIpcName - end + return utils.getDisplayName(noctalia.getConfig("mode"), currentIpcName, noctalia.getConfig("name")) +end + +local function copyNotifyAndLog(text, optionKey, logMessage) + if text and text ~= "" then + noctalia.copyToClipboard(text, "text/plain") + noctalia.notify(noctalia.tr("name"), noctalia.tr(optionKey) .. ": " .. text) + noctalia.log("ip-monitor: " .. logMessage .. ": " .. text) + end +end + +local function applyAction(action) + if action == "copy_ip" then + copyNotifyAndLog(currentIp, "settings.click.options.copy_ip", "copied IP to clipboard") + elseif action == "copy_name" then + copyNotifyAndLog(getDisplayName(), "settings.click.options.copy_name", "copied name to clipboard") end - return noctalia.getConfig("name") or "" end ------------------------------------------------------------------------------- @@ -45,49 +55,7 @@ local function render() local detailsFontSize = noctalia.getConfig("details_font_size") or 16 local displayName = getDisplayName() - local hasIp = currentIp and currentIp ~= "" - local hasName = displayName and displayName ~= "" - - local topRowContent = {} - - -- 1. Icon / Glyph - local glyph = noctalia.getConfig("glyph") - if glyph and glyph ~= "" then - table.insert(topRowContent, ui.glyph({ - name = glyph, - size = ipFontSize, - color = noctalia.getConfig("glyph_color") - })) - end - - -- 2. IP Text Label - if hasIp then - table.insert(topRowContent, ui.label({ - text = currentIp, - fontSize = ipFontSize, - color = noctalia.getConfig("text_color") - })) - end - - -- 3. Separator Label - if hasIp and hasName then - local sep = noctalia.getConfig("separator") - if sep == nil then sep = "-" end - table.insert(topRowContent, ui.label({ - text = sep, - fontSize = ipFontSize, - color = noctalia.getConfig("separator_color") - })) - end - - -- 4. Name Text Label - if hasName then - table.insert(topRowContent, ui.label({ - text = displayName, - fontSize = ipFontSize, - color = noctalia.getConfig("name_color") - })) - end + local topRowContent = utils.buildTopRow(currentIp, displayName, ipFontSize) local content = {} table.insert(content, ui.row({ gap = 8, align = "center", justify = "center" }, topRowContent)) @@ -135,6 +103,14 @@ end --- Handler for IPC messages dispatched to this widget. function onIpc(event, payload) + if event == "copy_ip" then + applyAction("copy_ip") + return + elseif event == "copy_name" then + applyAction("copy_name") + return + end + if noctalia.getConfig("mode") ~= "ipc" then return end if event == "set" then @@ -175,7 +151,7 @@ function onConfigChanged() currentTooltip = nil if mode == "interface" or mode == "custom_command" then - local interval = noctalia.getConfig("refresh_interval") or 10 + local interval = noctalia.getConfig("refresh_interval") or 60 noctalia.setUpdateInterval(interval * 1000) update() else From 5f12b02498954b00e57c4dc2ef284f52c89638b6 Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:23:38 +0100 Subject: [PATCH 14/15] docs: update README with desktop widget settings --- ip-monitor/README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/ip-monitor/README.md b/ip-monitor/README.md index 2b28c6dd..9e53ee84 100644 --- a/ip-monitor/README.md +++ b/ip-monitor/README.md @@ -17,6 +17,8 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. ## Settings +### Bar Widget Settings (`widget`) + | Setting | Type | Default | Description | | --- | --- | --- | --- | | `glyph` | `glyph` | `network` | The icon glyph displayed on the widget | @@ -32,10 +34,33 @@ It supports fetching IPs from network interfaces, custom commands, or via IPC. | `separator_color` | `color` | `on_surface` | Color of the separator | | `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | | `refresh_interval` | `int` | `60` | Refresh interval in seconds (max 3600) | -| `hidden_fields` | `string` | `""` | Comma-separated fields to hide (e.g. `network,mask`). Options: `iface`, `ip`, `network`, `gateway`, `mask`, `broadcast` | +| `hidden_fields` | `string` | `""` | Comma-separated fields to hide in tooltip (e.g. `network,mask`). Options: `iface`, `ip`, `network`, `gateway`, `mask`, `broadcast` | | `left_click_action` | `select` | `copy_ip` | Action to perform on left click (Copy IP, Copy Name, None) | | `right_click_action` | `select` | `copy_name` | Action to perform on right click (Copy IP, Copy Name, None) | +### Desktop Widget Settings (`desktop`) + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `glyph` | `glyph` | `network` | The icon glyph displayed on the widget | +| `glyph_color` | `color` | `on_surface` | Color of the icon | +| `mode` | `select` | `interface` | Operating mode: Interface, Custom Command, or IPC | +| `iface` | `string` | `wlan*` | Network interface wildcard to fetch IP from | +| `custom_command` | `string` | `curl -s ifconfig.me` | Shell command to execute in custom command mode | +| `text_color` | `color` | `on_surface` | Color of the IP text | +| `name` | `string` | `""` | A custom name to display alongside the IP | +| `ipc_id` | `string` | `default` | Identifier used to target this specific widget via IPC | +| `name_color` | `color` | `on_surface` | Color of the name text | +| `separator` | `string` | `-` | Separator symbol between IP and Name | +| `separator_color` | `color` | `on_surface` | Color of the separator | +| `hide_on_empty` | `boolean` | `true` | Hide the widget completely if no IP is found | +| `refresh_interval` | `int` | `60` | Refresh interval in seconds (max 3600) | +| `ip_font_size` | `int` | `32` | Font size for the IP and name text | +| `details_font_size` | `int` | `16` | Font size for the network details text | +| `details_key_color` | `color` | `primary` | Color for detail label keys (e.g. Network, Mask) | +| `details_value_color` | `color` | `on_surface` | Color for detail values | +| `hidden_fields` | `string` | `""` | Comma-separated fields to hide in details table (e.g. `network,mask`). Options: `iface`, `ip`, `network`, `gateway`, `mask`, `broadcast` | + ## Usage Add the widget to a bar from *Settings → Bar*. Plugin options live in *Settings → Plugins*. From ad826f7cf2b649c78467e42554615ba6e59cf58a Mon Sep 17 00:00:00 2001 From: 3ri4nG0ld Date: Mon, 3 Aug 2026 02:35:09 +0100 Subject: [PATCH 15/15] feat: remove unused IPC copy actions from widget logic --- ip-monitor/desktop.luau | 8 -------- ip-monitor/widget.luau | 8 -------- 2 files changed, 16 deletions(-) diff --git a/ip-monitor/desktop.luau b/ip-monitor/desktop.luau index 76dbac45..3e156a45 100644 --- a/ip-monitor/desktop.luau +++ b/ip-monitor/desktop.luau @@ -103,14 +103,6 @@ end --- Handler for IPC messages dispatched to this widget. function onIpc(event, payload) - if event == "copy_ip" then - applyAction("copy_ip") - return - elseif event == "copy_name" then - applyAction("copy_name") - return - end - if noctalia.getConfig("mode") ~= "ipc" then return end if event == "set" then diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau index f067d28f..7c34e7ea 100644 --- a/ip-monitor/widget.luau +++ b/ip-monitor/widget.luau @@ -118,14 +118,6 @@ end -- @param event string The IPC event name -- @param payload string The JSON payload string function onIpc(event, payload) - if event == "copy_ip" then - applyClickAction("copy_ip") - return - elseif event == "copy_name" then - applyClickAction("copy_name") - return - end - if noctalia.getConfig("mode") ~= "ipc" then return end if event == "set" then