feat(wifi-hotspot): add NetworkManager hotspot control plugin (#120)
* feat(wifi-hotspot): add NetworkManager hotspot control plugin Expose bar toggle, panel, and IPC to start/stop a Wi-Fi hotspot and list connected clients. * refactor(hotspot): rename plugin directory to hotspot Align the folder name with the cleboost/hotspot plugin id. * refactor(hotspot): align plugin id and state keys with hotspot slug Update manifest id, IPC references, and shared state keys after the directory rename.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
# Wi-Fi Hotspot
|
||||||
|
|
||||||
|
Start and stop a NetworkManager Wi-Fi hotspot from the bar, inspect connected
|
||||||
|
devices in a panel, and control the service over IPC.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `cleboost/hotspot` |
|
||||||
|
| Entries | Bar widget: `toggle`; panel: `panel`; service: `hotspot` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install `nmcli` from NetworkManager, plus `iw` and `ip` from iproute2, on
|
||||||
|
`PATH`. The active user must be allowed to manage NetworkManager connections
|
||||||
|
(the default on most desktop setups).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Open the plugin settings and set the hotspot name and password.
|
||||||
|
2. Add the `toggle` widget to a bar.
|
||||||
|
3. Left-click the widget to open the panel with the connected device list.
|
||||||
|
4. Right-click the widget to start or stop the hotspot.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg panel-toggle cleboost/hotspot:panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Starting a hotspot may disconnect the Wi-Fi adapter from its current network
|
||||||
|
while the access point is active.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ssid` | `string` | `Noctalia Hotspot` | Network name broadcast to clients. |
|
||||||
|
| `password` | `string` | *(empty)* | WPA password. Must be at least 8 characters before starting. |
|
||||||
|
| `interface` | `string` | *(empty)* | Optional Wi-Fi interface such as `wlan0`. Auto-detected when empty. |
|
||||||
|
| `refresh_interval` | `int` | `5` | Seconds between status and client-list refreshes. |
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
```sh
|
||||||
|
noctalia msg plugin cleboost/hotspot:hotspot all enable
|
||||||
|
noctalia msg plugin cleboost/hotspot:hotspot all disable
|
||||||
|
noctalia msg plugin cleboost/hotspot:hotspot all toggle
|
||||||
|
noctalia msg plugin cleboost/hotspot:hotspot all refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
The `enable`, `disable`, and `toggle` events change the hotspot state.
|
||||||
|
`refresh` and `status` re-read NetworkManager without changing it. IPC events
|
||||||
|
take no payload.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The plugin uses `nmcli device wifi hotspot` to create the access point and
|
||||||
|
`nmcli connection down` to stop the active AP profile.
|
||||||
|
- Connected devices are discovered with `iw station dump` and `ip neigh`.
|
||||||
|
- The plugin makes no network requests and writes no user content to disk.
|
||||||
|
- Notifications use the host `notification-show` IPC with Wi-Fi glyphs (`wifi`,
|
||||||
|
`wifi-off`, `alert-circle`) because `noctalia.notify()` does not accept a
|
||||||
|
custom icon yet.
|
||||||
|
- Hotspots started outside Noctalia are detected when NetworkManager reports an
|
||||||
|
active `802-11-wireless` connection in `ap` mode.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
local opened = false
|
||||||
|
local render
|
||||||
|
|
||||||
|
local status = noctalia.state.get("hotspot_status") or {
|
||||||
|
active = false,
|
||||||
|
busy = false,
|
||||||
|
available = true,
|
||||||
|
clients = {},
|
||||||
|
clientCount = 0,
|
||||||
|
error = "",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function sendCommand(action)
|
||||||
|
noctalia.state.set("hotspot_command", { action = action })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function phaseDetails()
|
||||||
|
if status.busy == true then
|
||||||
|
if status.active == true then
|
||||||
|
return noctalia.tr("panel.status.stopping_title"), noctalia.tr("panel.status.stopping_message"), "on_surface_variant", "loader-2"
|
||||||
|
end
|
||||||
|
return noctalia.tr("panel.status.starting_title"), noctalia.tr("panel.status.starting_message"), "on_surface_variant", "loader-2"
|
||||||
|
end
|
||||||
|
|
||||||
|
if status.active == true then
|
||||||
|
return noctalia.tr("panel.status.active_title"), noctalia.tr("panel.status.active_message", {
|
||||||
|
ssid = status.ssid or "?",
|
||||||
|
interface = status.interface or "?",
|
||||||
|
}), "tertiary", "router"
|
||||||
|
end
|
||||||
|
|
||||||
|
if status.available == false then
|
||||||
|
local message = status.error
|
||||||
|
if message == nil or message == "" then
|
||||||
|
message = noctalia.tr("error.missing_dependencies")
|
||||||
|
end
|
||||||
|
return noctalia.tr("panel.status.error_title"), message, "error", "alert-circle"
|
||||||
|
end
|
||||||
|
|
||||||
|
return noctalia.tr("panel.status.inactive_title"), noctalia.tr("panel.status.inactive_message"), "on_surface_variant", "wifi-off"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clientRow(client)
|
||||||
|
local subtitle = client.mac
|
||||||
|
if client.ip ~= nil and client.ip ~= "" then
|
||||||
|
subtitle = client.ip .. " · " .. client.mac
|
||||||
|
end
|
||||||
|
|
||||||
|
local trailing = nil
|
||||||
|
if client.signal ~= nil then
|
||||||
|
trailing = ui.label({
|
||||||
|
text = tostring(client.signal) .. " dBm",
|
||||||
|
fontSize = 11,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 10,
|
||||||
|
paddingH = 14,
|
||||||
|
paddingV = 10,
|
||||||
|
fill = "surface_variant/0.35",
|
||||||
|
radius = 10,
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = "device-mobile", size = 18, color = "primary" }),
|
||||||
|
ui.column({ gap = 2, flexGrow = 1 }, {
|
||||||
|
ui.label({ text = client.ip ~= "" and client.ip or client.mac, fontWeight = "medium", maxLines = 1 }),
|
||||||
|
ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
|
||||||
|
}),
|
||||||
|
trailing,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
render = function()
|
||||||
|
local busy = status.busy == true
|
||||||
|
local active = status.active == true
|
||||||
|
local canToggle = status.available ~= false and not busy
|
||||||
|
local stateTitle, stateMessage, stateColor, stateGlyph = phaseDetails()
|
||||||
|
|
||||||
|
local actionText = noctalia.tr("panel.action.start")
|
||||||
|
local actionGlyph = "player-play"
|
||||||
|
local actionVariant = "primary"
|
||||||
|
|
||||||
|
if active then
|
||||||
|
actionText = noctalia.tr("panel.action.stop")
|
||||||
|
actionGlyph = "player-stop"
|
||||||
|
actionVariant = "destructive"
|
||||||
|
elseif busy then
|
||||||
|
actionText = active and noctalia.tr("panel.action.stopping") or noctalia.tr("panel.action.starting")
|
||||||
|
actionGlyph = "loader-2"
|
||||||
|
end
|
||||||
|
|
||||||
|
local children = {
|
||||||
|
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
|
||||||
|
ui.row({ align = "center", gap = 9, flexGrow = 1 }, {
|
||||||
|
ui.glyph({ name = "router", size = 18, color = "primary" }),
|
||||||
|
ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold" }),
|
||||||
|
}),
|
||||||
|
ui.button({
|
||||||
|
glyph = "refresh",
|
||||||
|
variant = "ghost",
|
||||||
|
tooltip = noctalia.tr("panel.refresh"),
|
||||||
|
enabled = not busy,
|
||||||
|
onClick = "onRefresh",
|
||||||
|
}),
|
||||||
|
ui.button({
|
||||||
|
glyph = "close",
|
||||||
|
variant = "ghost",
|
||||||
|
tooltip = noctalia.tr("panel.close"),
|
||||||
|
onClick = "onCloseClicked",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ui.row({
|
||||||
|
align = "center",
|
||||||
|
gap = 10,
|
||||||
|
fill = "surface_variant/0.45",
|
||||||
|
radius = 10,
|
||||||
|
padding = 12,
|
||||||
|
}, {
|
||||||
|
ui.glyph({ name = stateGlyph, size = 18, color = stateColor }),
|
||||||
|
ui.column({ gap = 3, flexGrow = 1 }, {
|
||||||
|
ui.label({ text = stateTitle, fontWeight = "medium", color = stateColor }),
|
||||||
|
ui.label({ text = stateMessage, color = "on_surface_variant", maxLines = 3 }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.gateway ~= nil and status.gateway ~= "" then
|
||||||
|
table.insert(children, ui.label({
|
||||||
|
text = noctalia.tr("panel.gateway", { address = status.gateway }),
|
||||||
|
fontSize = 11,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(children, ui.button({
|
||||||
|
text = actionText,
|
||||||
|
glyph = actionGlyph,
|
||||||
|
variant = actionVariant,
|
||||||
|
controlSize = "lg",
|
||||||
|
enabled = canToggle,
|
||||||
|
onClick = "onToggle",
|
||||||
|
}))
|
||||||
|
|
||||||
|
table.insert(children, ui.row({ align = "center", justify = "space_between" }, {
|
||||||
|
ui.label({
|
||||||
|
text = noctalia.tr("panel.clients_title"),
|
||||||
|
fontWeight = "medium",
|
||||||
|
color = "on_surface_variant",
|
||||||
|
}),
|
||||||
|
ui.label({
|
||||||
|
text = tostring(status.clientCount or #(status.clients or {})),
|
||||||
|
fontSize = 11,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
local clients = status.clients or {}
|
||||||
|
if #clients == 0 then
|
||||||
|
table.insert(children, ui.label({
|
||||||
|
text = active and noctalia.tr("panel.no_clients") or noctalia.tr("panel.clients_inactive"),
|
||||||
|
color = "on_surface_variant",
|
||||||
|
maxLines = 2,
|
||||||
|
padding = 12,
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
local listChildren = {}
|
||||||
|
for _, client in ipairs(clients) do
|
||||||
|
table.insert(listChildren, clientRow(client))
|
||||||
|
end
|
||||||
|
table.insert(children, ui.scroll({ flexGrow = 1, gap = 8 }, listChildren))
|
||||||
|
end
|
||||||
|
|
||||||
|
panel.render(ui.column({ flexGrow = 1, gap = 12 }, children))
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch("hotspot_status", function(value)
|
||||||
|
if type(value) ~= "table" then return end
|
||||||
|
status = value
|
||||||
|
if opened then render() end
|
||||||
|
end)
|
||||||
|
|
||||||
|
function onOpen(_context)
|
||||||
|
opened = true
|
||||||
|
status = noctalia.state.get("hotspot_status") or status
|
||||||
|
sendCommand("refresh")
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClose()
|
||||||
|
opened = false
|
||||||
|
end
|
||||||
|
|
||||||
|
function onCloseClicked()
|
||||||
|
panel.close()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onToggle()
|
||||||
|
if status.busy == true or status.available == false then return end
|
||||||
|
sendCommand("toggle")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRefresh()
|
||||||
|
sendCommand("refresh")
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
id = "cleboost/hotspot"
|
||||||
|
name = "Wi-Fi Hotspot"
|
||||||
|
version = "1.0.0"
|
||||||
|
plugin_api = 9
|
||||||
|
author = "Cleboost"
|
||||||
|
license = "MIT"
|
||||||
|
icon = "router"
|
||||||
|
description = "Start and stop a Wi-Fi hotspot from the bar, with connected device list and IPC control."
|
||||||
|
tags = ["network", "bar", "panel", "service", "utility", "system", "indicator"]
|
||||||
|
dependencies = ["nmcli", "iw", "ip"]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "ssid"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.ssid.label"
|
||||||
|
description_key = "settings.ssid.description"
|
||||||
|
default = "Noctalia Hotspot"
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "password"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.password.label"
|
||||||
|
description_key = "settings.password.description"
|
||||||
|
default = ""
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "interface"
|
||||||
|
type = "string"
|
||||||
|
label_key = "settings.interface.label"
|
||||||
|
description_key = "settings.interface.description"
|
||||||
|
default = ""
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "refresh_interval"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.refresh_interval.label"
|
||||||
|
description_key = "settings.refresh_interval.description"
|
||||||
|
default = 5
|
||||||
|
min = 2
|
||||||
|
max = 60
|
||||||
|
|
||||||
|
[[service]]
|
||||||
|
id = "hotspot"
|
||||||
|
entry = "service.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "toggle"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "settings.glyph.label"
|
||||||
|
default = "router"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "panel"
|
||||||
|
entry = "panel.luau"
|
||||||
|
width = 420
|
||||||
|
height = 520
|
||||||
|
placement = "attached"
|
||||||
|
position = "top_right"
|
||||||
|
open_near_click = true
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
local STATUS_KEY = "hotspot_status"
|
||||||
|
local COMMAND_KEY = "hotspot_command"
|
||||||
|
|
||||||
|
local busy = false
|
||||||
|
local revision = 0
|
||||||
|
local available = noctalia.commandExists("nmcli")
|
||||||
|
local iwAvailable = noctalia.commandExists("iw")
|
||||||
|
local ipAvailable = noctalia.commandExists("ip")
|
||||||
|
local refreshing = false
|
||||||
|
local refreshAgain = false
|
||||||
|
|
||||||
|
local status = {
|
||||||
|
active = false,
|
||||||
|
busy = false,
|
||||||
|
available = available,
|
||||||
|
error = "",
|
||||||
|
revision = 0,
|
||||||
|
ssid = "",
|
||||||
|
interface = "",
|
||||||
|
connection = "",
|
||||||
|
gateway = "",
|
||||||
|
clients = {},
|
||||||
|
clientCount = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local refresh
|
||||||
|
local setActive
|
||||||
|
|
||||||
|
local function publish(errorMessage)
|
||||||
|
revision += 1
|
||||||
|
status.busy = busy
|
||||||
|
status.available = available
|
||||||
|
status.revision = revision
|
||||||
|
status.error = errorMessage or status.error or ""
|
||||||
|
status.clientCount = #(status.clients or {})
|
||||||
|
noctalia.state.set(STATUS_KEY, status)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellQuote(value)
|
||||||
|
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function showNotification(title, body, icon, urgency)
|
||||||
|
local payload = noctalia.json.encode({
|
||||||
|
app_name = noctalia.tr("title"),
|
||||||
|
summary = title,
|
||||||
|
body = body or "",
|
||||||
|
icon = icon,
|
||||||
|
urgency = urgency,
|
||||||
|
})
|
||||||
|
if payload == nil then
|
||||||
|
if urgency == "critical" then
|
||||||
|
noctalia.notifyError(title, body)
|
||||||
|
else
|
||||||
|
noctalia.notify(title, body)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.runAsync("noctalia msg notification-show " .. shellQuote(payload))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runCommand(command, callback, timeoutMs)
|
||||||
|
local started = noctalia.runAsync(command, callback, timeoutMs or 15000)
|
||||||
|
if not started and type(callback) == "function" then
|
||||||
|
callback({
|
||||||
|
exitCode = -1,
|
||||||
|
stdout = "",
|
||||||
|
stderr = noctalia.tr("error.command_start"),
|
||||||
|
timedOut = false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return started
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validateSettings()
|
||||||
|
local ssid = noctalia.string.trim(noctalia.getConfig("ssid") or "")
|
||||||
|
if ssid == "" then
|
||||||
|
return false, noctalia.tr("error.missing_ssid")
|
||||||
|
end
|
||||||
|
|
||||||
|
local password = tostring(noctalia.getConfig("password") or "")
|
||||||
|
if #password < 8 then
|
||||||
|
return false, noctalia.tr("error.password_too_short")
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, ssid, password
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseNmcliTriple(stdout, filterType)
|
||||||
|
local entries = {}
|
||||||
|
for line in tostring(stdout or ""):gmatch("[^\n]+") do
|
||||||
|
local name, entryType, extra = line:match("^([^:]+):([^:]+):([^:]*)$")
|
||||||
|
if entryType == filterType and name ~= nil and name ~= "" then
|
||||||
|
table.insert(entries, { name = name, device = extra or "" })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return entries
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pickWifiInterface(callback)
|
||||||
|
local configured = noctalia.string.trim(noctalia.getConfig("interface") or "")
|
||||||
|
if configured ~= "" then
|
||||||
|
callback(configured)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
runCommand("nmcli -t -f DEVICE,TYPE,STATE device status", function(result)
|
||||||
|
local devices = parseNmcliTriple(result.stdout, "wifi")
|
||||||
|
callback(devices[1] and devices[1].name or nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseIwStations(stdout)
|
||||||
|
local clients = {}
|
||||||
|
local current = nil
|
||||||
|
|
||||||
|
for line in tostring(stdout or ""):gmatch("[^\n]+") do
|
||||||
|
local mac = line:match("^Station ([%x:]+)")
|
||||||
|
if mac ~= nil then
|
||||||
|
current = { mac = mac:lower(), ip = "", signal = nil }
|
||||||
|
table.insert(clients, current)
|
||||||
|
elseif current ~= nil then
|
||||||
|
local signal = line:match("signal:%s*([%-%d]+)")
|
||||||
|
if signal ~= nil then
|
||||||
|
current.signal = tonumber(signal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return clients
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseIpNeigh(stdout)
|
||||||
|
local byMac = {}
|
||||||
|
for line in tostring(stdout or ""):gmatch("[^\n]+") do
|
||||||
|
local ip, mac = line:match("^([%d%.]+)%s+lladdr%s+([%x:]+)")
|
||||||
|
if ip ~= nil and mac ~= nil then
|
||||||
|
byMac[mac:lower()] = ip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return byMac
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mergeClients(stations, neighByMac)
|
||||||
|
local merged = {}
|
||||||
|
local seen = {}
|
||||||
|
|
||||||
|
for _, client in ipairs(stations) do
|
||||||
|
table.insert(merged, {
|
||||||
|
mac = client.mac,
|
||||||
|
ip = neighByMac[client.mac] or client.ip or "",
|
||||||
|
signal = client.signal,
|
||||||
|
})
|
||||||
|
seen[client.mac] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
for mac, ip in pairs(neighByMac) do
|
||||||
|
if not seen[mac] then
|
||||||
|
table.insert(merged, { mac = mac, ip = ip, signal = nil })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(merged, function(left, right)
|
||||||
|
if left.ip ~= right.ip then
|
||||||
|
if left.ip == "" then return false end
|
||||||
|
if right.ip == "" then return true end
|
||||||
|
return left.ip < right.ip
|
||||||
|
end
|
||||||
|
return left.mac < right.mac
|
||||||
|
end)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetchClients(iface, callback)
|
||||||
|
if iface == nil or iface == "" then
|
||||||
|
callback({})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local stations = {}
|
||||||
|
local neighByMac = {}
|
||||||
|
local remaining = 0
|
||||||
|
|
||||||
|
local function finish()
|
||||||
|
remaining -= 1
|
||||||
|
if remaining > 0 then return end
|
||||||
|
callback(mergeClients(stations, neighByMac))
|
||||||
|
end
|
||||||
|
|
||||||
|
if iwAvailable then
|
||||||
|
remaining += 1
|
||||||
|
runCommand("iw dev " .. shellQuote(iface) .. " station dump", function(result)
|
||||||
|
if result.exitCode == 0 then
|
||||||
|
stations = parseIwStations(result.stdout)
|
||||||
|
end
|
||||||
|
finish()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if ipAvailable then
|
||||||
|
remaining += 1
|
||||||
|
runCommand("ip neigh show dev " .. shellQuote(iface), function(result)
|
||||||
|
if result.exitCode == 0 then
|
||||||
|
neighByMac = parseIpNeigh(result.stdout)
|
||||||
|
end
|
||||||
|
finish()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if remaining == 0 then
|
||||||
|
callback({})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inspectConnection(entry, callback)
|
||||||
|
local command = "nmcli -g 802-11-wireless.mode,802-11-wireless.ssid,IP4.ADDRESS connection show "
|
||||||
|
.. shellQuote(entry.name)
|
||||||
|
runCommand(command, function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mode, ssid, gateway
|
||||||
|
local index = 0
|
||||||
|
for line in tostring(result.stdout or ""):gmatch("[^\n]+") do
|
||||||
|
index += 1
|
||||||
|
local value = noctalia.string.trim(line)
|
||||||
|
if index == 1 then mode = value
|
||||||
|
elseif index == 2 then ssid = value
|
||||||
|
elseif index == 3 then gateway = value end
|
||||||
|
end
|
||||||
|
|
||||||
|
if mode ~= "ap" then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
callback({
|
||||||
|
active = true,
|
||||||
|
connection = entry.name,
|
||||||
|
interface = entry.device,
|
||||||
|
ssid = ssid or "",
|
||||||
|
gateway = gateway or "",
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findActiveHotspot(callback)
|
||||||
|
runCommand("nmcli -t -f NAME,TYPE,DEVICE connection show --active", function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
local detail = noctalia.string.trim(result.stderr or "")
|
||||||
|
callback(nil, detail ~= "" and detail or noctalia.tr("error.status_failed"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local entries = parseNmcliTriple(result.stdout, "802-11-wireless")
|
||||||
|
if #entries == 0 then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local index = 1
|
||||||
|
local function nextEntry()
|
||||||
|
local entry = entries[index]
|
||||||
|
if entry == nil then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
inspectConnection(entry, function(info)
|
||||||
|
if info ~= nil then
|
||||||
|
callback(info)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
index += 1
|
||||||
|
nextEntry()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
nextEntry()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function completeRefresh(callback, active)
|
||||||
|
refreshing = false
|
||||||
|
if callback then callback(active) end
|
||||||
|
if refreshAgain then
|
||||||
|
refreshAgain = false
|
||||||
|
refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh = function(callback)
|
||||||
|
if not available then
|
||||||
|
status.active = false
|
||||||
|
status.clients = {}
|
||||||
|
publish(noctalia.tr("error.missing_dependencies"))
|
||||||
|
if callback then callback(false) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if refreshing then
|
||||||
|
refreshAgain = true
|
||||||
|
if callback then callback(status.active) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
refreshing = true
|
||||||
|
|
||||||
|
findActiveHotspot(function(info, err)
|
||||||
|
if err ~= nil then
|
||||||
|
status.active = false
|
||||||
|
status.clients = {}
|
||||||
|
publish(err)
|
||||||
|
completeRefresh(callback, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if info == nil then
|
||||||
|
status.active = false
|
||||||
|
status.ssid = ""
|
||||||
|
status.interface = ""
|
||||||
|
status.connection = ""
|
||||||
|
status.gateway = ""
|
||||||
|
status.clients = {}
|
||||||
|
publish()
|
||||||
|
completeRefresh(callback, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
status.active = true
|
||||||
|
status.ssid = info.ssid
|
||||||
|
status.interface = info.interface
|
||||||
|
status.connection = info.connection
|
||||||
|
status.gateway = info.gateway
|
||||||
|
|
||||||
|
fetchClients(info.interface, function(clients)
|
||||||
|
status.clients = clients
|
||||||
|
publish()
|
||||||
|
completeRefresh(callback, true)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function finishToggle(expectedActive, result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
busy = false
|
||||||
|
local detail = noctalia.string.trim(result.stderr or "")
|
||||||
|
if detail == "" then detail = noctalia.string.trim(result.stdout or "") end
|
||||||
|
if detail == "" then detail = noctalia.tr("error.command_failed") end
|
||||||
|
publish(detail)
|
||||||
|
showNotification(noctalia.tr("title"), detail, "alert-circle", "critical")
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh(function(currentActive)
|
||||||
|
busy = false
|
||||||
|
publish()
|
||||||
|
|
||||||
|
if currentActive ~= expectedActive then
|
||||||
|
showNotification(noctalia.tr("title"), noctalia.tr("error.state_mismatch"), "alert-circle", "critical")
|
||||||
|
elseif currentActive then
|
||||||
|
showNotification(
|
||||||
|
noctalia.tr("notification.enabled.title"),
|
||||||
|
noctalia.tr("notification.enabled.body", { ssid = status.ssid or noctalia.getConfig("ssid") or "" }),
|
||||||
|
"wifi",
|
||||||
|
"normal"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
showNotification(
|
||||||
|
noctalia.tr("notification.disabled.title"),
|
||||||
|
noctalia.tr("notification.disabled.body"),
|
||||||
|
"wifi-off",
|
||||||
|
"normal"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function startHotspot()
|
||||||
|
local ok, ssidOrError, password = validateSettings()
|
||||||
|
if not ok then
|
||||||
|
busy = false
|
||||||
|
publish(ssidOrError)
|
||||||
|
showNotification(noctalia.tr("title"), ssidOrError, "alert-circle", "critical")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
pickWifiInterface(function(iface)
|
||||||
|
if iface == nil or iface == "" then
|
||||||
|
busy = false
|
||||||
|
local message = noctalia.tr("error.no_wifi_device")
|
||||||
|
publish(message)
|
||||||
|
showNotification(noctalia.tr("title"), message, "alert-circle", "critical")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local command = table.concat({
|
||||||
|
"nmcli device wifi hotspot",
|
||||||
|
"ifname " .. shellQuote(iface),
|
||||||
|
"ssid " .. shellQuote(ssidOrError),
|
||||||
|
"password " .. shellQuote(password),
|
||||||
|
}, " ")
|
||||||
|
|
||||||
|
runCommand(command, function(result)
|
||||||
|
finishToggle(true, result)
|
||||||
|
end, 30000)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stopHotspot()
|
||||||
|
if status.connection ~= nil and status.connection ~= "" then
|
||||||
|
runCommand("nmcli connection down " .. shellQuote(status.connection), function(result)
|
||||||
|
finishToggle(false, result)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
findActiveHotspot(function(info)
|
||||||
|
if info == nil then
|
||||||
|
busy = false
|
||||||
|
status.active = false
|
||||||
|
status.clients = {}
|
||||||
|
publish()
|
||||||
|
showNotification(
|
||||||
|
noctalia.tr("notification.disabled.title"),
|
||||||
|
noctalia.tr("notification.disabled.body"),
|
||||||
|
"wifi-off",
|
||||||
|
"normal"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
runCommand("nmcli connection down " .. shellQuote(info.connection), function(result)
|
||||||
|
finishToggle(false, result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
setActive = function(nextActive)
|
||||||
|
if busy or not available then return end
|
||||||
|
|
||||||
|
if nextActive then
|
||||||
|
local ok, message = validateSettings()
|
||||||
|
if not ok then
|
||||||
|
publish(message)
|
||||||
|
showNotification(noctalia.tr("title"), message, "alert-circle", "critical")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
busy = true
|
||||||
|
publish()
|
||||||
|
|
||||||
|
if nextActive then
|
||||||
|
startHotspot()
|
||||||
|
else
|
||||||
|
stopHotspot()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handleCommand(action)
|
||||||
|
if action == "toggle" then
|
||||||
|
setActive(not status.active)
|
||||||
|
elseif action == "enable" then
|
||||||
|
setActive(true)
|
||||||
|
elseif action == "disable" then
|
||||||
|
setActive(false)
|
||||||
|
elseif action == "refresh" or action == "status" then
|
||||||
|
refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch(COMMAND_KEY, function(command)
|
||||||
|
if type(command) ~= "table" or type(command.action) ~= "string" then return end
|
||||||
|
handleCommand(command.action)
|
||||||
|
end)
|
||||||
|
|
||||||
|
publish(available and "" or noctalia.tr("error.missing_dependencies"))
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
function update()
|
||||||
|
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 5
|
||||||
|
noctalia.setUpdateInterval(math.max(2, seconds) * 1000)
|
||||||
|
if not busy then
|
||||||
|
refresh()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onConfigChanged()
|
||||||
|
update()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onIpc(event, _payload)
|
||||||
|
handleCommand(event)
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"command_failed": "The NetworkManager command failed.",
|
||||||
|
"command_start": "Noctalia could not start the hotspot command.",
|
||||||
|
"missing_dependencies": "nmcli is required to manage the hotspot.",
|
||||||
|
"missing_ssid": "Set a hotspot name in the plugin settings.",
|
||||||
|
"no_wifi_device": "No Wi-Fi adapter was found.",
|
||||||
|
"password_too_short": "The hotspot password must be at least 8 characters.",
|
||||||
|
"state_mismatch": "The requested hotspot state could not be confirmed.",
|
||||||
|
"status_failed": "NetworkManager status could not be read."
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"disabled": {
|
||||||
|
"body": "The Wi-Fi hotspot has been turned off.",
|
||||||
|
"title": "Hotspot disabled"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"body": "Clients can connect to {ssid}.",
|
||||||
|
"title": "Hotspot enabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"action": {
|
||||||
|
"start": "Start hotspot",
|
||||||
|
"starting": "Starting…",
|
||||||
|
"stop": "Stop hotspot",
|
||||||
|
"stopping": "Stopping…"
|
||||||
|
},
|
||||||
|
"clients_inactive": "Start the hotspot to see connected devices here.",
|
||||||
|
"clients_title": "Connected devices",
|
||||||
|
"close": "Close",
|
||||||
|
"gateway": "Gateway: {address}",
|
||||||
|
"no_clients": "No device is connected right now.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"status": {
|
||||||
|
"active_message": "{ssid} is broadcasting on {interface}.",
|
||||||
|
"active_title": "Hotspot active",
|
||||||
|
"error_title": "Unavailable",
|
||||||
|
"inactive_message": "Configure the name and password in settings, then start the hotspot.",
|
||||||
|
"inactive_title": "Hotspot off",
|
||||||
|
"starting_message": "NetworkManager is creating the access point…",
|
||||||
|
"starting_title": "Starting",
|
||||||
|
"stopping_message": "NetworkManager is shutting down the access point…",
|
||||||
|
"stopping_title": "Stopping"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"glyph": {
|
||||||
|
"label": "Bar glyph"
|
||||||
|
},
|
||||||
|
"interface": {
|
||||||
|
"description": "Optional Wi-Fi interface name. Leave empty to use the first adapter NetworkManager reports.",
|
||||||
|
"label": "Wi-Fi interface"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"description": "WPA password shared with connecting devices. Must be at least 8 characters.",
|
||||||
|
"label": "Password"
|
||||||
|
},
|
||||||
|
"refresh_interval": {
|
||||||
|
"description": "How often the service refreshes hotspot status and the connected device list, in seconds.",
|
||||||
|
"label": "Refresh interval"
|
||||||
|
},
|
||||||
|
"ssid": {
|
||||||
|
"description": "Network name broadcast by the hotspot.",
|
||||||
|
"label": "Hotspot name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Wi-Fi Hotspot",
|
||||||
|
"tooltip": {
|
||||||
|
"active": "{ssid} is active · {count} device(s) connected · click for panel · right-click to stop",
|
||||||
|
"changing": "Changing hotspot state…",
|
||||||
|
"inactive": "Hotspot is off · click for panel · right-click to start",
|
||||||
|
"unavailable": "NetworkManager is not available"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
--!nonstrict
|
||||||
|
|
||||||
|
local PANEL_ID = "cleboost/hotspot:panel"
|
||||||
|
local COMMAND_KEY = "hotspot_command"
|
||||||
|
|
||||||
|
local status = noctalia.state.get("hotspot_status") or {
|
||||||
|
active = false,
|
||||||
|
busy = false,
|
||||||
|
available = true,
|
||||||
|
clients = {},
|
||||||
|
clientCount = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local glyph = noctalia.getConfig("glyph") or "router"
|
||||||
|
|
||||||
|
local function sendCommand(action)
|
||||||
|
noctalia.state.set(COMMAND_KEY, { action = action })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
if status.available == false then
|
||||||
|
barWidget.setGlyph("wifi-off")
|
||||||
|
barWidget.setGlyphColor("error")
|
||||||
|
barWidget.setTooltip(status.error ~= "" and status.error or noctalia.tr("tooltip.unavailable"))
|
||||||
|
elseif status.busy == true then
|
||||||
|
barWidget.setGlyph("loader-2")
|
||||||
|
barWidget.setGlyphColor("primary")
|
||||||
|
barWidget.setTooltip(noctalia.tr("tooltip.changing"))
|
||||||
|
elseif status.active == true then
|
||||||
|
barWidget.setGlyph(glyph)
|
||||||
|
barWidget.setGlyphColor("tertiary")
|
||||||
|
local count = status.clientCount or #(status.clients or {})
|
||||||
|
barWidget.setText(count > 0 and tostring(count) or "")
|
||||||
|
barWidget.setTooltip(noctalia.tr("tooltip.active", {
|
||||||
|
ssid = status.ssid or "?",
|
||||||
|
count = tostring(count),
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
barWidget.setGlyph(glyph)
|
||||||
|
barWidget.setGlyphColor("on_surface_variant")
|
||||||
|
barWidget.setText("")
|
||||||
|
barWidget.setTooltip(noctalia.tr("tooltip.inactive"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.watch("hotspot_status", function(value)
|
||||||
|
if type(value) == "table" then
|
||||||
|
status = value
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
render()
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.togglePanel(PANEL_ID)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
if status.available == false then
|
||||||
|
noctalia.notifyError(noctalia.tr("title"), status.error or noctalia.tr("error.missing_dependencies"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if status.busy == true then return end
|
||||||
|
sendCommand("toggle")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onMiddleClick()
|
||||||
|
sendCommand("refresh")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onConfigChanged()
|
||||||
|
glyph = noctalia.getConfig("glyph") or "router"
|
||||||
|
render()
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user