diff --git a/hotspot/README.md b/hotspot/README.md new file mode 100644 index 0000000..b801d42 --- /dev/null +++ b/hotspot/README.md @@ -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. diff --git a/hotspot/panel.luau b/hotspot/panel.luau new file mode 100644 index 0000000..2097d45 --- /dev/null +++ b/hotspot/panel.luau @@ -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 diff --git a/hotspot/plugin.toml b/hotspot/plugin.toml new file mode 100644 index 0000000..f501c3c --- /dev/null +++ b/hotspot/plugin.toml @@ -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 diff --git a/hotspot/service.luau b/hotspot/service.luau new file mode 100644 index 0000000..c4232e8 --- /dev/null +++ b/hotspot/service.luau @@ -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 diff --git a/hotspot/thumbnail.webp b/hotspot/thumbnail.webp new file mode 100644 index 0000000..77b5367 Binary files /dev/null and b/hotspot/thumbnail.webp differ diff --git a/hotspot/translations/en.json b/hotspot/translations/en.json new file mode 100644 index 0000000..55a19ef --- /dev/null +++ b/hotspot/translations/en.json @@ -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" + } +} diff --git a/hotspot/widget.luau b/hotspot/widget.luau new file mode 100644 index 0000000..cc81a94 --- /dev/null +++ b/hotspot/widget.luau @@ -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