diff --git a/ip-monitor/README.md b/ip-monitor/README.md new file mode 100644 index 00000000..9e53ee84 --- /dev/null +++ b/ip-monitor/README.md @@ -0,0 +1,120 @@ +# IP Monitor + +A Noctalia bar and desktop 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`, Desktop widget: `desktop` | + +## 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 + +### Bar Widget Settings (`widget`) + +| 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) | +| `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*. + +### 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`) + +> [!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 +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 + +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/desktop.luau b/ip-monitor/desktop.luau new file mode 100644 index 00000000..3e156a45 --- /dev/null +++ b/ip-monitor/desktop.luau @@ -0,0 +1,157 @@ +------------------------------------------------------------------------------- +-- 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 active mode and IPC state. +local function getDisplayName() + 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 +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 topRowContent = utils.buildTopRow(currentIp, displayName, ipFontSize) + + local content = {} + table.insert(content, ui.row({ gap = 8, align = "center", justify = "center" }, topRowContent)) + + -- 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 + 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 + + 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 60 + 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 new file mode 100644 index 00000000..2e106e62 --- /dev/null +++ b/ip-monitor/plugin.toml @@ -0,0 +1,298 @@ +# 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 = "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 = "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" + 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 = "name_color" + type = "color" + label_key = "settings.name_color.label" + description_key = "settings.name_color.description" + default = "on_surface" + + [[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" + + [[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 = "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" }, + ] + + [[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" +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" + + [[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/thumbnail.webp b/ip-monitor/thumbnail.webp new file mode 100644 index 00000000..d6b84bd7 Binary files /dev/null and b/ip-monitor/thumbnail.webp differ diff --git a/ip-monitor/translations/en.json b/ip-monitor/translations/en.json new file mode 100644 index 00000000..5e0bd25e --- /dev/null +++ b/ip-monitor/translations/en.json @@ -0,0 +1,110 @@ +{ + "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": { + "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." + }, + "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" + } + }, + "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." + }, + "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 new file mode 100644 index 00000000..6485eb7d --- /dev/null +++ b/ip-monitor/utils.luau @@ -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 diff --git a/ip-monitor/widget.luau b/ip-monitor/widget.luau new file mode 100644 index 00000000..7c34e7ea --- /dev/null +++ b/ip-monitor/widget.luau @@ -0,0 +1,176 @@ +------------------------------------------------------------------------------- +-- IP Monitor Widget for Noctalia +-- +-- Monitors network IP addresses from network interfaces (with glob matching), +-- custom shell commands, or external IPC events. +------------------------------------------------------------------------------- + +local utils = require("./utils.luau") + +-- Current runtime state +local currentIp = "" +local currentIpcName = "" +local currentTooltip = nil + +------------------------------------------------------------------------------- +-- HELPER FUNCTIONS +------------------------------------------------------------------------------- + +--- Resolves the active display name according to active mode and IPC state. +local function getDisplayName() + return utils.getDisplayName(noctalia.getConfig("mode"), currentIpcName, noctalia.getConfig("name")) +end + +--- Updates the widget hover tooltip from the currentTooltip table. +local function updateTooltip() + if not currentTooltip then + barWidget.setTooltip(nil) + 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 + 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 + + 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 displayName = getDisplayName() + 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 + 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() + 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() + 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. +-- @param event string The IPC event name +-- @param payload string The JSON payload string +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 + +------------------------------------------------------------------------------- +-- LIFECYCLE HOOKS +------------------------------------------------------------------------------- + +--- 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 60 + 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()