* 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.
503 lines
12 KiB
Luau
503 lines
12 KiB
Luau
--!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
|