feat: add ip-monitor plugin (#207)
* Add ip-monitor plugin * fix: removed typo * feat: increase default refresh interval to 60 seconds * fix: prevent shell injection in IP monitor gateway lookup. * docs: fix author casing in README * feat: add configurable click actions * feat: add desktop widget support and refactor core logic - Extracted IP fetching and data parsing into a shared `utils.luau` module to prevent code duplication. - Created `desktop.luau` to display network details explicitly in a tabulated UI. - Added specific configuration options for font sizes and colors for the new desktop widget * feat: add hidden_fields configuration to allow filtering tooltip and desktop widget details * fix: remove raw HTML from README * refactor(utils): use bit32 and fix glob pattern escaping & gateway parsing * refactor(utils): centralize shared UI builder and display name resolution * refactor(widget): use shared utils helpers and clean up duplicate logic * refactor(desktop): use shared utils helpers and add IPC click action parity * docs: update README with desktop widget settings * feat: remove unused IPC copy actions from widget logic
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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()
|
||||
@@ -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 = ""
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user