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