diff --git a/network-toolkit/README.md b/network-toolkit/README.md new file mode 100644 index 00000000..0f73eee2 --- /dev/null +++ b/network-toolkit/README.md @@ -0,0 +1,95 @@ +# Network Toolkit + +A unified network management hub for Noctalia providing real-time Wi-Fi, Ethernet, Hotspot, Bluetooth, and persistent DNS control from a single interface. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `autumn/network-toolkit` | +| Entries | Bar widget: `widget`; shortcut: `toggle`; panel: `panel`; service: `service` | + +## Requirements + +Install the following tools on `PATH`: + +- **`networkmanager`** (`nmcli`) — Wi-Fi scanning, connection management, hotspot orchestration, and DNS routing. +- **`bluez-utils`** (`bluetoothctl`) — Bluetooth adapter power, scanning, device pairing, and trust settings. +- **`ip`** — Interface address resolution and neighbor discovery. +- **`iw`** — Virtual AP interface provisioning (`__ap`) and wireless client enumeration. +- **`qrencode`** *(optional)* — Generates shareable Wi-Fi QR codes in the panel. + +## Features + +- **Wi-Fi & Ethernet**: Live access point scanning with signal metrics and security badges; inline password authentication prompt; saved network quick-connect; QR code generator for connected Wi-Fi credentials; network forget capability. +- **Concurrent Wi-Fi Hotspot**: Dynamically provisions a virtual access point (`-ap`, such as `wlp3s0-ap` or `wlan0-ap`) alongside active Wi-Fi station mode on supported hardware via `scripts/hotspot_helper.sh`, allowing you to host a hotspot while remaining connected to your existing Wi-Fi network; live connected client list with hostnames, IP, and MAC addresses. +- **Bluetooth Device Manager**: Built-in native C BlueZ D-Bus agent (`scripts/bluetooth_helper`) delivering instant desktop pairing authorization popups with PIN/passkey validation; one-click connect, disconnect, device removal, and auto-reconnect (trust) configuration; adapter discoverability toggle. +- **Universal Persistent DNS Switcher**: Instant switching between curated providers (Cloudflare, Google, Quad9, AdGuard) and custom DNS servers; automatically re-applies chosen DNS across network switches and system reboots. +- **Bar Widget & Shortcut**: Highly configurable top bar widget supporting single or multi-domain indicator modes; quick-toggle control center shortcut for one-click hotspot activation. + +## Usage + +1. Add the `Network Toolkit` widget to your bar in **Settings → Bar Widgets**. +2. Add the `Hotspot` shortcut tile in **Settings → Control Center Shortcuts**. +3. **Left-click** the bar widget to open the main network panel. +4. **Right-click** the bar widget to trigger the configured quick action (toggle Wi-Fi, Hotspot, or Bluetooth). + +Open the main panel via IPC: + +```sh +noctalia msg panel-toggle autumn/network-toolkit:panel +``` + +Or bind it to a keybinding in your Niri configuration (`~/.config/niri/config.kdl`): + +```kdl +binds { + Mod+N { spawn-sh "noctalia msg panel-toggle autumn/network-toolkit:panel"; } +} +``` + +## Settings + +All settings can be configured under **Settings → Plugins → Network Toolkit**: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refreshinterval` | `int` | `5` | Background state refresh interval in seconds (2–60). | +| `color` | `string` | `on_surface_variant` | Theme color token for widget glyphs. | +| `text_color` | `string` | `on_surface_variant` | Theme color token for widget labels. | +| `hotspot_ssid` | `string` | `Noctalia-Hotspot` | Default broadcast SSID for the hotspot. | +| `hotspot_password` | `string` | `noctaliapass` | WPA2 passphrase for the hotspot (minimum 8 characters). | +| `hotspot_max_connections` | `int` | `8` | Maximum client connections allowed on the hotspot (1–32). | +| `custom_dns_1` | `string` | `""` | Custom DNS slot 1 (`Name=IP`, e.g. `NextDNS=45.90.28.0 45.90.30.0`). | +| `custom_dns_2` | `string` | `""` | Custom DNS slot 2. | +| `custom_dns_3` | `string` | `""` | Custom DNS slot 3. | +| `bluetooth_download_dir` | `string` | `~/Downloads` | Target directory for incoming Bluetooth file transfers. | +| `bluetooth_device_name` | `string` | `""` | Custom broadcast alias for the local Bluetooth adapter. | +| `widget_display_mode` | `select` | `network` | Display mode: `network`, `bluetooth`, `dns`, `network_hotspot`, `network_bluetooth`, `network_dns`, `bluetooth_dns`, `network_bluetooth_dns`, `network_hotspot_bluetooth_dns`. | +| `widget_right_click` | `select` | `toggle_wifi` | Quick action on right-click: `toggle_wifi`, `toggle_hs`, or `toggle_bt`. | + +## IPC + +The background service synchronizes state across all entries via `network_command` and `network_state`. You can also trigger panel actions directly: + +```sh +# Toggle panel +noctalia msg panel-toggle autumn/network-toolkit:panel + +# Open panel +noctalia msg panel-open autumn/network-toolkit:panel + +# Close panel +noctalia msg panel-close autumn/network-toolkit:panel +``` + +## Notes + +- **Bluetooth Pairing Agent**: Uses a built-in D-Bus agent (`scripts/bluetooth_helper`) registered under `/org/noctalia/bluetooth_helper` to handle authorization and passkey requests directly with BlueZ. +- **Concurrent Hotspot**: Simultaneous Wi-Fi client and AP mode dynamically creates a virtual interface (`-ap`) via `scripts/hotspot_helper.sh`. If the wireless driver does not support multi-VIF concurrency, starting the hotspot will fall back to standard access point mode. +- **DNS Configuration**: DNS preferences are managed through NetworkManager device connections and persist across network switches. +- **Files Written**: Runtime pairing and DNS states are stored in `/tmp` during the active session. + +## License + +MIT diff --git a/network-toolkit/panel.luau b/network-toolkit/panel.luau new file mode 100644 index 00000000..a8aaab6a --- /dev/null +++ b/network-toolkit/panel.luau @@ -0,0 +1,532 @@ +--!nonstrict +-- Network Toolkit panel UI +local state = noctalia.state.get("network_state") or {} +local customDnsInput, passwordInput, selectedSsid = "", "", nil +local showPassword, showWifiQr, qrLoading, showHsQr = false, false, false, false +local qrImagePath, qrHsPath = "/tmp/network-toolkit-wifi-qr.png", "/tmp/network-toolkit-hotspot-qr.png" +local hoveredId, cmdSeq, animFrame = nil, 0, 0 +local activeLeftCard, activeRightCard = "wifi", "bluetooth" +local dismissedPairingAddresses, removedDevices, deviceActionOverrides = {}, {}, {} +local btConnecting, wifiConnectingSsid = {}, nil + +local LOADER = { "loader", "loader-2", "loader-3", "loader-quarter" } + +local function signalGlyph(val) + local s = tonumber(val) or 0 + return (s >= 88 and "wifi") or (s >= 63 and "wifi-3") or (s >= 38 and "wifi-2") or (s >= 13 and "wifi-1") or "wifi-0" +end + +local function sendCommand(domain, action, payload) + cmdSeq += 1 + noctalia.state.set("network_command", { domain = domain, action = action, payload = payload, nonce = os.time() .. "_" .. cmdSeq }) +end + +local function generateWifiQr(ssid, security, renderFn) + if not ssid or ssid == "" then return end + qrLoading = true; renderFn() + local safe = ssid:gsub("'", "'\\''") + noctalia.runAsync(string.format("nmcli -s -g 802-11-wireless-security.psk connection show '%s' 2>/dev/null || nmcli -s -g 802-11-wireless-security.psk connection show id '%s' 2>/dev/null", safe, safe), function(res) + local psk = (res.stdout or ""):gsub("%s+", "") + local sec = (security == "Open" or (psk == "" and security ~= "WPA2" and security ~= "WPA")) and "nopass" or "WPA" + noctalia.runAsync(string.format("qrencode -s 6 -m 2 -o '%s' 'WIFI:T:%s;S:%s;P:%s;;' 2>/dev/null", qrImagePath, sec, safe, psk:gsub("'", "'\\''")), function() + qrLoading = false; renderFn() + end, 4000) + end, 4000) +end + +local function generateHsQr(renderFn) + local s = (state.hotspot and state.hotspot.ssid ~= "") and state.hotspot.ssid or (noctalia.getConfig("hotspot_ssid") or "Noctalia-Hotspot") + local p = noctalia.getConfig("hotspot_password") or "noctaliapass" + if p == "" or #p < 8 then p = "noctaliapass" end + noctalia.runAsync(string.format("qrencode -s 6 -m 2 -o '%s' 'WIFI:T:WPA;S:%s;P:%s;;' 2>/dev/null", qrHsPath, s:gsub("'", "'\\''"), p:gsub("'", "'\\''")), function() renderFn() end, 4000) +end + +local render + +local function setHover(id, hState) + local target = (hState == "true") and id or nil + if hoveredId ~= target then hoveredId = target; if render then render() end end +end + +local function hintRow(text, glyph, color) + return ui.row({ align = "center", gap = 6, paddingV = 3, paddingH = 2 }, { + ui.glyph({ name = glyph or "info-circle", size = 14, color = color or "on_surface_variant" }), + ui.label({ text = text, fontSize = 11, color = color or "on_surface_variant" }) + }) +end + +local function renderDeviceRow(key, label, sublabel, glyphName, glyphColor, isHovered, isSelected, rightExtras, onClick) + local leftItems = { ui.glyph({ name = glyphName, size = 18, color = glyphColor or "on_surface_variant" }) } + if sublabel and sublabel ~= "" then + table.insert(leftItems, ui.column({ gap = 0 }, { + ui.label({ text = label, fontSize = 13, fontWeight = isSelected and "bold" or "normal", color = "on_surface" }), + ui.label({ text = sublabel, fontSize = 11, color = "on_surface_variant" }), + })) + else + table.insert(leftItems, ui.label({ text = label, fontSize = 13, fontWeight = isSelected and "bold" or "normal", color = "on_surface" })) + end + return ui.row({ + key = "row-" .. key, align = "center", justify = "space_between", + fill = "surface", + border = (isHovered or isSelected) and "primary" or "surface", + borderWidth = 1, radius = 8, height = 45, paddingV = 8, paddingH = 12, + onHover = function(s) setHover(key, s) end + }, { + ui.row({ align = "center", gap = 10, flexGrow = 1, onClick = onClick }, leftItems), + ui.row({ align = "center", gap = 8 }, rightExtras or {}) + }) +end + +local function smallBtn(glyph, variant, onClick) + return ui.button({ glyph = glyph, glyphSize = 14, width = 28, height = 28, variant = variant or "ghost", contentAlign = "center", onClick = onClick }) +end + +render = function() + state = noctalia.state.get("network_state") or state + local hs, net, dns, bt = state.hotspot or {}, state.net or {}, state.dns or {}, state.bluetooth or {} + local hsActive = (hs.active == true) or (net.hotspotActive == true) + local isConn = net.wifiConnected or net.ethernetConnected + local connTitle = net.ethernetConnected and (net.interface ~= "" and net.interface or noctalia.tr("status.ethernet")) + or (net.wifiConnected and (net.ssid ~= "" and net.ssid or noctalia.tr("status.wifi")) + or (hsActive and (hs.ssid or noctalia.tr("status.hotspot_active")) or noctalia.tr("status.disconnected"))) + + local connIp = (isConn and net.ipAddress ~= "") and (" (" .. net.ipAddress .. ")") or "" + local connDetails = isConn and (connTitle .. connIp .. " • " .. tostring(net.signal or 0) .. "% • " .. (net.band ~= "" and net.band or "—") .. (hsActive and (" • Hotspot (" .. tostring(hs.clientCount or 0) .. ")") or "")) + or (hsActive and (connTitle .. " • " .. tostring(hs.clientCount or 0) .. " Clients • Hotspot") or (connTitle .. " • —")) + + local spinner = LOADER[(animFrame % #LOADER) + 1] + local isCustomDns = (dns.activeProvider ~= nil and dns.activeProvider ~= "default") + local dnsLabel = isCustomDns and (dns.current and dns.current.label or dns.activeProvider) or noctalia.tr("panel.system_default") + + local subInfo = { + ui.row({ align = "center", gap = 6 }, { + ui.glyph({ name = isCustomDns and "shield" or "shield-off", size = 15, color = isCustomDns and "primary" or "on_surface_variant" }), + ui.label({ text = noctalia.tr("panel.dns_prefix"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = dnsLabel, fontSize = 12, color = "on_surface" }), + }) + } + if bt.pairedDevices then + for _, d in ipairs(bt.pairedDevices) do + if d.connected then + table.insert(subInfo, ui.row({ align = "center", gap = 6 }, { + ui.glyph({ name = d.icon or "bluetooth", size = 15, color = "primary" }), + ui.label({ text = noctalia.tr("panel.bt_prefix"), fontSize = 12, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = d.name or d.address, fontSize = 12, color = "on_surface" }), + smallBtn("bluetooth-off", "ghost", function() sendCommand("bluetooth", "disconnect", { address = d.address }) end) + })) + end + end + end + + local connCard = ui.column({ gap = 6, fill = "surface_variant", radius = 12, paddingV = 8, paddingH = 10 }, { + ui.label({ text = noctalia.tr("panel.current_connection"), fontSize = 13, fontWeight = "bold", color = "on_surface" }), + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = connDetails, fontSize = 12, fontWeight = "bold", color = "on_surface" }), + ui.button({ + glyph = isConn and "plug-off" or (hsActive and "broadcast-off" or "plug"), glyphSize = 16, + variant = (isConn or hsActive) and "destructive" or "primary", contentAlign = "center", + onClick = function() + if isConn then net.wifiConnected, net.ssid, net.ipAddress = false, "", ""; render(); sendCommand("wifi", "disconnect") + elseif hsActive then hs.active = not hs.active; render(); sendCommand("hotspot", "toggle") + else sendCommand("wifi", "auto_connect") end + end + }) + }), + ui.row({ align = "center", justify = "space_between", gap = 12 }, subInfo) + }) + + local leftControls = {} + if net.refreshing and activeLeftCard == "wifi" then table.insert(leftControls, ui.glyph({ name = spinner, size = 16, color = "primary" })) end + if activeLeftCard == "wifi" then table.insert(leftControls, ui.button({ glyph = "refresh", glyphSize = 16, variant = "ghost", contentAlign = "center", onClick = function() sendCommand("wifi", "scan") end })) end + if hs.active and activeLeftCard == "hotspot" then + table.insert(leftControls, smallBtn("qrcode", showHsQr and "primary" or "ghost", function() + showHsQr = not showHsQr; if showHsQr then generateHsQr(render) end; render() + end)) + end + table.insert(leftControls, ui.button({ + glyph = hs.active and "broadcast" or "broadcast-off", glyphSize = 16, + variant = (activeLeftCard == "hotspot") and "primary" or "ghost", contentAlign = "center", + onClick = function() if activeLeftCard == "wifi" then activeLeftCard = "hotspot"; render() else sendCommand("hotspot", "toggle") end end, + onRightClick = function() sendCommand("hotspot", "toggle") end + })) + table.insert(leftControls, ui.button({ + glyph = net.wifiEnabled and "wifi" or "wifi-off", glyphSize = 18, + variant = (activeLeftCard == "wifi") and "primary" or "ghost", contentAlign = "center", + onClick = function() + if activeLeftCard == "hotspot" then activeLeftCard = "wifi"; render() + else + net.wifiEnabled = not net.wifiEnabled + if not net.wifiEnabled then net.wifiConnected, net.ssid, net.ipAddress, net.availableNetworks = false, "", "", {} end + render(); sendCommand("wifi", "toggle") + end + end, + onRightClick = function() + net.wifiEnabled = not net.wifiEnabled + if not net.wifiEnabled then net.wifiConnected, net.ssid, net.ipAddress, net.availableNetworks = false, "", "", {} end + render(); sendCommand("wifi", "toggle") + end + })) + + local leftHeader = ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = (activeLeftCard == "wifi") and noctalia.tr("panel.wifi") or noctalia.tr("panel.hotspot"), fontSize = 13, fontWeight = "bold", color = "on_surface" }), + ui.row({ align = "center", gap = 6 }, leftControls) + }) + + local leftCard + if activeLeftCard == "wifi" then + if not net.wifiEnabled then + leftCard = ui.column({ flexGrow = 1, fill = "surface_variant", radius = 12, paddingV = 8, paddingH = 10, gap = 4 }, { leftHeader, hintRow(noctalia.tr("panel.wifi_off"), "wifi-off") }) + else + local wifiList = {} + local networks = net.availableNetworks or {} + local activeNet, savedNets, otherNets, seen = nil, {}, {}, {} + + for _, n in ipairs(networks) do + if n.active or (net.wifiConnected and net.ssid ~= "" and n.ssid == net.ssid) then + if not activeNet then activeNet = n end; seen[n.ssid] = true + elseif n.saved then + if n.signal > 0 and not seen[n.ssid] then table.insert(savedNets, n); seen[n.ssid] = true end + elseif not seen[n.ssid] then table.insert(otherNets, n); seen[n.ssid] = true end + end + + if activeNet or net.wifiConnected then + local sName = activeNet and activeNet.ssid or net.ssid + local sGlyph = signalGlyph(activeNet and (activeNet.bars or activeNet.signal) or (net.bars or net.signal or 0)) + local aKey = "active-" .. sName + local rBtns = {} + if net.signal > 0 then table.insert(rBtns, ui.label({ text = tostring(net.signal) .. "%", fontSize = 11, color = "on_surface_variant" })) end + if activeNet and activeNet.security and activeNet.security ~= "Open" then table.insert(rBtns, ui.glyph({ name = "lock", size = 14, color = "on_surface_variant" })) end + table.insert(rBtns, smallBtn("qrcode", showWifiQr and "primary" or "ghost", function() showWifiQr = not showWifiQr; if showWifiQr then generateWifiQr(sName, activeNet and activeNet.security or "WPA", render) end; render() end)) + table.insert(rBtns, ui.glyph({ name = "check", size = 16, color = "primary" })) + table.insert(wifiList, renderDeviceRow(aKey, sName, nil, sGlyph, "primary", hoveredId == aKey, false, rBtns, function() if activeNet then sendCommand("wifi", "connect", { ssid = activeNet.ssid }) end end)) + + if showWifiQr then + local qrContent = {} + if qrLoading then + table.insert(qrContent, ui.row({ align = "center", justify = "center", height = 130 }, { ui.glyph({ name = spinner, size = 18, color = "primary" }), ui.label({ text = noctalia.tr("panel.generating_qr"), fontSize = 12, color = "on_surface_variant" }) })) + elseif noctalia.fileExists and noctalia.fileExists(qrImagePath) then + table.insert(qrContent, ui.column({ align = "center", gap = 6, paddingV = 8 }, { ui.image({ path = qrImagePath, width = 140, height = 140, radius = 8 }), ui.label({ text = noctalia.tr("panel.scan_to_connect"), fontSize = 11, color = "on_surface_variant" }) })) + end + table.insert(wifiList, ui.column({ key = "qr-box", fill = "surface", radius = 8, border = "primary", borderWidth = 1, paddingV = 6, paddingH = 8, align = "center" }, qrContent)) + end + end + + if #savedNets > 0 then + table.insert(wifiList, ui.label({ text = noctalia.tr("panel.saved_networks"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" })) + for _, n in ipairs(savedNets) do + local k, isC = "saved-" .. n.ssid, (wifiConnectingSsid == n.ssid) + local extras = {} + if n.signal > 0 then table.insert(extras, ui.label({ text = tostring(n.signal) .. "%", fontSize = 11, color = "on_surface_variant" })) end + if n.security ~= "Open" then table.insert(extras, ui.glyph({ name = "lock", size = 14, color = "on_surface_variant" })) end + if isC then table.insert(extras, ui.glyph({ name = spinner, size = 14, color = "primary" })) end + table.insert(extras, smallBtn("trash", "ghost", function() sendCommand("wifi", "forget", { ssid = n.ssid }) end)) + table.insert(wifiList, renderDeviceRow(k, n.ssid, nil, signalGlyph(n.signal or 0), isC and "primary" or "on_surface_variant", hoveredId == k, false, extras, function() + wifiConnectingSsid = n.ssid; render(); sendCommand("wifi", "connect", { ssid = n.ssid }) + end)) + end + end + + if #otherNets > 0 then + table.insert(wifiList, ui.label({ text = noctalia.tr("panel.available_networks"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" })) + for _, n in ipairs(otherNets) do + local k, isSel = "avail-" .. n.ssid, (selectedSsid == n.ssid) + local extras = {} + if n.signal > 0 then table.insert(extras, ui.label({ text = tostring(n.signal) .. "%", fontSize = 11, color = "on_surface_variant" })) end + if n.security ~= "Open" then table.insert(extras, ui.glyph({ name = "lock", size = 14, color = "on_surface_variant" })) end + table.insert(wifiList, renderDeviceRow(k, n.ssid, nil, signalGlyph(n.signal or 0), "on_surface_variant", hoveredId == k, isSel, extras, function() + if n.security == "Open" then sendCommand("wifi", "connect", { ssid = n.ssid }) + else selectedSsid = (selectedSsid == n.ssid) and nil or n.ssid; passwordInput = ""; showPassword = false; render() end + end)) + + if isSel then + local function submitPwd() if passwordInput ~= "" then sendCommand("wifi", "connect", { ssid = n.ssid, password = passwordInput }); selectedSsid, passwordInput, showPassword = nil, "", false; render() end end + table.insert(wifiList, ui.column({ key = "pwd-" .. k, gap = 4, fill = "surface", radius = 8, paddingV = 8, paddingH = 10 }, { + ui.label({ text = noctalia.tr("panel.password_for") .. " " .. n.ssid, fontSize = 11, color = "on_surface_variant" }), + ui.row({ align = "center", gap = 6 }, { + ui.input({ placeholder = noctalia.tr("panel.password_placeholder"), value = passwordInput, password = not showPassword, fontSize = 11, flexGrow = 1, onChange = function(v) passwordInput = v end, onSubmit = submitPwd }), + smallBtn(showPassword and "eye-off" or "eye", "ghost", function() showPassword = not showPassword; render() end), + ui.button({ text = noctalia.tr("panel.connect"), variant = "primary", fontSize = 11, onClick = submitPwd }) + }) + })) + end + end + elseif not net.wifiConnected and not activeNet and #savedNets == 0 then + table.insert(wifiList, hintRow(net.refreshing and noctalia.tr("panel.scanning") or noctalia.tr("panel.no_networks"), net.refreshing and spinner or "info-circle")) + end + leftCard = ui.column({ flexGrow = 1, fill = "surface_variant", radius = 12, paddingV = 8, paddingH = 10, gap = 4 }, { leftHeader, ui.scroll({ flexGrow = 1, gap = 4 }, wifiList) }) + end + else + local cList = hs.clients or {} + local bandText = (hs.band == "a" or hs.band == "5GHz" or hs.band == "5 GHz") and "5 GHz" or "2.4 GHz" + if hs.channel and hs.channel > 0 then bandText = bandText .. " (Ch " .. tostring(hs.channel) .. ")" end + local scrollEl = {} + if showHsQr and hs.active and noctalia.fileExists and noctalia.fileExists(qrHsPath) then + table.insert(scrollEl, ui.column({ fill = "surface", radius = 8, paddingV = 6, paddingH = 8, align = "center", gap = 4 }, { + ui.image({ path = qrHsPath, width = 130, height = 130, radius = 6 }), + ui.label({ text = noctalia.tr("panel.scan_to_connect"), fontSize = 10, color = "on_surface_variant" }) + })) + end + if hs.active then + table.insert(scrollEl, ui.label({ text = noctalia.tr("panel.connected_devices_ratio", { count = tostring(#cList), max = tostring(hs.maxConnections or 8) }), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" })) + if #cList == 0 then table.insert(scrollEl, ui.label({ text = noctalia.tr("panel.no_devices"), fontSize = 11, color = "on_surface_variant" })) + else + for _, c in ipairs(cList) do + table.insert(scrollEl, ui.row({ align = "center", justify = "start", fill = "surface", radius = 8, height = 44, paddingV = 6, paddingH = 10, gap = 10 }, { + ui.glyph({ name = "device-mobile", size = 16, color = "primary" }), + ui.column({ gap = 1 }, { + ui.label({ text = (c.name and c.name ~= "") and c.name or c.ip, fontSize = 12, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = c.ip .. " • " .. c.mac, fontSize = 10, color = "on_surface_variant" }) + }) + })) + end + end + end + leftCard = ui.column({ flexGrow = 1, fill = "surface_variant", radius = 12, paddingV = 8, paddingH = 10, gap = 4 }, { + leftHeader, + hintRow(hs.active and ((hs.ssid or "Noctalia-Hotspot") .. " • " .. bandText) or noctalia.tr("panel.hotspot_inactive_tip"), hs.active and "broadcast" or "broadcast-off", hs.active and "primary" or "on_surface_variant"), + ui.scroll({ flexGrow = 1, gap = 4 }, scrollEl) + }) + end + + -- Right Card (Bluetooth / DNS) + local rightControls = {} + if bt.scanning and activeRightCard == "bluetooth" then table.insert(rightControls, ui.glyph({ name = spinner, size = 16, color = "primary" })) end + if bt.enabled and activeRightCard == "bluetooth" then table.insert(rightControls, ui.button({ glyph = "refresh", glyphSize = 16, variant = "ghost", contentAlign = "center", onClick = function() sendCommand("bluetooth", "scan") end })) end + table.insert(rightControls, ui.button({ + glyph = isCustomDns and "shield" or "shield-off", glyphSize = 16, variant = (activeRightCard == "dns") and "primary" or "ghost", contentAlign = "center", + onClick = function() if activeRightCard == "bluetooth" then activeRightCard = "dns"; render() else sendCommand("dns", "toggle") end end, + onRightClick = function() sendCommand("dns", "toggle") end + })) + table.insert(rightControls, ui.button({ + glyph = bt.enabled and "bluetooth" or "bluetooth-off", glyphSize = 18, variant = (activeRightCard == "bluetooth") and "primary" or "ghost", contentAlign = "center", + onClick = function() + if activeRightCard == "dns" then activeRightCard = "bluetooth"; render() + else if state.bluetooth then state.bluetooth.enabled = not state.bluetooth.enabled end; render(); sendCommand("bluetooth", "toggle") end + end, + onRightClick = function() if state.bluetooth then state.bluetooth.enabled = not state.bluetooth.enabled end; render(); sendCommand("bluetooth", "toggle") end + })) + + local rightHeader = ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = (activeRightCard == "bluetooth") and noctalia.tr("panel.bluetooth") or noctalia.tr("panel.dns_service"), fontSize = 13, fontWeight = "bold", color = "on_surface" }), + ui.row({ align = "center", gap = 6 }, rightControls) + }) + + local rightCard + if activeRightCard == "bluetooth" then + local btList = {} + if not bt.enabled then + table.insert(btList, hintRow(noctalia.tr("panel.bt_off"), "bluetooth-off")) + else + table.insert(btList, ui.row({ + key = "row-bt-disc", align = "center", justify = "space_between", paddingV = 4, paddingH = 4, + onClick = function() if state.bluetooth then state.bluetooth.discoverable = not state.bluetooth.discoverable end; render(); sendCommand("bluetooth", "discoverable") end + }, { + ui.label({ text = noctalia.tr("panel.discoverable"), fontSize = 13, fontWeight = "bold", color = "on_surface" }), + ui.glyph({ name = bt.discoverable and "toggle-right-filled" or "toggle-left-filled", size = 30, color = bt.discoverable and "primary" or "on_surface_variant" }) + })) + + local now = os.time() + for _, req in ipairs(bt.pairingRequests or {}) do + local addr = req.address or "" + local exp = dismissedPairingAddresses[addr] + if not exp or now > exp then + table.insert(btList, ui.column({ key = "pair-req-" .. addr, gap = 8, fill = "surface", radius = 8, paddingV = 8, paddingH = 10 }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = req.icon or "bluetooth", size = 18, color = "primary" }), + ui.column({ gap = 2 }, { + ui.label({ text = noctalia.tr("panel.pairing_request_msg", { name = req.name or addr }), fontSize = 12, fontWeight = "bold", color = "on_surface" }), + req.passkey and ui.label({ text = noctalia.tr("panel.pairing_passkey", { key = tostring(req.passkey) }), fontSize = 11, fontWeight = "bold", color = "primary" }) or nil + }) + }), + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.button({ text = noctalia.tr("panel.accept"), variant = "primary", fontSize = 11, flexGrow = 1, contentAlign = "center", onClick = function() dismissedPairingAddresses[addr] = os.time() + 10; if state.bluetooth then state.bluetooth.pairingRequests = {} end; render(); sendCommand("bluetooth", "accept_pair", { address = addr }) end }), + ui.button({ text = noctalia.tr("panel.reject"), variant = "destructive", fontSize = 11, flexGrow = 1, contentAlign = "center", onClick = function() dismissedPairingAddresses[addr] = os.time() + 10; if state.bluetooth then state.bluetooth.pairingRequests = {} end; render(); sendCommand("bluetooth", "reject_pair", { address = addr }) end }) + }) + })) + end + end + + local connP, otherP = {}, {} + for _, d in ipairs(bt.pairedDevices or {}) do + local la = (d.address or ""):lower() + local exp = removedDevices[la] + if not exp or now > exp then + if d.connected then table.insert(connP, d) else table.insert(otherP, d) end + end + end + + local function renderBtDevice(d, isConnectedSec) + local k, la = "bt-" .. d.address, (d.address or ""):lower() + local isAuto = (d.autoReconnect == true or d.trusted == true) + local isC = (btConnecting[la] and (now < btConnecting[la])) or (d.connecting == true) + local btns = {} + + if isConnectedSec then + btConnecting[la] = nil + table.insert(btns, ui.glyph({ name = "check", size = 14, color = "primary" })) + table.insert(btns, smallBtn("bluetooth-off", "ghost", function() + deviceActionOverrides[la] = { connected = false, expires = os.time() + 4 } + d.connected = false; render(); sendCommand("bluetooth", "disconnect", { address = d.address }) + end)) + else + if isC then table.insert(btns, ui.glyph({ name = spinner, size = 14, color = "primary" })) + else + table.insert(btns, smallBtn("bluetooth-connected", "ghost", function() + btConnecting[la] = os.time() + 4; d.connecting = true; render(); sendCommand("bluetooth", "connect", { address = d.address }) + end)) + end + end + + table.insert(btns, smallBtn(isAuto and "repeat" or "repeat-off", isAuto and "primary" or "ghost", function() + local nextTr = not d.trusted + deviceActionOverrides[la] = { trusted = nextTr, expires = os.time() + 4 } + d.trusted, d.autoReconnect = nextTr, nextTr; render(); sendCommand("bluetooth", "toggle_autoreconnect", { address = d.address }) + end)) + + table.insert(btns, smallBtn("trash", "ghost", function() + removedDevices[la] = os.time() + 10; btConnecting[la], deviceActionOverrides[la] = nil, nil + if state.bluetooth and state.bluetooth.pairedDevices then + local p = {} + for _, dev in ipairs(state.bluetooth.pairedDevices) do if (dev.address or ""):lower() ~= la then table.insert(p, dev) end end + state.bluetooth.pairedDevices = p + end + render(); sendCommand("bluetooth", "remove", { address = d.address }) + end)) + + return renderDeviceRow(k, d.name, (not isConnectedSec and isC) and noctalia.tr("panel.connecting") or nil, d.icon or "bluetooth", (isConnectedSec or isC) and "primary" or "on_surface_variant", hoveredId == k, false, btns, function() + if isConnectedSec then + deviceActionOverrides[la] = { connected = false, expires = os.time() + 4 } + d.connected = false; render(); sendCommand("bluetooth", "disconnect", { address = d.address }) + elseif not isC then + btConnecting[la] = os.time() + 4; d.connecting = true; render(); sendCommand("bluetooth", "connect", { address = d.address }) + end + end) + end + + if #connP > 0 then + table.insert(btList, ui.label({ text = noctalia.tr("panel.connected_devices"), fontSize = 11, fontWeight = "bold", color = "primary" })) + for _, d in ipairs(connP) do table.insert(btList, renderBtDevice(d, true)) end + end + if #otherP > 0 then + table.insert(btList, ui.label({ text = noctalia.tr("panel.paired_devices"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" })) + for _, d in ipairs(otherP) do table.insert(btList, renderBtDevice(d, false)) end + end + + local avail = bt.availableDevices or {} + if #avail > 0 then + table.insert(btList, ui.label({ text = noctalia.tr("panel.available_devices"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" })) + for _, d in ipairs(avail) do + local k = "bt-" .. d.address + local btns = { smallBtn("bluetooth-connected", "ghost", function() sendCommand("bluetooth", "pair", { address = d.address }) end) } + table.insert(btList, renderDeviceRow(k, d.name, nil, d.icon or "bluetooth", "on_surface_variant", hoveredId == k, false, btns, function() sendCommand("bluetooth", "pair", { address = d.address }) end)) + end + elseif #connP == 0 and #otherP == 0 then + table.insert(btList, hintRow(bt.scanning and noctalia.tr("panel.bt_scanning") or noctalia.tr("panel.no_bt_devices"), bt.scanning and spinner or "info-circle")) + end + end + rightCard = ui.column({ flexGrow = 1, fill = "surface_variant", radius = 12, paddingV = 8, paddingH = 10, gap = 4 }, { rightHeader, ui.scroll({ flexGrow = 1, gap = 4 }, btList) }) + else + local dnsList = {} + local actId = dns.activeProvider or "default" + for _, p in ipairs(dns.presets or {}) do + table.insert(dnsList, ui.button({ + key = "dns-" .. p.id, text = p.label or p.id, glyph = p.glyph or "shield", glyphSize = 14, + variant = (p.id == actId) and "primary" or "ghost", contentAlign = "start", fontSize = 12, + onClick = function() sendCommand("dns", "apply", p.id) end + })) + end + local function applyCust() + if customDnsInput ~= "" then sendCommand("dns", "apply", { provider = "custom", servers = customDnsInput }); customDnsInput = ""; render() end + end + table.insert(dnsList, ui.row({ align = "center", gap = 6, paddingV = 1 }, { + ui.input({ placeholder = noctalia.tr("panel.custom_dns_placeholder"), value = customDnsInput, fontSize = 11, flexGrow = 1, onChange = function(v) customDnsInput = v end, onSubmit = applyCust }), + ui.button({ text = noctalia.tr("panel.apply"), variant = "primary", fontSize = 11, onClick = applyCust }) + })) + rightCard = ui.column({ flexGrow = 1, fill = "surface_variant", radius = 12, paddingV = 7, paddingH = 9, gap = 3 }, { rightHeader, ui.scroll({ flexGrow = 1, gap = 2 }, dnsList) }) + end + + -- Root layout + panel.render(ui.column({ flexGrow = 1, gap = 6 }, { + ui.row({ align = "center", justify = "space_between" }, { + ui.row({ align = "center", gap = 6 }, { ui.glyph({ name = "world", size = 18, color = "primary" }), ui.label({ text = noctalia.tr("title"), fontSize = 14, fontWeight = "bold", color = "primary" }) }), + ui.row({ align = "center", gap = 4 }, { + ui.button({ glyph = "refresh", variant = "ghost", contentAlign = "center", onClick = function() sendCommand("system", "refresh") end }), + ui.button({ glyph = "x", variant = "ghost", contentAlign = "center", onClick = function() panel.close() end }) + }) + }), + connCard, + ui.row({ flexGrow = 1, gap = 6, align = "stretch" }, { + ui.column({ flexGrow = 1, width = "50%" }, { leftCard }), + ui.column({ flexGrow = 1, width = "50%" }, { rightCard }), + }) + })) +end + +local lastHsPoll = 0 +function onOpen(_context) + state = noctalia.state.get("network_state") or state + selectedSsid, passwordInput, showPassword, showWifiQr, showHsQr, hoveredId = nil, "", false, false, false, nil + activeLeftCard, activeRightCard = "wifi", "bluetooth" + render() + if not state.net or not state.net.availableNetworks or #state.net.availableNetworks == 0 then sendCommand("wifi", "scan") end + if state.hotspot and state.hotspot.active then sendCommand("hotspot", "refresh") end + if state.bluetooth and state.bluetooth.enabled then sendCommand("bluetooth", "refresh") end +end + +function update() + local now = os.time() + local isRef = state.net and state.net.refreshing + local isBtScan = state.bluetooth and state.bluetooth.scanning + if isRef or isBtScan or qrLoading or next(btConnecting) ~= nil or (wifiConnectingSsid ~= nil) then + animFrame += 1; render() + end + + local pairRaw = noctalia.readFile("/tmp/noctalia_bt_pairing.json") + if pairRaw and pairRaw ~= "" then + local a, n, pk = pairRaw:match('"address":"([^"]+)"'), pairRaw:match('"name":"([^"]+)"') or "Bluetooth Device", pairRaw:match('"passkey":"([^"]*)"') or "" + local ts = tonumber(pairRaw:match('"timestamp":(%d+)')) or now + if a and (now - ts) < 20 then + local exp = dismissedPairingAddresses[a] + if not exp or now > exp then + if not state.bluetooth then state.bluetooth = {} end + local cur = state.bluetooth.pairingRequests or {} + local exists = false + for _, r in ipairs(cur) do if r.address == a and r.passkey == pk then exists = true; break end end + if not exists then state.bluetooth.pairingRequests = { { address = a, name = n, passkey = pk, timestamp = ts } }; render() end + end + end + end + + if activeLeftCard == "hotspot" and state.hotspot and state.hotspot.active and (now - lastHsPoll >= 1) then + lastHsPoll = now; sendCommand("hotspot", "refresh") + end +end + +noctalia.setUpdateInterval(500) + +noctalia.state.watch("network_state", function(newState) + if type(newState) == "table" then + state = newState + local now = os.time() + if state.bluetooth and state.bluetooth.pairedDevices then + local filtered = {} + for _, d in ipairs(state.bluetooth.pairedDevices) do + local la = (d.address or ""):lower() + local exp = removedDevices[la] + if not exp or now > exp then + local ov = deviceActionOverrides[la] + if ov and now < ov.expires then + if ov.connected ~= nil then d.connected = ov.connected end + if ov.trusted ~= nil then d.trusted, d.autoReconnect = ov.trusted, ov.trusted end + end + table.insert(filtered, d) + end + end + state.bluetooth.pairedDevices = filtered + end + render() + end +end) + +function onConfigChanged() render() end +render() diff --git a/network-toolkit/plugin.toml b/network-toolkit/plugin.toml new file mode 100644 index 00000000..15338c7c --- /dev/null +++ b/network-toolkit/plugin.toml @@ -0,0 +1,142 @@ +id = "autumn/network-toolkit" +name = "Network Toolkit" +version = "0.1.0" +plugin_api = 10 +author = "Autumn" +license = "MIT" +icon = "world" +description = "Unified control panel for Bluetooth, Wi-Fi, Ethernet, Hotspot, and DNS Switcher." +tags = ["network", "bar", "panel", "service", "utility"] +dependencies = ["networkmanager", "iw", "ip", "qrencode", "bluez-utils"] + +[[setting]] +key = "refreshinterval" +type = "int" +label_key = "settings.refreshinterval.label" +description_key = "settings.refreshinterval.description" +default = 5 +min = 2 +max = 60 + +[[setting]] +key = "color" +type = "string" +label_key = "settings.color.label" +description_key = "settings.color.description" +default = "on_surface_variant" + +[[setting]] +key = "text_color" +type = "string" +label_key = "settings.text_color.label" +description_key = "settings.text_color.description" +default = "on_surface_variant" + +[[setting]] +key = "hotspot_ssid" +type = "string" +label_key = "settings.hotspot_ssid.label" +description_key = "settings.hotspot_ssid.description" +default = "Noctalia-Hotspot" + +[[setting]] +key = "hotspot_password" +type = "string" +label_key = "settings.hotspot_password.label" +description_key = "settings.hotspot_password.description" +default = "noctaliapass" + +[[setting]] +key = "hotspot_max_connections" +type = "int" +label_key = "settings.hotspot_max_connections.label" +description_key = "settings.hotspot_max_connections.description" +default = 8 +min = 1 +max = 32 + +[[setting]] +key = "custom_dns_1" +type = "string" +label_key = "settings.custom_dns_1.label" +description_key = "settings.custom_dns_1.description" +default = "" + +[[setting]] +key = "custom_dns_2" +type = "string" +label_key = "settings.custom_dns_2.label" +description_key = "settings.custom_dns_2.description" +default = "" + +[[setting]] +key = "custom_dns_3" +type = "string" +label_key = "settings.custom_dns_3.label" +description_key = "settings.custom_dns_3.description" +default = "" + +[[setting]] +key = "bluetooth_download_dir" +type = "string" +label_key = "settings.bluetooth_download_dir.label" +description_key = "settings.bluetooth_download_dir.description" +default = "~/Downloads" + +[[setting]] +key = "bluetooth_device_name" +type = "string" +label_key = "settings.bluetooth_device_name.label" +description_key = "settings.bluetooth_device_name.description" +default = "" + +[[setting]] +key = "widget_display_mode" +type = "select" +label_key = "settings.widget_display_mode.label" +description_key = "settings.widget_display_mode.description" +default = "network" +options = [ + { value = "network", label_key = "settings.widget_display_mode.network" }, + { value = "bluetooth", label_key = "settings.widget_display_mode.bluetooth" }, + { value = "dns", label_key = "settings.widget_display_mode.dns" }, + { value = "network_hotspot", label_key = "settings.widget_display_mode.network_hotspot" }, + { value = "network_bluetooth", label_key = "settings.widget_display_mode.network_bluetooth" }, + { value = "network_dns", label_key = "settings.widget_display_mode.network_dns" }, + { value = "bluetooth_dns", label_key = "settings.widget_display_mode.bluetooth_dns" }, + { value = "network_bluetooth_dns", label_key = "settings.widget_display_mode.network_bluetooth_dns" }, + { value = "network_hotspot_bluetooth_dns", label_key = "settings.widget_display_mode.network_hotspot_bluetooth_dns" }, +] + +[[setting]] +key = "widget_right_click" +type = "select" +label_key = "settings.widget_right_click.label" +description_key = "settings.widget_right_click.description" +default = "toggle_wifi" +options = [ + { value = "toggle_wifi", label_key = "settings.widget_right_click.toggle_wifi" }, + { value = "toggle_hs", label_key = "settings.widget_right_click.toggle_hs" }, + { value = "toggle_bt", label_key = "settings.widget_right_click.toggle_bt" }, +] + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 700 +height = 505 +placement = "attached" +position = "top_right" +open_near_click = true diff --git a/network-toolkit/scripts/bluetooth_helper b/network-toolkit/scripts/bluetooth_helper new file mode 100755 index 00000000..40dcaa6e Binary files /dev/null and b/network-toolkit/scripts/bluetooth_helper differ diff --git a/network-toolkit/scripts/bluetooth_helper.c b/network-toolkit/scripts/bluetooth_helper.c new file mode 100644 index 00000000..4fea59b9 --- /dev/null +++ b/network-toolkit/scripts/bluetooth_helper.c @@ -0,0 +1,227 @@ +// Native BlueZ D-Bus agent helper for Bluetooth pairing +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#define AGENT_PATH "/org/noctalia/bluetooth_helper" +#define JSON_PATH "/tmp/noctalia_bt_pairing.json" +#define ACT_PATH "/tmp/noctalia_bt_pairing_action" + +static void clean_files(void) { + unlink(JSON_PATH); + unlink(ACT_PATH); +} + +// Extract MAC address and alias from D-Bus object path +static void get_device_info(DBusConnection *conn, const char *dev_path, char *addr, char *name) { + strcpy(addr, dev_path); + strcpy(name, "Bluetooth Device"); + const char *p = strstr(dev_path, "dev_"); + if (p) { + strncpy(addr, p + 4, 17); + addr[17] = '\0'; + for (int i = 0; addr[i]; i++) if (addr[i] == '_') addr[i] = ':'; + } + DBusMessage *msg = dbus_message_new_method_call("org.bluez", dev_path, "org.freedesktop.DBus.Properties", "Get"); + if (!msg) return; + const char *iface = "org.bluez.Device1", *prop_name = "Alias"; + dbus_message_append_args(msg, DBUS_TYPE_STRING, &iface, DBUS_TYPE_STRING, &prop_name, DBUS_TYPE_INVALID); + DBusMessage *reply = dbus_connection_send_with_reply_and_block(conn, msg, 1000, NULL); + dbus_message_unref(msg); + if (reply) { + DBusMessageIter iter, sub; + if (dbus_message_iter_init(reply, &iter) && dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_VARIANT) { + dbus_message_iter_recurse(&iter, &sub); + if (dbus_message_iter_get_arg_type(&sub) == DBUS_TYPE_STRING) { + const char *val; + dbus_message_iter_get_basic(&sub, &val); + if (val && *val) strncpy(name, val, 63); + } + } + dbus_message_unref(reply); + } +} + +static int wait_user_action(void) { + time_t t0 = time(NULL); + while (time(NULL) - t0 < 20) { + FILE *f = fopen(ACT_PATH, "r"); + if (f) { + char buf[32] = {0}; + if (fgets(buf, sizeof(buf), f)) { + fclose(f); + clean_files(); + return (strcasestr(buf, "accept") != NULL || strcasestr(buf, "yes") != NULL); + } + fclose(f); + } + usleep(50000); + } + clean_files(); + return 0; +} + +// Write pairing details to JSON and await user response +static int handle_req(DBusConnection *conn, const char *dev_path, const char *passkey, int wait) { + char addr[64] = {0}, name[64] = {0}; + get_device_info(conn, dev_path, addr, name); + FILE *f = fopen(JSON_PATH, "w"); + if (f) { + fprintf(f, "{\"address\":\"%s\",\"name\":\"%s\",\"passkey\":\"%s\",\"timestamp\":%ld}\n", addr, name, passkey ? passkey : "", (long)time(NULL)); + fflush(f); + fclose(f); + } + char cmd[256]; + if (passkey && *passkey) snprintf(cmd, sizeof(cmd), "notify-send -u normal -t 6000 'Network Toolkit' '%s requested to pair (PIN: %s)' 2>/dev/null", name, passkey); + else snprintf(cmd, sizeof(cmd), "notify-send -u normal -t 6000 'Network Toolkit' '%s requested to pair' 2>/dev/null", name); + system(cmd); + return wait ? wait_user_action() : 1; +} + +static DBusHandlerResult agent_filter(DBusConnection *conn, DBusMessage *msg, void *user_data) { + const char *member = dbus_message_get_member(msg); + if (!member) return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; + + if (strcmp(member, "Release") == 0) { + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "AuthorizeService") == 0) { + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "RequestAuthorization") == 0) { + const char *dev = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_INVALID); + if (handle_req(conn, dev ? dev : "", "", 1)) { + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + } else { + DBusMessage *err = dbus_message_new_error(msg, "org.bluez.Error.Rejected", "Pairing rejected"); + dbus_connection_send(conn, err, NULL); + dbus_message_unref(err); + } + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "RequestConfirmation") == 0) { + const char *dev = NULL; + dbus_uint32_t passkey = 0; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_UINT32, &passkey, DBUS_TYPE_INVALID); + char pbuf[16]; + snprintf(pbuf, sizeof(pbuf), "%06u", passkey); + if (handle_req(conn, dev ? dev : "", pbuf, 1)) { + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + } else { + DBusMessage *err = dbus_message_new_error(msg, "org.bluez.Error.Rejected", "Pairing rejected"); + dbus_connection_send(conn, err, NULL); + dbus_message_unref(err); + } + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "RequestPasskey") == 0) { + const char *dev = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_INVALID); + if (handle_req(conn, dev ? dev : "", "", 1)) { + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_uint32_t passkey = 0; + dbus_message_append_args(reply, DBUS_TYPE_UINT32, &passkey, DBUS_TYPE_INVALID); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + } else { + DBusMessage *err = dbus_message_new_error(msg, "org.bluez.Error.Rejected", "Pairing rejected"); + dbus_connection_send(conn, err, NULL); + dbus_message_unref(err); + } + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "RequestPinCode") == 0) { + const char *dev = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_INVALID); + if (handle_req(conn, dev ? dev : "", "0000", 1)) { + DBusMessage *reply = dbus_message_new_method_return(msg); + const char *pin = "0000"; + dbus_message_append_args(reply, DBUS_TYPE_STRING, &pin, DBUS_TYPE_INVALID); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + } else { + DBusMessage *err = dbus_message_new_error(msg, "org.bluez.Error.Rejected", "Pairing rejected"); + dbus_connection_send(conn, err, NULL); + dbus_message_unref(err); + } + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "DisplayPasskey") == 0) { + const char *dev = NULL; + dbus_uint32_t passkey = 0; + dbus_uint16_t entered = 0; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_UINT32, &passkey, DBUS_TYPE_UINT16, &entered, DBUS_TYPE_INVALID); + char pbuf[16]; + snprintf(pbuf, sizeof(pbuf), "%06u", passkey); + handle_req(conn, dev ? dev : "", pbuf, 0); + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "DisplayPinCode") == 0) { + const char *dev = NULL, *pin = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_OBJECT_PATH, &dev, DBUS_TYPE_STRING, &pin, DBUS_TYPE_INVALID); + handle_req(conn, dev ? dev : "", pin ? pin : "", 0); + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + return DBUS_HANDLER_RESULT_HANDLED; + } + if (strcmp(member, "Cancel") == 0) { + clean_files(); + DBusMessage *reply = dbus_message_new_method_return(msg); + dbus_connection_send(conn, reply, NULL); + dbus_message_unref(reply); + return DBUS_HANDLER_RESULT_HANDLED; + } + return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; +} + +int main(void) { + clean_files(); + DBusError err; + dbus_error_init(&err); + DBusConnection *conn = dbus_bus_get(DBUS_BUS_SYSTEM, &err); + if (!conn) return 1; + + DBusObjectPathVTable vtable = { .message_function = agent_filter }; + dbus_connection_register_object_path(conn, AGENT_PATH, &vtable, NULL); + + const char *path = AGENT_PATH, *cap = "KeyboardDisplay"; + DBusMessage *msg = dbus_message_new_method_call("org.bluez", "/org/bluez", "org.bluez.AgentManager1", "RegisterAgent"); + if (msg) { + dbus_message_append_args(msg, DBUS_TYPE_OBJECT_PATH, &path, DBUS_TYPE_STRING, &cap, DBUS_TYPE_INVALID); + DBusMessage *rep = dbus_connection_send_with_reply_and_block(conn, msg, 2000, NULL); + if (rep) dbus_message_unref(rep); + dbus_message_unref(msg); + } + + msg = dbus_message_new_method_call("org.bluez", "/org/bluez", "org.bluez.AgentManager1", "RequestDefaultAgent"); + if (msg) { + dbus_message_append_args(msg, DBUS_TYPE_OBJECT_PATH, &path, DBUS_TYPE_INVALID); + DBusMessage *rep = dbus_connection_send_with_reply_and_block(conn, msg, 2000, NULL); + if (rep) dbus_message_unref(rep); + dbus_message_unref(msg); + } + + system("bluetoothctl discoverable on 2>/dev/null; bluetoothctl pairable on 2>/dev/null"); + + while (dbus_connection_read_write_dispatch(conn, -1)) {} + return 0; +} diff --git a/network-toolkit/scripts/hotspot_helper.sh b/network-toolkit/scripts/hotspot_helper.sh new file mode 100755 index 00000000..073fd405 --- /dev/null +++ b/network-toolkit/scripts/hotspot_helper.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Creates virtual AP interface for concurrent client and hotspot operation +set -euo pipefail + +BASE="${1:-${HOTSPOT_BASE:-}}" +if [ -z "$BASE" ]; then + BASE=$(nmcli -t -f DEVICE,TYPE device 2>/dev/null | awk -F: '$2=="wifi" && $1!~/-ap$/ && $1!~/^p2p-/ {print $1; exit}') + [ -z "$BASE" ] && command -v iw >/dev/null 2>&1 && BASE=$(iw dev 2>/dev/null | awk '/Interface/ {if ($2 !~ /-ap$/ && $2 !~ /^p2p-/) {print $2; exit}}') + [ -z "$BASE" ] && BASE="wlan0" +fi + +AP="${2:-${HOTSPOT_IFACE:-${BASE}-ap}}" +[ -d "/sys/class/net/${AP}" ] && { ip link set "${AP}" up 2>/dev/null || true; exit 0; } + +RUN() { + if [ "$(id -u)" -eq 0 ]; then "$@" 2>/dev/null || true + elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then sudo -n "$@" 2>/dev/null || true + elif command -v pkexec >/dev/null 2>&1; then pkexec "$@" 2>/dev/null || true + else "$@" 2>/dev/null || true + fi +} + +RUN iw dev "${BASE}" interface add "${AP}" type __ap +ip link set "${AP}" up 2>/dev/null || true diff --git a/network-toolkit/service.luau b/network-toolkit/service.luau new file mode 100644 index 00000000..f352177a --- /dev/null +++ b/network-toolkit/service.luau @@ -0,0 +1,117 @@ +--!nonstrict +-- Network Toolkit — Background service and event coordinator +local net = require("./services/net.luau") +local hotspot = require("./services/hotspot.luau") +local dns = require("./services/dns.luau") +local bt = require("./services/bluetooth.luau") + +net.init(); hotspot.init(); dns.init(); bt.init() + +local isFirstSync = true +local lastNet, lastEth, lastPairKey = "", false, nil +local LAST_DNS = "/tmp/network-toolkit-last-dns.txt" + +local function publishAll() + local n, b = net.getStatus(), bt.getStatus() + + if n.wifiConnected and n.ssid ~= "" and n.ssid ~= lastNet then + lastNet = n.ssid + dns.enforce() + if not isFirstSync then + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.connected_to", { ssid = n.ssid })) + end + elseif n.ethernetConnected and not lastEth then + lastEth = true + dns.enforce() + if not isFirstSync then + noctalia.notify(noctalia.tr("title"), "Connected to Ethernet") + end + end + if not n.wifiConnected then lastNet = "" end + if not n.ethernetConnected then lastEth = false end + isFirstSync = false + + if b.pairingRequests and #b.pairingRequests > 0 then + local r = b.pairingRequests[1] + local key = (r.address or "") .. ":" .. (r.passkey or "") .. ":" .. (r.timestamp or 0) + if key ~= lastPairKey then + lastPairKey = key + local msg = (r.passkey and r.passkey ~= "") and string.format("%s requested to pair (Passkey: %s)", r.name or r.address, r.passkey) or string.format("%s requested to pair", r.name or r.address) + noctalia.notify(noctalia.tr("title"), msg) + end + else lastPairKey = nil end + + noctalia.state.set("network_state", { nonce = os.time(), net = n, hotspot = hotspot.getStatus(), dns = dns.getStatus(), bluetooth = b }) +end + +noctalia.state.watch("network_command", function(cmd) + if type(cmd) ~= "table" then return end + local d, a, p = cmd.domain, cmd.action, cmd.payload or {} + + if d == "hotspot" then + if a == "toggle" then hotspot.toggle(not hotspot.getStatus().active, function() publishAll(); net.refresh(publishAll) end) + elseif a == "refresh" then hotspot.refresh(publishAll) end + elseif d == "wifi" then + if a == "toggle" then net.toggleWifi(not net.getStatus().wifiEnabled, publishAll) + elseif a == "disconnect" then net.disconnect(publishAll) + elseif a == "auto_connect" then net.autoConnect(publishAll) + elseif a == "connect" then + net.connectWifi(p.ssid or "", p.password or "", function(_s, ok, msg, wasHs) + if not ok then + local l = (msg or ""):lower() + local isPwd = l:find("secret") or l:find("password") or l:find("802-11-wireless-security") + noctalia.notifyError(noctalia.tr("title"), noctalia.tr(isPwd and "notifications.incorrect_password" or "notifications.connect_failed", { ssid = p.ssid or "" })) + else + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.connected_to", { ssid = p.ssid or "" })) + end + if wasHs then hotspot.toggle(true, publishAll) end + publishAll() + end) + elseif a == "forget" then net.forgetWifi(p.ssid or "", publishAll) + elseif a == "scan" then net.scan(publishAll) end + elseif d == "dns" then + if a == "apply" then + local prov, srv = (type(p) == "table") and (p.provider or "custom") or (p or "default"), (type(p) == "table") and (p.servers or "") or "" + if prov ~= "default" then pcall(noctalia.writeFile, LAST_DNS, prov == "custom" and ("custom=" .. srv) or prov) end + publishAll(); dns.apply(prov, srv, publishAll) + elseif a == "toggle" then + if dns.getStatus().activeProvider == "default" then + local ok, last = pcall(noctalia.readFile, LAST_DNS) + last = (ok and type(last) == "string" and last ~= "") and last:match("^%s*(.-)%s*$") or "cloudflare" + if last:sub(1, 7) == "custom=" then dns.apply("custom", last:sub(8), publishAll) else dns.apply(last, "", publishAll) end + else dns.apply("default", "", publishAll) end + publishAll() + end + elseif d == "bluetooth" then + local addr = p.address or "" + if a == "toggle" then bt.toggle(not bt.getStatus().enabled, publishAll) + elseif a == "scan" then bt.scan(publishAll) + elseif a == "connect" then bt.connect(addr, publishAll) + elseif a == "disconnect" then bt.disconnect(addr, publishAll) + elseif a == "pair" then bt.pair(addr, publishAll) + elseif a == "accept_pair" then bt.acceptPair(addr, publishAll) + elseif a == "reject_pair" then bt.rejectPair(addr, publishAll) + elseif a == "toggle_autoreconnect" then bt.toggleAutoReconnect(addr, publishAll) + elseif a == "remove" then bt.remove(addr, publishAll) + elseif a == "discoverable" then bt.toggleDiscoverable(not bt.getStatus().discoverable, publishAll) + elseif a == "refresh" then bt.refresh(publishAll) end + elseif d == "system" and a == "refresh" then + net.scan(publishAll); hotspot.refresh(publishAll); dns.refresh(publishAll); bt.scan(publishAll) + end +end) + +function update() + net.refresh(publishAll); hotspot.refresh(publishAll); dns.refresh(publishAll); bt.refresh(publishAll) + local pSec = tonumber(noctalia.getConfig("refreshinterval")) or 5 + if pSec < 2 then pSec = 2 elseif pSec > 60 then pSec = 60 end + noctalia.setUpdateInterval((hotspot.getStatus().active and 1500 or (pSec * 1000))) +end + +function onConfigChanged() + hotspot.updateConfig(publishAll) + update() +end + +noctalia.setUpdateInterval(5000) +publishAll() +update() diff --git a/network-toolkit/services/bluetooth.luau b/network-toolkit/services/bluetooth.luau new file mode 100644 index 00000000..3c375d7b --- /dev/null +++ b/network-toolkit/services/bluetooth.luau @@ -0,0 +1,258 @@ +--!nonstrict +-- Bluetooth device management and BlueZ agent interface +local BT = {} +local btctlOk = noctalia.commandExists("bluetoothctl") +local refreshing = false +local pendingActions = {} +local btPowerLock, discLock = 0, 0 + +local state = { + available = btctlOk, enabled = false, discoverable = false, scanning = false, + pairedDevices = {}, availableDevices = {}, pairingRequests = {}, connectedAddrs = {}, + connectingAddrs = {}, alias = "", error = "" +} + +local function btTypeGlyph(name) + local l = (name or ""):lower() + if l:find("headset") or l:find("wh%-") or l:find("wf%-") or l:find("quietcomfort") or l:find("airpod") or l:find("buds") or l:find("freebuds") or l:find("earphone") or l:find("tune") then return "headphones" + elseif l:find("speaker") or l:find("soundlink") or l:find("flip") or l:find("charge") or l:find("boom") or l:find("go") or l:find("echo") then return "speaker" + elseif l:find("mouse") or l:find("trackpad") then return "mouse" + elseif l:find("keyboard") or l:find("keychron") or l:find("k%d%d%d") then return "keyboard" + elseif l:find("controller") or l:find("gamepad") or l:find("xbox") or l:find("dualsense") or l:find("dualshock") then return "device-gamepad" + elseif l:find("phone") or l:find("pixel") or l:find("iphone") or l:find("galaxy") or l:find("oneplus") or l:find("redmi") or l:find("xiaomi") or l:find("nothing") then return "device-mobile" + elseif l:find("tv") or l:find("display") or l:find("smart screen") then return "device-tv" + elseif l:find("watch") or l:find("band") or l:find("fitbit") or l:find("garmin") then return "device-watch" + end + return "bluetooth" +end + +local function ensureAgent() + local bin = noctalia.pluginDir() .. "/scripts/bluetooth_helper" + noctalia.runAsync(string.format("pgrep -f bluetooth_helper >/dev/null 2>&1 || (setsid '%s' >/dev/null 2>&1 &)", bin), function() end, 2000) +end + +function BT.init() + state.available = btctlOk + if not state.available then state.error = noctalia.tr("errors.bluetoothctl_not_found") end + ensureAgent() + return state.available +end + +function BT.getStatus() return state end + +local BATCH = [[ +echo "=SHOW="; bluetoothctl show 2>/dev/null +echo "=PAIRED="; bluetoothctl devices Paired 2>/dev/null +echo "=ALL="; bluetoothctl devices 2>/dev/null +echo "=INFOS="; bluetoothctl devices Paired 2>/dev/null | awk '{print $2}' | while read -r mac; do + echo "---DEV $mac---"; bluetoothctl info "$mac" 2>/dev/null +done +echo "=AGENT="; cat /tmp/noctalia_bt_pairing.json 2>/dev/null || true +]] + +function BT.refresh(callback) + if not state.available or refreshing then if callback then callback(state) end; return end + refreshing = true + ensureAgent() + + noctalia.runAsync(BATCH, function(res) + refreshing = false + local out = res.stdout or "" + local showSec = out:match("=SHOW=(.-)=PAIRED=") or "" + local pairSec = out:match("=PAIRED=(.-)=ALL=") or "" + local allSec = out:match("=ALL=(.-)=INFOS=") or "" + local infoSec = out:match("=INFOS=(.-)=AGENT=") or "" + local agentSec = out:match("=AGENT=(.*)") or "" + + if os.time() >= btPowerLock then state.enabled = (showSec:find("Powered:%s*yes") ~= nil) end + if os.time() >= discLock then state.discoverable = (showSec:find("Discoverable:%s*yes") ~= nil) end + local alias = showSec:match("Alias:%s*(.-)\n") or showSec:match("Name:%s*(.-)\n") + state.alias = alias and alias:match("^%s*(.-)%s*$") or "" + + if not state.enabled then + state.pairedDevices, state.availableDevices, state.pairingRequests, state.connectedAddrs = {}, {}, {}, {} + if callback then callback(state) end + return + end + + local devConn, devTrust, devName = {}, {}, {} + local curDev = nil + for line in infoSec:gmatch("[^\r\n]+") do + local h = line:match("%-%-%-DEV%s+(%S+)%-%-%-") + if h then curDev = h + elseif curDev then + if line:find("Connected:%s*yes") then devConn[curDev] = true end + if line:find("Trusted:%s*yes") then devTrust[curDev] = true end + local al = line:match("Alias:%s*(.+)") or line:match("Name:%s*(.+)") + if al and not devName[curDev] then devName[curDev] = al:match("^%s*(.-)%s*$") end + end + end + state.connectedAddrs = devConn + + local nameMap, paired, pairedMap = {}, {}, {} + for line in allSec:gmatch("[^\r\n]+") do + local a, n = line:match("^Device%s+(%S+)%s+(.+)$") + if a and n then nameMap[a] = n end + end + + local now = os.time() + for line in pairSec:gmatch("[^\r\n]+") do + local a, n = line:match("^Device%s+(%S+)%s+(.+)$") + if a and n then + pairedMap[a] = true + local dName = devName[a] or n + local la = a:lower() + local isC = (devConn[a] == true) + local isPending = pendingActions[la] + if isPending then + if now < isPending.expires then + if isC == isPending.connected then pendingActions[la] = nil + else isC = isPending.connected end + else pendingActions[la] = nil end + end + table.insert(paired, { + address = a, name = dName, connected = isC, + connecting = (state.connectingAddrs[la] == true), + trusted = (devTrust[a] == true), autoReconnect = (devTrust[a] == true), + icon = btTypeGlyph(dName) + }) + end + end + state.pairedDevices = paired + + -- grabs pairing requests from native agent + local reqs = {} + if agentSec and agentSec ~= "" then + local a = agentSec:match('"address":%s*"([^"]+)"') + local n = agentSec:match('"name":%s*"([^"]+)"') or a + local pk = agentSec:match('"passkey":%s*"([^"]*)"') or "" + local ts = tonumber(agentSec:match('"timestamp":%s*(%d+)')) or now + if a and (now - ts) <= 20 then + table.insert(reqs, { address = a, name = n, passkey = pk, timestamp = ts, icon = btTypeGlyph(n) }) + end + end + state.pairingRequests = reqs + + -- Available + local avail = {} + for a, n in pairs(nameMap) do + if not pairedMap[a] and n ~= "" and a:find(":") then + table.insert(avail, { address = a, name = n, icon = btTypeGlyph(n), connected = (devConn[a] == true) }) + end + end + state.availableDevices = avail + + if callback then callback(state) end + end, 6000) +end + +function BT.scan(callback) + if not state.available or not state.enabled then BT.refresh(callback); return end + state.scanning = true + if callback then callback(state) end + noctalia.runAsync("timeout 4 bluetoothctl scan on 2>/dev/null || true", function() + state.scanning = false + BT.refresh(callback) + end, 6000) +end + +function BT.toggle(enable, callback) + btPowerLock = os.time() + 3 + state.enabled = enable + if not enable then state.pairedDevices, state.availableDevices, state.pairingRequests, state.connectedAddrs = {}, {}, {}, {} end + if callback then callback(state) end + local cmd = enable and "rfkill unblock bluetooth 2>/dev/null; bluetoothctl power on 2>/dev/null" or "bluetoothctl power off 2>/dev/null" + noctalia.runAsync(cmd, function() + BT.refresh(function(s) if enable and callback then BT.scan(callback) elseif callback then callback(s) end end) + end, 3000) +end + +function BT.connect(addr, callback) + local la = (addr or ""):lower() + state.connectingAddrs[la] = true + if state.pairedDevices then + for _, d in ipairs(state.pairedDevices) do if (d.address or ""):lower() == la then d.connecting = true; break end end + end + if callback then callback(state) end + + local cmd = string.format("timeout 4 bluetoothctl connect %s 2>&1 || (bluetoothctl disconnect %s 2>/dev/null; true)", addr, addr) + noctalia.runAsync(cmd, function() + state.connectingAddrs[la] = nil + pendingActions[la] = nil + BT.refresh(function(s) + local isConn = s.connectedAddrs and (s.connectedAddrs[addr] or s.connectedAddrs[la]) + if not isConn then + noctalia.runAsync(string.format("bluetoothctl disconnect %s 2>/dev/null", addr), function() end, 1000) + if noctalia.notifyError then pcall(noctalia.notifyError, noctalia.tr("title"), "Connection failed") + else pcall(noctalia.notify, noctalia.tr("title"), "Connection failed") end + end + if callback then callback(s) end + end) + end, 5000) +end + +function BT.disconnect(addr, callback) + local la = (addr or ""):lower() + pendingActions[la] = { connected = false, expires = os.time() + 4 } + state.connectedAddrs[addr], state.connectedAddrs[la] = nil, nil + if state.pairedDevices then + for _, d in ipairs(state.pairedDevices) do if (d.address or ""):lower() == la then d.connected = false; break end end + end + if callback then callback(state) end + noctalia.runAsync("bluetoothctl disconnect " .. addr .. " 2>/dev/null", function() BT.refresh(callback) end, 3000) +end + +function BT.pair(addr, callback) + noctalia.runAsync(string.format("bluetoothctl pair %s 2>/dev/null; bluetoothctl connect %s 2>/dev/null", addr, addr), function() BT.refresh(callback) end, 12000) +end + +function BT.toggleAutoReconnect(addr, callback) + local isTr = false + if state.pairedDevices then + for _, d in ipairs(state.pairedDevices) do + if d.address == addr then d.trusted = not d.trusted; d.autoReconnect = d.trusted; isTr = d.trusted; break end + end + end + if callback then callback(state) end + noctalia.runAsync(string.format("bluetoothctl %s %s 2>/dev/null", isTr and "trust" or "untrust", addr), function() BT.refresh(callback) end, 3000) +end + +function BT.acceptPair(addr, callback) + state.pairingRequests = {} + if callback then callback(state) end + pcall(noctalia.writeFile, "/tmp/noctalia_bt_pairing_action", "accept") + local cmd = string.format("echo -n 'accept' > /tmp/noctalia_bt_pairing_action; rm -f /tmp/noctalia_bt_pairing.json; sleep 0.2; bluetoothctl connect %s 2>/dev/null", addr) + noctalia.runAsync(cmd, function() BT.refresh(callback) end, 10000) +end + +function BT.rejectPair(addr, callback) + state.pairingRequests = {} + if callback then callback(state) end + pcall(noctalia.writeFile, "/tmp/noctalia_bt_pairing_action", "reject") + local cmd = string.format("echo -n 'reject' > /tmp/noctalia_bt_pairing_action; rm -f /tmp/noctalia_bt_pairing.json; bluetoothctl cancel-pairing %s 2>/dev/null || true", addr) + noctalia.runAsync(cmd, function() BT.refresh(callback) end, 2000) +end + +function BT.remove(addr, callback) + local la = (addr or ""):lower() + pendingActions[la] = nil + state.connectedAddrs[addr], state.connectedAddrs[la] = nil, nil + if state.pairedDevices then + local p = {} + for _, d in ipairs(state.pairedDevices) do if (d.address or ""):lower() ~= la then table.insert(p, d) end end + state.pairedDevices = p + end + state.pairingRequests = {} + if callback then callback(state) end + noctalia.runAsync(string.format("rm -f /tmp/noctalia_bt_pairing.json; bluetoothctl disconnect %s 2>/dev/null || true; bluetoothctl untrust %s 2>/dev/null || true; bluetoothctl remove %s 2>/dev/null", addr, addr, addr), function() BT.refresh(callback) end, 3000) +end + +function BT.setDiscoverable(enable, callback) + discLock = os.time() + 3 + state.discoverable = enable + if callback then callback(state) end + noctalia.runAsync("bluetoothctl discoverable " .. (enable and "on" or "off") .. " 2>/dev/null", function() BT.refresh(callback) end, 3000) +end +BT.toggleDiscoverable = BT.setDiscoverable + +return BT diff --git a/network-toolkit/services/dns.luau b/network-toolkit/services/dns.luau new file mode 100644 index 00000000..d7fa398e --- /dev/null +++ b/network-toolkit/services/dns.luau @@ -0,0 +1,133 @@ +--!nonstrict +-- Persistent DNS configuration service +local DNS = {} +local nmcliOk = noctalia.commandExists("nmcli") +local FILE = "/tmp/noctalia_device_dns.json" + +local BUILTIN = { + { id = "default", label = "System Default (ISP)", ip = "", glyph = "shield" }, + { id = "cloudflare", label = "Cloudflare", ip = "1.1.1.1 1.0.0.1", glyph = "cloud" }, + { id = "google", label = "Google", ip = "8.8.8.8 8.8.4.4", glyph = "brand-google" }, + { id = "quad9", label = "Quad9", ip = "9.9.9.9 149.112.112.112", glyph = "lock" }, + { id = "adguard", label = "AdGuard", ip = "94.140.14.14 94.140.15.15", glyph = "shield-check" }, +} + +local current = BUILTIN[1] +local changing, refreshing = false, false + +local function validIp(ip) + local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + return a and #a <= 3 and #b <= 3 and #c <= 3 and #d <= 3 and tonumber(a) <= 255 and tonumber(b) <= 255 and tonumber(c) <= 255 and tonumber(d) <= 255 +end + +local function validSpec(spec) + local n = 0 + for tok in spec:gmatch("%S+") do + n += 1 + if n > 2 or not validIp(tok) then return false end + end + return n > 0 +end + +function DNS.getPresets() + local list = {} + for _, p in ipairs(BUILTIN) do table.insert(list, p) end + for i = 1, 3 do + local val = noctalia.getConfig("custom_dns_" .. i) + if type(val) == "string" and val ~= "" then + local name, ip = val:match("^([^=]+)=(.*)$") + name = name and name:match("^%s*(.-)%s*$") or val:match("^%s*(.-)%s*$") + ip = ip and ip:match("^%s*(.-)%s*$") or name + if validSpec(ip) then table.insert(list, { id = "custom_" .. i, label = name, ip = ip, glyph = "server" }) end + end + end + return list +end + +local function findEntry(id, customIp) + if id == "custom" and type(customIp) == "string" and customIp ~= "" then + local ip = customIp:match("^%s*(.-)%s*$") + return { id = "custom", label = "Custom (" .. ip .. ")", ip = ip, glyph = "server" } + end + for _, p in ipairs(DNS.getPresets()) do if p.id == id then return p end end + return BUILTIN[1] +end + +local function readSaved() + local ok, c = pcall(noctalia.readFile, FILE) + if ok and type(c) == "string" and c ~= "" then + local p, s, l = c:match('"provider"%s*:%s*"([^"]+)"'), c:match('"servers"%s*:%s*"([^"]*)"'), c:match('"label"%s*:%s*"([^"]*)"') + if p then return { provider = p, servers = s or "", label = l } end + end + return nil +end + +local function writeSaved(p, s, l) + pcall(noctalia.writeFile, FILE, string.format('{"provider":"%s","servers":"%s","label":"%s"}', p, s, l or "")) +end + +local function applyInternal(entry, callback) + local isDef = (entry.id == "default" or entry.ip == "") + local safeIp = isDef and "" or entry.ip:gsub("[^%d%. ]", "") + local script = string.format([[ +nmcli -t -f UUID,TYPE,DEVICE connection show --active 2>/dev/null | while IFS=: read -r u t d; do + [ -z "$u" ] || [ "$d" = "lo" ] && continue + case "$d" in *-ap|p2p-dev-*) continue ;; esac + [ "$(nmcli -g ipv4.method connection show uuid "$u" 2>/dev/null)" = "shared" ] && continue + if [ "%s" = "1" ]; then + nmcli connection modify "$u" ipv4.dns "" ipv4.ignore-auto-dns no 2>/dev/null || true + else + nmcli connection modify "$u" ipv4.dns "%s" ipv4.ignore-auto-dns yes 2>/dev/null || true + fi + nmcli device reapply "$d" 2>/dev/null || true +done +]], isDef and "1" or "0", safeIp) + + noctalia.runAsync(script, function(res) if callback then callback(res.exitCode == 0) end end, 6000) +end + +function DNS.getStatus() + return { available = nmcliOk, activeProvider = current.id, current = current, currentServers = current.ip, changing = changing, presets = DNS.getPresets() } +end + +function DNS.init() + if not nmcliOk then return false end + local s = readSaved() + if s and s.provider and s.provider ~= "default" then + current = findEntry(s.provider, s.servers) + applyInternal(current, nil) + else + current = BUILTIN[1] + end + return true +end + +function DNS.refresh(callback) + if not nmcliOk or refreshing then if callback then callback(DNS.getStatus()) end; return end + refreshing = true + local s = readSaved() + current = (s and s.provider and s.provider ~= "default") and findEntry(s.provider, s.servers) or BUILTIN[1] + noctalia.runAsync("nmcli -t -f TYPE,DEVICE,UUID connection show --active 2>/dev/null", function() + refreshing = false + if callback then callback(DNS.getStatus()) end + end, 4000) +end + +function DNS.apply(providerId, customServers, callback) + local target = findEntry(providerId, customServers) + current, changing = target, true + writeSaved(target.id, target.ip, target.label) + applyInternal(target, function(ok) + changing = false + if ok then noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.dns_switched", { provider = target.label })) + else noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notifications.dns_switch_failed")) end + DNS.refresh(callback) + end) +end + +function DNS.enforce(callback) + if current and current.id ~= "default" then applyInternal(current, callback) + elseif callback then callback(true) end +end + +return DNS diff --git a/network-toolkit/services/hotspot.luau b/network-toolkit/services/hotspot.luau new file mode 100644 index 00000000..593a4102 --- /dev/null +++ b/network-toolkit/services/hotspot.luau @@ -0,0 +1,180 @@ +--!nonstrict +-- Hotspot management with concurrent Wi-Fi station and AP support +local Hotspot = {} +local nmcliOk, ipOk, iwOk = noctalia.commandExists("nmcli"), noctalia.commandExists("ip"), noctalia.commandExists("iw") +local refreshing = false +local hsPowerLock = 0 +local SCRIPT = noctalia.pluginDir() .. "/scripts/hotspot_helper.sh" + +local state = { + active = false, busy = false, available = nmcliOk and ipOk, + ssid = "Noctalia-Hotspot", connName = "Noctalia-Hotspot", + interface = "wlan0-ap", baseInterface = "wlan0", + clients = {}, clientCount = 0, maxConnections = 8, + concurrentSupported = false, maxChannels = 1, channel = 6, band = "bg", error = "" +} + +local function getCfg() + local s = noctalia.getConfig("hotspot_ssid") or "Noctalia-Hotspot" + local p = noctalia.getConfig("hotspot_password") or "noctaliapass" + return (s == "" and "Noctalia-Hotspot" or s), (p == "" or #p < 8) and "noctaliapass" or p +end + +function Hotspot.init() + state.available = nmcliOk and ipOk + if not state.available then state.error = noctalia.tr("errors.missing_command") end + state.ssid = getCfg() + state.maxConnections = tonumber(noctalia.getConfig("hotspot_max_connections")) or 8 + return state.available +end + +function Hotspot.getStatus() return state end + +local BATCH_CMD = [[ +echo "=A="; nmcli -t -f TYPE,STATE,DEVICE,NAME connection show --active 2>/dev/null +echo "=D="; nmcli -t -f DEVICE,TYPE,STATE device 2>/dev/null +echo "=I="; iw dev 2>/dev/null +echo "=N="; ip neighbor show 2>/dev/null +echo "=S="; iw dev 2>/dev/null | awk '/Interface.*-ap/{print $2}' | while read -r i; do iw dev "$i" station dump 2>/dev/null; done +echo "=L="; cat /var/lib/misc/dnsmasq.leases /var/lib/NetworkManager/dnsmasq*.leases 2>/dev/null || true +]] + +function Hotspot.refresh(callback) + if not state.available or refreshing then if callback then callback(state) end; return end + refreshing = true + local cfgSsid = getCfg() + state.maxConnections = tonumber(noctalia.getConfig("hotspot_max_connections")) or 8 + + noctalia.runAsync(BATCH_CMD, function(res) + refreshing = false + local out = res.stdout or "" + local actSec = out:match("=A=(.-)=D=") or "" + local devSec = out:match("=D=(.-)=I=") or "" + local iwSec = out:match("=I=(.-)=N=") or "" + local nbrSec = out:match("=N=(.-)=S=") or "" + local staSec = out:match("=S=(.-)=L=") or "" + local lseSec = out:match("=L=(.*)") or "" + + local isActive, apDev, connName = false, "", "" + for line in actSec:gmatch("[^\r\n]+") do + local _, devState, d, name = line:match("^([^:]+):([^:]+):([^:]+):(.*)$") + if name and devState == "activated" and (name:lower():find("hotspot") or name == cfgSsid or name == "Noctalia-Hotspot") then + isActive, apDev, connName = true, d, name + break + end + end + + local baseDev = "" + for line in devSec:gmatch("[^\r\n]+") do + local d, typ, s = line:match("^([^:]+):([^:]+):([^:]+)") + if typ == "wifi" and d ~= apDev and not d:find("%-ap$") and not d:find("^p2p%-") then + if baseDev == "" or s == "connected" then baseDev = d end + end + end + if baseDev == "" then + for line in iwSec:gmatch("[^\r\n]+") do + local ifn = line:match("Interface%s+([%w%-_]+)") + if ifn and not ifn:find("%-ap$") and not ifn:find("^p2p%-") then baseDev = ifn; break end + end + end + state.baseInterface = baseDev ~= "" and baseDev or "wlan0" + + if apDev == "" then + for line in iwSec:gmatch("[^\r\n]+") do + local ifn = line:match("Interface%s+([%w%-_]+)") + if ifn and ifn:find("%-ap$") then apDev = ifn; break end + end + end + state.interface = apDev ~= "" and apDev or (state.baseInterface .. "-ap") + if os.time() >= hsPowerLock then state.active = isActive end + state.connName = connName ~= "" and connName or "Noctalia-Hotspot" + + -- find connected devices & ips + local clients = {} + for line in staSec:gmatch("[^\r\n]+") do + local mac = line:match("Station%s+([%x:]+)%s+%(on") + if mac then clients[mac:lower()] = { mac = mac:lower(), ip = "", name = "" } end + end + for line in lseSec:gmatch("[^\r\n]+") do + local _, mac, ip, name = line:match("^(%d+)%s+([%x:]+)%s+([%d%.]+)%s+(%S+)") + if mac and ip and clients[mac:lower()] then + clients[mac:lower()].ip = ip + if name and name ~= "*" then clients[mac:lower()].name = name end + end + end + for line in nbrSec:gmatch("[^\r\n]+") do + local ip, dev, mac = line:match("^([%d%.]+)%s+dev%s+([%w%-_]+)%s+lladdr%s+([%x:]+)") + if ip and dev and mac and dev:find("%-ap$") and clients[mac:lower()] and clients[mac:lower()].ip == "" then + clients[mac:lower()].ip = ip + end + end + + local cList = {} + for _, c in pairs(clients) do table.insert(cList, c) end + table.sort(cList, function(a, b) return a.mac < b.mac end) + state.clients, state.clientCount = cList, #cList + + if callback then callback(state) end + end, 4000) +end + +function Hotspot.detectCapabilities(callback) + noctalia.runAsync("iw phy 2>/dev/null; echo '=D='; nmcli -t -f DEVICE,TYPE device 2>/dev/null", function(res) + local out = res.stdout or "" + local phySec = out:match("(.-)=D=") or out + local ap = phySec:find("AP") ~= nil + local sta = phySec:find("managed") ~= nil + state.concurrentSupported = ap and sta + if callback then callback({ baseIface = state.baseInterface, concurrent = state.concurrentSupported, maxChannels = 1 }) end + end, 3000) +end + +function Hotspot.toggle(enable, callback) + if not state.available or state.busy then if callback then callback(state) end; return end + local ssid, pass = getCfg() + local sSsid, sPass = ssid:gsub("'", "'\\''"), pass:gsub("'", "'\\''") + hsPowerLock = os.time() + 4 + state.active, state.busy = enable, true + if callback then callback(state) end + + if not enable then + local target = state.interface ~= "" and state.interface or (state.baseInterface .. "-ap") + local downCmd = string.format("nmcli connection down 'Noctalia-Hotspot' 2>/dev/null || nmcli connection down '%s' 2>/dev/null || nmcli device disconnect '%s' 2>/dev/null || true", sSsid, target) + noctalia.runAsync(downCmd, function() + state.busy, state.active = false, false + Hotspot.refresh(callback) + end, 4000) + return + end + + local upCmd = string.format([[ +BASE='%s'; AP='%s' +[ ! -d "/sys/class/net/${AP}" ] && bash '%s' "$BASE" "$AP" >/dev/null 2>&1 || true +CHAN=$(iw dev "$BASE" info 2>/dev/null | awk '/channel/ {print $2; exit}') +[ -z "$CHAN" ] && CHAN=6 +[ "$CHAN" -gt 14 ] && BAND="a" || BAND="bg" + +UUID=$(nmcli -t -f UUID,NAME connection show 2>/dev/null | awk -F: '$2=="Noctalia-Hotspot" || $2=="'%s'" {print $1; exit}') +if [ -n "$UUID" ]; then + nmcli connection modify "$UUID" connection.id "Noctalia-Hotspot" connection.interface-name "$AP" 802-11-wireless.ssid '%s' 802-11-wireless.mode ap 802-11-wireless.band "$BAND" 802-11-wireless.channel "$CHAN" 802-11-wireless-security.key-mgmt wpa-psk 802-11-wireless-security.psk '%s' ipv4.method shared ipv6.method disabled connection.autoconnect no 2>/dev/null || true +else + nmcli connection add type wifi ifname "$AP" con-name 'Noctalia-Hotspot' ssid '%s' 802-11-wireless.mode ap 802-11-wireless.band "$BAND" 802-11-wireless.channel "$CHAN" wifi-sec.key-mgmt wpa-psk wifi-sec.psk '%s' ipv4.method shared ipv6.method disabled connection.autoconnect no 2>/dev/null || true +fi +nmcli connection up 'Noctalia-Hotspot' 2>/dev/null || (sleep 0.5 && nmcli connection up 'Noctalia-Hotspot' 2>/dev/null) +]], state.baseInterface, state.interface, SCRIPT, sSsid, sSsid, sPass, sSsid, sPass) + + noctalia.runAsync(upCmd, function() + state.busy = false + Hotspot.refresh(callback) + end, 8000) +end + +function Hotspot.updateConfig(callback) + local ssid, pass = getCfg() + state.ssid = ssid + local sSsid, sPass = ssid:gsub("'", "'\\''"), pass:gsub("'", "'\\''") + local cmd = string.format("nmcli connection modify 'Noctalia-Hotspot' 802-11-wireless.ssid '%s' 802-11-wireless-security.psk '%s' 2>/dev/null || true; [ '%s' = '1' ] && nmcli connection up 'Noctalia-Hotspot' 2>/dev/null || true", sSsid, sPass, state.active and "1" or "0") + noctalia.runAsync(cmd, function() Hotspot.refresh(callback) end, 4000) +end + +return Hotspot \ No newline at end of file diff --git a/network-toolkit/services/net.luau b/network-toolkit/services/net.luau new file mode 100644 index 00000000..0a70d0e6 --- /dev/null +++ b/network-toolkit/services/net.luau @@ -0,0 +1,201 @@ +--!nonstrict +-- Network status, Wi-Fi scanning, and connection management +local Net = {} +local nmcliOk = noctalia.commandExists("nmcli") +local CACHE_FILE = "/tmp/noctalia_net_cache.json" +local refreshing = false +local wifiPowerLock = 0 +local connectingSsid = nil +local lastConnectedSsid = "" + +local state = { + available = nmcliOk, wifiEnabled = true, wifiConnected = false, ethernetConnected = false, + hotspotActive = false, ssid = "", signal = 0, band = "—", ipAddress = "", interface = "", + speedFormatted = "0 B/s ↓ 0 B/s ↑", availableNetworks = {}, refreshing = false, error = "" +} + +local function saveCache() + pcall(noctalia.writeFile, CACHE_FILE, string.format('{"ssid":"%s","interface":"%s","ipAddress":"%s","band":"%s","wifiConnected":%s,"ethernetConnected":%s,"signal":%d}', + state.ssid:gsub('"', '\\"'), state.interface:gsub('"', '\\"'), state.ipAddress:gsub('"', '\\"'), + state.band:gsub('"', '\\"'), tostring(state.wifiConnected), tostring(state.ethernetConnected), state.signal)) +end + +local function loadCache() + local ok, c = pcall(noctalia.readFile, CACHE_FILE) + if ok and type(c) == "string" and c ~= "" then + state.ssid = c:match('"ssid"%s*:%s*"([^"]*)"') or state.ssid + state.interface = c:match('"interface"%s*:%s*"([^"]*)"') or state.interface + state.ipAddress = c:match('"ipAddress"%s*:%s*"([^"]*)"') or state.ipAddress + state.band = c:match('"band"%s*:%s*"([^"]*)"') or state.band + state.wifiConnected = (c:match('"wifiConnected"%s*:%s*true') ~= nil) + state.ethernetConnected = (c:match('"ethernetConnected"%s*:%s*true') ~= nil) + state.signal = tonumber(c:match('"signal"%s*:%s*(%d+)')) or state.signal + if state.wifiConnected and state.ssid ~= "" then lastConnectedSsid = state.ssid end + end +end + +function Net.init() + state.available = nmcliOk + if not state.available then state.error = noctalia.tr("errors.nmcli_not_found") end + loadCache() + return state.available +end + +function Net.getStatus() return state end + +function Net.signalGlyph(val) + local s = tonumber(val) or 0 + return (s >= 88 and "wifi") or (s >= 63 and "wifi-3") or (s >= 38 and "wifi-2") or (s >= 13 and "wifi-1") or "wifi-0" +end + +local BATCH = [[ +echo "=D="; nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device 2>/dev/null +echo "=A="; nmcli -t -f TYPE,STATE,DEVICE,NAME connection show --active 2>/dev/null +echo "=S="; nmcli -t -f NAME,TYPE connection show 2>/dev/null +echo "=I="; ip -4 addr show 2>/dev/null +echo "=W="; nmcli -t -f IN-USE,SSID,SIGNAL,SECURITY,FREQ device wifi list 2>/dev/null +]] + +function Net.refresh(callback) + if not state.available or refreshing then if callback then callback(state) end; return end + refreshing, state.refreshing = true, true + + noctalia.runAsync(BATCH, function(res) + refreshing, state.refreshing = false, false + local out = res.stdout or "" + local devSec = out:match("=D=(.-)=A=") or "" + local actSec = out:match("=A=(.-)=S=") or "" + local savSec = out:match("=S=(.-)=I=") or "" + local ipSec = out:match("=I=(.-)=W=") or "" + local wifSec = out:match("=W=(.*)") or "" + + local wifiConn, ethConn, hsAct = false, false, false + local connSsid, wifiIf, ethIf = "", "", "" + local hsSsid = noctalia.getConfig("hotspot_ssid") or "Noctalia-Hotspot" + + for line in actSec:gmatch("[^\r\n]+") do + local typ, st, dev, name = line:match("^([^:]+):([^:]+):([^:]+):(.*)$") + if name and dev then + if dev:find("%-ap$") or name == "Noctalia-Hotspot" or name == hsSsid then hsAct = true + elseif (st == "activated" or st == "connected") and (typ == "802-11-wireless" or typ == "wifi") then wifiConn, connSsid, wifiIf = true, name, dev + elseif (st == "activated" or st == "connected") and (typ == "802-3-ethernet" or typ == "ethernet") then ethConn, ethIf = true, dev end + end + end + + local wEnabled = true + for line in devSec:gmatch("[^\r\n]+") do + local d, typ, s, c = line:match("^([^:]+):([^:]+):([^:]+):?(.*)$") + if typ == "wifi" and not d:find("%-ap$") and not d:find("^p2p%-") then + if s == "unavailable" or s == "unmanaged" then wEnabled = false end + if s == "connected" and c and c ~= "" and not c:find("Hotspot") then wifiConn, connSsid, wifiIf = true, c, d end + elseif typ == "ethernet" and s == "connected" then ethConn, ethIf = true, d end + end + + local curIp = "" + local activeIf = ethConn and ethIf or (wifiConn and wifiIf or "") + if activeIf ~= "" then + local blk = ipSec:match("%d+:%s+" .. activeIf .. ":(.-)%d+:%s+") or ipSec:match("%d+:%s+" .. activeIf .. ":(.*)") + if blk then curIp = blk:match("inet%s+([%d%.]+)/") or "" end + end + if curIp == "" then curIp = ipSec:match("inet%s+([%d%.]+)/%d+%s+brd[%s%d%.]+scope global") or "" end + + local savedMap = {} + for line in savSec:gmatch("[^\r\n]+") do + local name, typ = line:match("^([^:]+):([^:]+)$") + if name and (typ == "802-11-wireless" or typ == "wifi") then + if name ~= connectingSsid then savedMap[name] = true end + end + end + + local networks, seen = {}, {} + for line in wifSec:gmatch("[^\r\n]+") do + local inUse, ssid, sig, sec, freq = line:match("^([^:]*):([^:]*):([^:]*):([^:]*):(.*)$") + if ssid and ssid ~= "" and not seen[ssid] and not ssid:find("Noctalia%-Hotspot") and ssid ~= hsSsid then + seen[ssid] = true + local isAct = (wifiConn and connSsid ~= "" and ssid == connSsid) + local sigN = tonumber(sig) or 0 + if isAct then + local fNum = tonumber((freq or ""):match("(%d+)")) or 0 + state.band = (fNum >= 4900) and "5 GHz" or "2.4 GHz" + if sigN > 0 then state.signal = sigN end + end + table.insert(networks, { ssid = ssid, signal = sigN, security = (sec ~= "--" and sec ~= "") and sec or "Open", active = isAct, saved = (savedMap[ssid] == true) }) + end + end + + if wifiConn and connSsid ~= "" and not seen[connSsid] then + table.insert(networks, 1, { ssid = connSsid, signal = state.signal > 0 and state.signal or 60, security = "WPA2", active = true, saved = true }) + end + + -- lock stops toggle double-clicking while nmcli is working + if os.time() >= wifiPowerLock then state.wifiEnabled = wEnabled end + state.wifiConnected, state.ethernetConnected = wifiConn, ethConn + state.hotspotActive, state.ssid, state.interface, state.ipAddress = hsAct, (wifiConn and connSsid or ""), activeIf, curIp + if wifiConn and connSsid ~= "" then lastConnectedSsid = connSsid end + if #networks > 0 then state.availableNetworks = networks end + saveCache() + if callback then callback(state) end + end, 5000) +end + +function Net.scan(callback) Net.refresh(callback) end + +function Net.toggleWifi(enable, callback) + wifiPowerLock = os.time() + 3 + state.wifiEnabled = enable + if not enable then state.wifiConnected, state.ssid, state.ipAddress, state.signal, state.availableNetworks = false, "", "", 0, {} end + saveCache() + if callback then callback(state) end + noctalia.runAsync("nmcli radio wifi " .. (enable and "on" or "off"), function() Net.refresh(callback) end, 3000) +end + +function Net.connectWifi(ssid, password, callback) + local safe = ssid:gsub("'", "'\\''") + local hasPwd = (type(password) == "string" and password ~= "") + local prevSsid = (state.wifiConnected and state.ssid ~= "" and state.ssid ~= ssid) and state.ssid + or (lastConnectedSsid ~= "" and lastConnectedSsid ~= ssid and lastConnectedSsid or nil) + connectingSsid = hasPwd and ssid or nil + + local wifiCmd = hasPwd + and string.format("nmcli --wait 8 device wifi connect '%s' password '%s'", safe, password:gsub("'", "'\\''")) + or string.format("nmcli --wait 8 connection up id '%s' 2>/dev/null || nmcli --wait 8 device wifi connect '%s'", safe, safe) + local wasHs = state.hotspotActive + local fullCmd = wasHs and ("nmcli connection down 'Noctalia-Hotspot' 2>/dev/null || true; sleep 0.2; " .. wifiCmd) or wifiCmd + + noctalia.runAsync(fullCmd, function(res) + connectingSsid = nil + local ok = (res.exitCode == 0) + if not ok then + local restoreCmd = hasPwd and string.format("nmcli connection delete id '%s' 2>/dev/null || nmcli connection delete '%s' 2>/dev/null; ", safe, safe) or "" + if prevSsid then + local pSafe = prevSsid:gsub("'", "'\\''") + restoreCmd = restoreCmd .. string.format("nmcli connection up id '%s' 2>/dev/null || nmcli device wifi connect '%s' 2>/dev/null || nmcli device autoconnect 2>/dev/null || true", pSafe, pSafe) + else + restoreCmd = restoreCmd .. "nmcli device autoconnect 2>/dev/null || true" + end + noctalia.runAsync(restoreCmd, function() + Net.refresh(function(s) if callback then callback(s, false, res.stdout or res.stderr or "", wasHs) end end) + end, 4000) + else + Net.refresh(function(s) if callback then callback(s, true, res.stdout or res.stderr or "", wasHs) end end) + end + end, 10000) +end + +function Net.disconnect(callback) + local iface = state.interface ~= "" and state.interface or "wlan0" + state.wifiConnected, state.ethernetConnected, state.ssid, state.ipAddress = false, false, "", "" + saveCache() + if callback then callback(state) end + noctalia.runAsync(string.format("nmcli device disconnect '%s' 2>/dev/null", iface), function() Net.refresh(callback) end, 4000) +end + +function Net.autoConnect(callback) + noctalia.runAsync("nmcli networking on 2>/dev/null || nmcli device autoconnect 2>/dev/null", function() Net.refresh(callback) end, 5000) +end + +function Net.forgetWifi(ssid, callback) + noctalia.runAsync(string.format("nmcli connection delete id '%s' 2>/dev/null", ssid:gsub("'", "'\\''")), function() Net.refresh(callback) end, 4000) +end + +return Net \ No newline at end of file diff --git a/network-toolkit/shortcut.luau b/network-toolkit/shortcut.luau new file mode 100644 index 00000000..532c6792 --- /dev/null +++ b/network-toolkit/shortcut.luau @@ -0,0 +1,19 @@ +--!nonstrict +-- Network Toolkit — Quick settings hotspot shortcut +local state = noctalia.state.get("network_state") or {} +local cmdSeq = 0 + +local function render() + local hs = state.hotspot or {} + local active = (hs.active == true) + shortcut.setLabel(active and ((hs.ssid and hs.ssid ~= "") and hs.ssid or noctalia.tr("status.hotspot_active")) or noctalia.tr("panel.hotspot")) + shortcut.setIcon(active and "broadcast" or "broadcast-off", "broadcast") + shortcut.setActive(active) +end + +noctalia.state.watch("network_state", function(s) if type(s) == "table" then state = s; render() end end) +function onClick() + cmdSeq += 1 + noctalia.state.set("network_command", { domain = "hotspot", action = "toggle", nonce = os.time() .. "_" .. cmdSeq }) +end +render() diff --git a/network-toolkit/thumbnail.webp b/network-toolkit/thumbnail.webp new file mode 100644 index 00000000..5e966ab7 Binary files /dev/null and b/network-toolkit/thumbnail.webp differ diff --git a/network-toolkit/translations/en.json b/network-toolkit/translations/en.json new file mode 100644 index 00000000..a4474fad --- /dev/null +++ b/network-toolkit/translations/en.json @@ -0,0 +1,154 @@ +{ + "title": "Network Toolkit", + "status": { + "disconnected": "Disconnected", + "ethernet": "Ethernet", + "wifi": "Wi-Fi", + "hotspot_active": "Hotspot Active" + }, + "tooltip": { + "network": "Network", + "band": "Band", + "speed": "Speed", + "dns": "DNS", + "bluetooth": "Bluetooth", + "left_click": "Left-click", + "right_click": "Right-click", + "action_switch": "Switch view / Toggle", + "action_toggle": "Quick toggle" + }, + "panel": { + "current_connection": "Current Connection", + "dns_service": "DNS Server", + "hotspot": "Hotspot", + "connected_devices": "Connected Devices", + "connected_devices_count": "{count} Connected Devices", + "connected_devices_ratio": "CONNECTED DEVICES ({count}/{max})", + "no_devices": "No devices connected", + "active": "Active", + "inactive": "Inactive", + "hotspot_active_tip": "Broadcasting network as {ssid}", + "hotspot_inactive_tip": "Hotspot is inactive.", + "system_default": "Default (ISP)", + "dns_prefix": "DNS:", + "bt_prefix": "BT:", + "wifi": "Wi-Fi", + "saved_networks": "SAVED", + "available_networks": "AVAILABLE", + "wifi_off": "Wi-Fi is turned off", + "hotspot_active_notice": "Wi-Fi is unavailable while Hotspot is active", + "hotspot_active_notice_desc": "Turn off Hotspot to scan and connect to Wi-Fi networks.", + "turn_off_hotspot": "Turn Off Hotspot", + "scanning": "Scanning available networks...", + "no_networks": "No networks found", + "password_for": "Password for", + "password_placeholder": "Enter Wi-Fi Password...", + "custom_dns_placeholder": "Custom DNS (e.g. 1.1.1.1 8.8.8.8)", + "connect": "Connect", + "apply": "Apply", + "on": "ON", + "off": "OFF", + "scan_to_connect": "Scan to connect to Wi-Fi", + "generating_qr": "Generating QR code...", + "share_wifi": "Share Wi-Fi", + "bluetooth": "Bluetooth", + "connected_devices": "CONNECTED DEVICES", + "paired_devices": "PAIRED DEVICES", + "available_devices": "AVAILABLE DEVICES", + "connecting": "Connecting...", + "connection_failed": "Connection failed", + "discoverable": "Discoverable", + "discoverable_desc": "Visible to nearby devices", + "no_bt_devices": "No Bluetooth devices found", + "bt_off": "Bluetooth is turned off", + "bt_scanning": "Scanning for devices...", + "pair": "Pair", + "unpair": "Remove", + "accept": "Accept", + "reject": "Reject", + "disconnect_device": "Disconnect", + "pairing_requests": "PAIRING REQUESTS", + "pairing_request_msg": "{name} requested to pair", + "pairing_passkey": "Passkey: {key}", + "pairing_request_no_key": "{name} requested to pair" + }, + "notifications": { + "dns_switched": "DNS switched to {provider}", + "dns_switch_failed": "Failed to switch DNS", + "dns_reset": "DNS reset to System Default", + "connected_to": "Connected to {ssid}", + "connect_failed": "Failed to connect to {ssid}", + "incorrect_password": "Failed to connect to {ssid}: Incorrect password" + }, + "errors": { + "missing_command": "Missing required command dependencies (nmcli/ip)", + "nmcli_not_found": "NetworkManager (nmcli) is not installed", + "bluetoothctl_not_found": "bluetoothctl is not installed" + }, + "settings": { + "refreshinterval": { + "label": "Refresh Interval (Seconds)", + "description": "How often the background service updates network state." + }, + "color": { + "label": "Widget Icon Color", + "description": "Custom color token or hex for the status bar widget icon." + }, + "text_color": { + "label": "Widget Text Color", + "description": "Custom color token (e.g. primary, on_surface) or hex for the widget text." + }, + "hotspot_ssid": { + "label": "Hotspot Name / SSID", + "description": "Network name broadcasted by your Wi-Fi hotspot." + }, + "hotspot_password": { + "label": "Hotspot Password", + "description": "Password required for devices to connect (minimum 8 characters)." + }, + "hotspot_max_connections": { + "label": "Max Hotspot Connections", + "description": "Maximum number of simultaneously connected devices." + }, + "custom_dns_1": { + "label": "Custom DNS Slot 1", + "description": "Format: Name = IP1 IP2 (e.g. NextDNS = 45.90.28.0 45.90.30.0). Only appears in DNS menu when filled." + }, + "custom_dns_2": { + "label": "Custom DNS Slot 2", + "description": "Format: Name = IP1 IP2 (e.g. Mullvad = 194.242.2.4). Only appears in DNS menu when filled." + }, + "custom_dns_3": { + "label": "Custom DNS Slot 3", + "description": "Format: Name = IP1 IP2 (e.g. ControlD = 76.76.2.0). Only appears in DNS menu when filled." + }, + "bluetooth_download_dir": { + "label": "Bluetooth Download Location", + "description": "Directory where files received via Bluetooth are stored (e.g. ~/Downloads)." + }, + "bluetooth_device_name": { + "label": "Bluetooth Device Name", + "description": "Custom name/alias for your Bluetooth adapter. Leave empty to use system default." + }, + "widget_display_mode": { + "label": "Widget Display Mode", + "description": "Choose what information is displayed on the status bar widget.", + "network": "Network (Default)", + "bluetooth": "Bluetooth", + "dns": "DNS", + "network_hotspot": "Network + Hotspot", + "network_bluetooth": "Network + Bluetooth", + "network_dns": "Network + DNS", + "bluetooth_dns": "Bluetooth + DNS", + "network_bluetooth_dns": "Network + Bluetooth + DNS", + "network_hotspot_bluetooth_dns": "Network + Hotspot + Bluetooth + DNS" + }, + "widget_right_click": { + "label": "Widget Right-Click Action", + "description": "Action performed when right-clicking the bar widget.", + "toggle_wifi": "Toggle Wi-Fi", + "toggle_hs": "Toggle Hotspot", + "toggle_bt": "Toggle Bluetooth" + } + } +} diff --git a/network-toolkit/widget.luau b/network-toolkit/widget.luau new file mode 100644 index 00000000..34f51429 --- /dev/null +++ b/network-toolkit/widget.luau @@ -0,0 +1,124 @@ +--!nonstrict +-- Network Toolkit — Top bar status widget +local state = noctalia.state.get("network_state") or {} +local cmdSeq = 0 + +local function signalGlyph(val) + local s = tonumber(val) or 0 + return (s >= 88 and "wifi") or (s >= 63 and "wifi-3") or (s >= 38 and "wifi-2") or (s >= 13 and "wifi-1") or "wifi-0" +end + +local function sendCommand(domain, action, payload) + cmdSeq += 1 + noctalia.state.set("network_command", { domain = domain, action = action, payload = payload, nonce = os.time() .. "_" .. cmdSeq }) +end + +local function buildTooltip() + local net, hs, dns, bt = state.net or {}, state.hotspot or {}, state.dns or {}, state.bluetooth or {} + local rows = {} + local function add(k, v) table.insert(rows, { key = k, value = v }) end + + if hs.active and net.wifiConnected then + add("Wi-Fi", net.ssid ~= "" and net.ssid or noctalia.tr("status.wifi")) + if net.signal and net.signal > 0 then add("Signal", tostring(net.signal) .. "%") end + add("Hotspot", hs.ssid or "Hotspot"); add("Clients", tostring(hs.clientCount or 0)) + if net.ipAddress and net.ipAddress ~= "" then add("IP", net.ipAddress) end + elseif hs.active then + add("Hotspot", hs.ssid or "Hotspot"); add("Clients", tostring(hs.clientCount or 0)) + elseif net.ethernetConnected then + add(noctalia.tr("tooltip.network"), net.interface ~= "" and net.interface or noctalia.tr("status.ethernet")) + if net.ipAddress and net.ipAddress ~= "" then add("IP", net.ipAddress) end + add(noctalia.tr("tooltip.band"), "Ethernet") + elseif net.wifiConnected then + add("SSID", net.ssid ~= "" and net.ssid or noctalia.tr("status.wifi")) + if net.signal and net.signal > 0 then add("Signal", tostring(net.signal) .. "%") end + add(noctalia.tr("tooltip.band"), (net.band and net.band ~= "") and net.band or "—") + if net.ipAddress and net.ipAddress ~= "" then add("IP", net.ipAddress) end + else + add(noctalia.tr("tooltip.network"), noctalia.tr("status.disconnected")) + add(noctalia.tr("tooltip.band"), "—") + end + + add(noctalia.tr("tooltip.speed"), (net.speedFormatted and net.speedFormatted ~= "") and net.speedFormatted or "0 B/s ↓ 0 B/s ↑") + add(noctalia.tr("tooltip.dns"), (dns.activeProvider and dns.activeProvider ~= "default") and (dns.current and dns.current.label or dns.activeProvider) or noctalia.tr("panel.system_default")) + + local btStr = noctalia.tr("panel.bt_off") + if bt.enabled then + local cList = {} + if bt.pairedDevices then + for _, d in ipairs(bt.pairedDevices) do if d.connected then table.insert(cList, d.name or d.address) end end + end + btStr = #cList > 0 and table.concat(cList, ", ") or "On (No devices)" + end + add(noctalia.tr("tooltip.bluetooth"), btStr) + add(noctalia.tr("tooltip.left_click"), noctalia.tr("tooltip.action_switch")) + add(noctalia.tr("tooltip.right_click"), noctalia.tr("tooltip.action_toggle")) + return rows +end + +local function render() + local color = noctalia.getConfig("color") or "on_surface_variant" + local textColor = noctalia.getConfig("text_color") or color + if textColor == "" then textColor = color end + local mode = noctalia.getConfig("widget_display_mode") or "network" + local hs, net, dns, bt = state.hotspot or {}, state.net or {}, state.dns or {}, state.bluetooth or {} + + local container = barWidget.isVertical() and ui.column or ui.row + + local function makeBlock(gName, text, gColor, showDot) + local icon = showDot and ui.row({ align = "start", gap = -3 }, { ui.glyph({ name = gName, size = 16, color = gColor or color }), ui.glyph({ name = "circle-filled", size = 6, color = "error" }) }) or ui.glyph({ name = gName, size = 16, color = gColor or color }) + local items = { icon } + if text and text ~= "" then table.insert(items, ui.label({ text = text, fontSize = 14, color = textColor })) end + return ui.row({ gap = 5, align = "center" }, items) + end + + local netText = net.ethernetConnected and (net.interface ~= "" and net.interface or noctalia.tr("status.ethernet")) or (net.wifiConnected and (net.ssid ~= "" and net.ssid or noctalia.tr("status.wifi")) or "") + local hsDevs = "(" .. tostring(hs.clientCount or 0) .. "/" .. tostring(hs.maxConnections or 8) .. ")" + local netHsText = hs.active and (netText ~= "" and (netText .. " " .. hsDevs) or hsDevs) or netText + local netGlyph = net.ethernetConnected and "ethernet" or (net.wifiConnected and signalGlyph(net.bars or net.signal or 0) or "wifi-0") + + local btText = noctalia.tr("panel.off") + if bt.enabled then + local first = nil + if bt.pairedDevices then + for _, d in ipairs(bt.pairedDevices) do if d.connected then first = d.name or d.address; break end end + end + btText = first or noctalia.tr("panel.bluetooth") + end + local btGlyph = bt.enabled and "bluetooth" or "bluetooth-off" + + local isCustomDns = (dns.activeProvider and dns.activeProvider ~= "default") + local dnsText = isCustomDns and (dns.current and dns.current.label or dns.activeProvider) or noctalia.tr("panel.system_default") + local dnsGlyph = isCustomDns and "shield" or "shield-off" + + local children = {} + local bNet = makeBlock(netGlyph, (mode:find("hotspot") and netHsText or netText), color, hs.active) + local bBt = makeBlock(btGlyph, btText, nil, mode == "bluetooth" and hs.active or false) + local bDns = makeBlock(dnsGlyph, dnsText, nil, mode == "dns" and hs.active or false) + + if mode == "bluetooth" then table.insert(children, bBt) + elseif mode == "dns" then table.insert(children, bDns) + elseif mode == "network_hotspot" then table.insert(children, bNet) + elseif mode == "network_bluetooth" then table.insert(children, bNet); table.insert(children, bBt) + elseif mode == "network_dns" then table.insert(children, bNet); table.insert(children, bDns) + elseif mode == "bluetooth_dns" then table.insert(children, bBt); table.insert(children, bDns) + elseif mode == "network_bluetooth_dns" or mode == "network_hotspot_bluetooth_dns" then table.insert(children, bNet); table.insert(children, bBt); table.insert(children, bDns) + else table.insert(children, bNet) end + + barWidget.render(container({ gap = 7, align = "center" }, children)) + barWidget.setTooltip(buildTooltip()) +end + +noctalia.state.watch("network_state", function(s) if type(s) == "table" then state = s; render() end end) +function update() state = noctalia.state.get("network_state") or state; render() end +noctalia.setUpdateInterval(1000) + +function onClick() noctalia.togglePanel("autumn/network-toolkit:panel") end +function onRightClick() + local op = noctalia.getConfig("widget_right_click") or "toggle_wifi" + if op == "toggle_hs" then sendCommand("hotspot", "toggle") + elseif op == "toggle_bt" then sendCommand("bluetooth", "toggle") + else sendCommand("wifi", "toggle") end +end +function onConfigChanged() render() end +render() \ No newline at end of file