Add nightwatch75/dns-switcher 0.1.1 (#292)
Switch the system DNS between popular providers, custom servers, or the ISP default, from a panel on the bar — no reconnect, no captive-portal re-run. Detection reads the active connection's own ipv4.dns/ipv4.ignore-auto-dns instead of guessing, so a manually configured resolver (LAN ones included) shows as its provider and DHCP-assigned DNS shows as Default (ISP). A singleton service entry owns detection/apply so multiple bars share one engine. Every bar gesture (left/right/scroll) is a manifest [widget.actions] default, resolved through noctalia's own IPC registry, so any of them can be remapped from the bar's own gesture settings without touching the plugin. The panel also carries a DNS lookup tester: resolve a name against the active provider's own address with dig/nslookup, to confirm a switch took effect or that a provider blocks a domain. Co-authored-by: nightwatch75 <nightwatch75@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
nightwatch75
Claude Sonnet 5
parent
4f14e7777c
commit
d2287159e0
@@ -0,0 +1,124 @@
|
||||
# DNS Switcher
|
||||
|
||||
A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: switch the system DNS
|
||||
between popular providers, your own servers, or the ISP default — from a panel
|
||||
on the bar, no reconnect. Based on
|
||||
[Ronin-CK's v4 DNS Switcher](https://github.com/noctalia-dev/legacy-v4-plugins),
|
||||
rebuilt on the v5 Luau plugin API.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `nightwatch75/dns-switcher` |
|
||||
| Entries | Bar widget: `dns-switcher`; panel: `panel`; service: `service` |
|
||||
|
||||
## Features
|
||||
|
||||
- **Instant, no-drop switching** — one `nmcli con mod` + `nmcli device
|
||||
reapply` on the active connection profile; the network never disconnects
|
||||
- **Pre-configured providers** (Google, Cloudflare, OpenDNS, AdGuard, Quad9)
|
||||
plus up to 5 custom servers (`Name = address`, e.g. `Pi-hole = 192.168.1.5`)
|
||||
- **Detection**, not guessing — reads the connection's own `ipv4.dns` /
|
||||
`ipv4.ignore-auto-dns`, so a manually configured resolver (LAN ones
|
||||
included) shows as its provider, DHCP-assigned DNS shows as *Default (ISP)*
|
||||
- **DNS lookup tester** at the bottom of the panel: resolve any name against
|
||||
the currently active provider's own address with `dig`/`nslookup`, to
|
||||
confirm a switch took effect or check whether a provider blocks a domain
|
||||
- **Fully rebindable gestures** — left click, right click and scroll are
|
||||
declared in the manifest (`[widget.actions]`), so any of them can be
|
||||
remapped from the bar's own gesture settings; scroll cycles providers
|
||||
- **Singleton service** — one engine regardless of how many bars/monitors
|
||||
show the widget; widget and panel are pure renderers over its shared state
|
||||
- Live footer (connection name + active resolver IPs, with a copy button),
|
||||
glyph-only mode for compact bars
|
||||
|
||||
## Usage
|
||||
|
||||
Add the `dns-switcher` widget from Noctalia's widget picker. Default gestures:
|
||||
|
||||
| Action | Effect |
|
||||
|--------------|--------------------------------------------------|
|
||||
| Left click | Open/close the provider panel |
|
||||
| Right click | Reset to the connection default (ISP) |
|
||||
| Scroll | Cycle to the next/previous configured provider |
|
||||
|
||||
All three are bar-level defaults and can be remapped from *Settings → Bar*.
|
||||
The panel itself, and the plugin's settings page, also open from the CLI:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle nightwatch75/dns-switcher:panel
|
||||
noctalia msg settings-open-plugin nightwatch75/dns-switcher
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `providers` | `string` | `google,cloudflare,opendns,adguard,quad9` | Comma-separated built-in provider ids shown in the panel. Empty = none. |
|
||||
| `custom_1` … `custom_5` | `string` | *(empty)* | One custom resolver each: `Name = address`, one or two IPv4 addresses. |
|
||||
| `poll_seconds` | `int` | `10` | How often the active DNS is re-read with `nmcli` (2–120). |
|
||||
| `privilege_command` | `string` | *(empty)* | Prefix to run `nmcli` changes as root (`pkexec`, `sudo -n`) — see *Privileges*. |
|
||||
| `show_label` (widget) | `bool` | `true` | Show the provider name next to the glyph. |
|
||||
|
||||
## IPC
|
||||
|
||||
```sh
|
||||
noctalia msg plugin nightwatch75/dns-switcher:service all apply cloudflare
|
||||
noctalia msg plugin nightwatch75/dns-switcher:service all poll
|
||||
noctalia msg plugin nightwatch75/dns-switcher:service all cycle next
|
||||
```
|
||||
|
||||
`apply` takes a built-in id, `default` (ISP), or `custom:<name>`; `poll`
|
||||
forces an immediate re-check; `cycle next`/`cycle prev` step to the
|
||||
neighbouring provider (what scroll sends).
|
||||
|
||||
## Requirements
|
||||
|
||||
- noctalia v5.0.0-beta.6 or newer for the core plugin; the gesture remapping
|
||||
and lookup tester need a newer build still (`plugin_api = 22`)
|
||||
- NetworkManager (`networkmanager`, provides `nmcli`) with an active connection
|
||||
- Permission to modify system connections (see *Privileges* below)
|
||||
- `dig` (bind-tools/dnsutils) or `nslookup`, optional — only the lookup
|
||||
tester needs one of them; the rest of the plugin works without either
|
||||
|
||||
## Privileges
|
||||
|
||||
*Privilege command* is **empty by default**: NetworkManager's polkit policy
|
||||
usually lets active local sessions modify system connections without a
|
||||
password. If you get a "not authorized" error, set it to `pkexec` (shows
|
||||
noctalia's own polkit prompt) or `sudo -n` with a matching sudoers rule:
|
||||
|
||||
```
|
||||
# /etc/sudoers.d/nmcli-dns
|
||||
youruser ALL=(root) NOPASSWD: /usr/bin/nmcli
|
||||
```
|
||||
|
||||
Or grant it via a polkit rule and keep the setting empty:
|
||||
|
||||
```js
|
||||
// /etc/polkit-1/rules.d/50-nmcli-dns.rules
|
||||
polkit.addRule(function(action, subject) {
|
||||
if ((action.id == "org.freedesktop.NetworkManager.settings.modify.system" ||
|
||||
action.id == "org.freedesktop.NetworkManager.network-control") &&
|
||||
subject.isInGroup("wheel")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Both widen what the account can do to NetworkManager system-wide — apply
|
||||
your usual judgement on shared machines.
|
||||
|
||||
## Notes
|
||||
|
||||
- IPv4 DNS only, like the v4 plugin.
|
||||
- Targets the first active wifi/ethernet connection (falling back to the
|
||||
first active non-loopback one); a VPN's own DNS is not touched.
|
||||
- Custom servers are five separate `string` settings rather than one list,
|
||||
because Noctalia's list editor has no in-place row edit — a `string`
|
||||
field does. A server name may not contain `=`.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,78 @@
|
||||
--!nonstrict
|
||||
-- dns-switcher — bar widget: renders the state published by the service
|
||||
-- entry (service.luau).
|
||||
--
|
||||
-- Click mapping is declared in plugin.toml's [widget.actions] (plugin_api >=
|
||||
-- 14), not hard-coded here, so the user can remap it from the bar's own
|
||||
-- gesture settings like any built-in widget:
|
||||
-- Left click — panel-toggle nightwatch75/dns-switcher:panel
|
||||
-- Right click — plugin nightwatch75/dns-switcher:service all apply default
|
||||
-- Scroll up/down — plugin nightwatch75/dns-switcher:service all cycle next/prev
|
||||
--
|
||||
-- No middle-click binding: every bar widget carries a built-in `middle`
|
||||
-- binding that opens its own settings, and that default is exactly what's
|
||||
-- wanted here.
|
||||
|
||||
local STATE_KEY = "dns_state"
|
||||
local GLYPH_UNKNOWN = "globe"
|
||||
|
||||
local snapshot = nil -- last state published by the service
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function setLabel(text)
|
||||
if noctalia.getConfig("show_label") == false then
|
||||
barWidget.setText("")
|
||||
else
|
||||
barWidget.setText(text)
|
||||
end
|
||||
end
|
||||
|
||||
local function paint(glyph, role, label)
|
||||
barWidget.setGlyph(glyph)
|
||||
barWidget.setGlyphColor(role)
|
||||
barWidget.setColor(role)
|
||||
setLabel(label)
|
||||
end
|
||||
|
||||
local function render()
|
||||
local s = snapshot
|
||||
if s == nil or (s.current == nil and s.error == nil) then
|
||||
paint(GLYPH_UNKNOWN, "on_surface", tr("status_checking"))
|
||||
barWidget.clearTooltip()
|
||||
return
|
||||
end
|
||||
if s.changing == true then
|
||||
paint("refresh", "secondary", tr("status_switching"))
|
||||
barWidget.setTooltip(tr("tooltip_switching"))
|
||||
return
|
||||
end
|
||||
if s.error ~= nil then
|
||||
paint(GLYPH_UNKNOWN, "error", s.error)
|
||||
barWidget.clearTooltip()
|
||||
return
|
||||
end
|
||||
local role = s.current.id ~= "default" and "primary" or "on_surface"
|
||||
paint(s.current.glyph, role, s.current.label)
|
||||
local servers = s.current.ip
|
||||
if servers == "" then
|
||||
servers = s.servers ~= "" and s.servers or tr("status_auto")
|
||||
end
|
||||
barWidget.setTooltip({
|
||||
{ key = tr("tooltip_provider"), value = s.current.label },
|
||||
{ key = tr("tooltip_servers"), value = servers },
|
||||
{ key = tr("tooltip_actions"), value = tr("tooltip_hints") },
|
||||
})
|
||||
end
|
||||
|
||||
noctalia.state.watch(STATE_KEY, function(value)
|
||||
if type(value) == "table" then
|
||||
snapshot = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
snapshot = noctalia.state.get(STATE_KEY)
|
||||
render()
|
||||
@@ -0,0 +1,345 @@
|
||||
--!nonstrict
|
||||
-- dns-switcher — provider panel. Pure renderer over the shared state: the
|
||||
-- service entry (service.luau) publishes "dns_state" and executes the
|
||||
-- "apply_request" entries this panel emits. Picking a provider applies it
|
||||
-- immediately (one nmcli change, no reactivation).
|
||||
|
||||
local STATE_KEY = "dns_state"
|
||||
local REQUEST_KEY = "apply_request"
|
||||
|
||||
local RESOLVE_TIMEOUT_MS = 4000
|
||||
|
||||
-- Read out of the plugin's own manifest (readFile resolves a relative path
|
||||
-- against the plugin directory), so the header cannot drift from the version
|
||||
-- the store shows. Empty when unreadable — a missing version is not worth an
|
||||
-- error line in the panel.
|
||||
local pluginVersion = (function()
|
||||
local text = noctalia.readFile("plugin.toml")
|
||||
if type(text) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return ("\n" .. text):match('\nversion%s*=%s*"([^"]+)"') or ""
|
||||
end)()
|
||||
|
||||
local snapshot = nil -- last state published by the service
|
||||
|
||||
-- DNS lookup tester (bottom of the panel). Entirely panel-local: it neither
|
||||
-- reads nor writes the shared state, since it never changes what DNS is
|
||||
-- configured — it only asks "does a name resolve through the DNS that IS
|
||||
-- configured right now", which is exactly the question this plugin otherwise
|
||||
-- has no way to answer.
|
||||
local resolveQuery = ""
|
||||
local resolveBusy = false
|
||||
local resolveError = nil
|
||||
local resolveResult = nil -- { tool, serverIp?, serverLabel?, lines? (dig), raw? (nslookup) }
|
||||
|
||||
local render
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- The nonce is monotonic across writers (widget instances and the panel):
|
||||
-- each seeds from the last request already in the shared state.
|
||||
local function requestApply(entry)
|
||||
if snapshot == nil or snapshot.changing == true then
|
||||
return
|
||||
end
|
||||
local prev = noctalia.state.get(REQUEST_KEY)
|
||||
local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1
|
||||
noctalia.state.set(REQUEST_KEY, { nonce = nonce, id = entry.id, label = entry.label, ip = entry.ip })
|
||||
end
|
||||
|
||||
-- A conservative hostname shape (letters/digits/dot/hyphen, no leading dot or
|
||||
-- hyphen, 253 chars max — the DNS wire-format limit). shellQuote() below is
|
||||
-- the actual safety net; this only keeps an obviously-wrong query from ever
|
||||
-- reaching a shell as a "valid enough" no-op.
|
||||
local function isValidHostname(name)
|
||||
return name ~= "" and #name <= 253 and name:match("^[%w][%w%.%-]*$") ~= nil
|
||||
end
|
||||
|
||||
-- Name + first address of the currently active provider, if it has an
|
||||
-- address of its own (built-in/custom entries do; "Default (ISP)" does not).
|
||||
-- nil means "ask the system resolver", not "no server" — the lookup still
|
||||
-- runs either way.
|
||||
local function activeServerInfo()
|
||||
if snapshot == nil or snapshot.current == nil or type(snapshot.current.ip) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local ip = snapshot.current.ip:match("%S+")
|
||||
if ip == nil then
|
||||
return nil
|
||||
end
|
||||
return { ip = ip, label = snapshot.current.label }
|
||||
end
|
||||
|
||||
local function parseDigShort(stdout)
|
||||
local lines = {}
|
||||
for line in stdout:gmatch("[^\n]+") do
|
||||
local clean = trim(line)
|
||||
if clean ~= "" then
|
||||
table.insert(lines, clean)
|
||||
end
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
-- Prefers `dig +short` (one line per answer, trivial to parse); falls back to
|
||||
-- raw `nslookup` output, shown verbatim, when dig is not installed. Both are
|
||||
-- pointed at the active provider's own address when it has one, so the
|
||||
-- answer reflects that resolver specifically rather than whatever the system
|
||||
-- resolver layer (systemd-resolved, etc.) does with it.
|
||||
local function runResolve()
|
||||
local name = trim(resolveQuery)
|
||||
if not isValidHostname(name) then
|
||||
resolveError = tr("resolve_invalid")
|
||||
resolveResult = nil
|
||||
render()
|
||||
return
|
||||
end
|
||||
|
||||
local server = activeServerInfo()
|
||||
local useDig = noctalia.commandExists("dig")
|
||||
local useNslookup = not useDig and noctalia.commandExists("nslookup")
|
||||
if not useDig and not useNslookup then
|
||||
resolveError = tr("resolve_no_tool")
|
||||
resolveResult = nil
|
||||
render()
|
||||
return
|
||||
end
|
||||
|
||||
resolveBusy = true
|
||||
resolveError = nil
|
||||
resolveResult = nil
|
||||
render()
|
||||
|
||||
local tool = useDig and "dig" or "nslookup"
|
||||
local cmd
|
||||
if useDig then
|
||||
cmd = "dig +time=3 +tries=1 +short "
|
||||
.. (server ~= nil and ("@" .. shellQuote(server.ip) .. " ") or "")
|
||||
.. shellQuote(name)
|
||||
else
|
||||
cmd = "nslookup " .. shellQuote(name) .. (server ~= nil and (" " .. shellQuote(server.ip)) or "")
|
||||
end
|
||||
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
resolveBusy = false
|
||||
if result.timedOut then
|
||||
resolveError = tr("resolve_timeout")
|
||||
elseif tool == "dig" then
|
||||
local lines = parseDigShort(result.stdout or "")
|
||||
if #lines == 0 then
|
||||
resolveError = tr("resolve_empty")
|
||||
else
|
||||
resolveResult = {
|
||||
tool = tool,
|
||||
serverIp = server ~= nil and server.ip or nil,
|
||||
serverLabel = server ~= nil and server.label or nil,
|
||||
lines = lines,
|
||||
}
|
||||
end
|
||||
else
|
||||
local raw = trim(result.stdout or "")
|
||||
if raw == "" then
|
||||
resolveError = tr("resolve_empty")
|
||||
else
|
||||
resolveResult = {
|
||||
tool = tool,
|
||||
serverIp = server ~= nil and server.ip or nil,
|
||||
serverLabel = server ~= nil and server.label or nil,
|
||||
raw = raw,
|
||||
}
|
||||
end
|
||||
end
|
||||
render()
|
||||
end, RESOLVE_TIMEOUT_MS)
|
||||
if not ok then
|
||||
resolveBusy = false
|
||||
resolveError = tr("resolve_spawn_failed")
|
||||
render()
|
||||
end
|
||||
end
|
||||
|
||||
-- A row names its provider and the addresses it would set, so picking one is not
|
||||
-- a guess about what it does. The ISP default has no fixed addresses -- whatever
|
||||
-- the LAN hands out -- so it stays a bare label, and the footer shows what is
|
||||
-- actually in use. A button carries one run of text, so the two are joined with a
|
||||
-- separator rather than styled apart.
|
||||
local function providerRow(entry)
|
||||
local active = snapshot.current ~= nil and snapshot.current.id == entry.id
|
||||
local text = entry.label
|
||||
if entry.ip ~= nil and entry.ip ~= "" then
|
||||
text = text .. " · " .. entry.ip
|
||||
end
|
||||
return ui.button({
|
||||
key = "dns-" .. entry.id .. (active and "-on" or ""),
|
||||
glyph = entry.glyph,
|
||||
text = text,
|
||||
variant = active and "primary" or "ghost",
|
||||
contentAlign = "start",
|
||||
onClick = function()
|
||||
if not active then
|
||||
requestApply(entry)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function statusFooter()
|
||||
if snapshot.changing == true then
|
||||
return ui.label({ text = tr("status_switching"), fontSize = 11, color = "secondary" })
|
||||
end
|
||||
local servers = snapshot.servers
|
||||
if servers == nil or servers == "" then
|
||||
servers = tr("status_auto")
|
||||
end
|
||||
local caption = servers
|
||||
if snapshot.conName ~= nil and snapshot.conName ~= "" then
|
||||
caption = snapshot.conName .. " · " .. servers
|
||||
end
|
||||
return ui.row({ gap = 6, align = "center" }, {
|
||||
ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", flexGrow = 1 }),
|
||||
ui.button({ glyph = "copy", variant = "ghost", tooltip = tr("tip_copy"), onClick = "onCopyServers" }),
|
||||
})
|
||||
end
|
||||
|
||||
-- Bottom-of-panel lookup tester: a name, a button, and whatever the active
|
||||
-- resolver (or the system one, with no provider address of its own) answers.
|
||||
local function resolveSection()
|
||||
local children = {
|
||||
ui.separator({}),
|
||||
ui.label({ key = "resolve-title", text = tr("resolve_title"), fontSize = 12, fontWeight = "semibold", color = "on_surface" }),
|
||||
ui.row({ key = "resolve-row", gap = 6, align = "center" }, {
|
||||
ui.input({
|
||||
key = "resolve-input",
|
||||
value = resolveQuery,
|
||||
placeholder = tr("resolve_placeholder"),
|
||||
flexGrow = 1,
|
||||
onChange = function(value)
|
||||
resolveQuery = value
|
||||
end,
|
||||
onSubmit = function(value)
|
||||
resolveQuery = value
|
||||
runResolve()
|
||||
end,
|
||||
}),
|
||||
ui.button({
|
||||
key = "resolve-go" .. (resolveBusy and "-off" or ""),
|
||||
glyph = "search",
|
||||
variant = "primary",
|
||||
enabled = not resolveBusy,
|
||||
tooltip = tr("tip_resolve"),
|
||||
onClick = function()
|
||||
runResolve()
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
if resolveBusy then
|
||||
table.insert(children, ui.label({ key = "resolve-status", text = tr("resolve_busy"), fontSize = 11, color = "secondary" }))
|
||||
elseif resolveError ~= nil then
|
||||
table.insert(children, ui.label({ key = "resolve-status", text = resolveError, fontSize = 11, color = "error", maxLines = 2 }))
|
||||
elseif resolveResult ~= nil then
|
||||
local via
|
||||
if resolveResult.serverIp ~= nil then
|
||||
via = tr("resolve_via", { name = resolveResult.serverLabel or resolveResult.serverIp, server = resolveResult.serverIp })
|
||||
else
|
||||
via = tr("resolve_via_system")
|
||||
end
|
||||
local body = resolveResult.lines ~= nil and table.concat(resolveResult.lines, ", ") or resolveResult.raw
|
||||
-- The answer first, then which server gave it — the label is set
|
||||
-- apart with a slightly larger font since it names what answered.
|
||||
table.insert(children, ui.label({ key = "resolve-body", text = body, fontSize = 14, color = "primary", maxLines = 6 }))
|
||||
table.insert(children, ui.label({ key = "resolve-via", text = via, fontSize = 13, fontWeight = "medium", color = "on_surface_variant" }))
|
||||
end
|
||||
|
||||
return ui.column({ key = "resolve", gap = 6 }, children)
|
||||
end
|
||||
|
||||
render = function()
|
||||
local children = {
|
||||
ui.row({ gap = 8, align = "center" }, {
|
||||
ui.label({
|
||||
key = "title",
|
||||
text = tr("title"),
|
||||
fontSize = 16,
|
||||
fontWeight = "bold",
|
||||
color = "on_surface",
|
||||
}),
|
||||
-- Version off the manifest, small and dimmed: it answers "which
|
||||
-- build am I running" without competing with the title. The spacer
|
||||
-- rather than a flexGrow title keeps the two together on the left.
|
||||
ui.label({ key = "version", text = pluginVersion, fontSize = 10, color = "on_surface_variant" }),
|
||||
ui.spacer({ key = "gap", flexGrow = 1 }),
|
||||
ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("tip_settings"), onClick = "onOpenSettings" }),
|
||||
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
||||
}),
|
||||
}
|
||||
|
||||
if snapshot == nil or snapshot.list == nil then
|
||||
table.insert(children, ui.label({ text = tr("status_checking"), color = "on_surface_variant" }))
|
||||
elseif snapshot.error ~= nil then
|
||||
table.insert(children, ui.label({ text = snapshot.error, color = "error" }))
|
||||
else
|
||||
local rows = {}
|
||||
for _, entry in ipairs(snapshot.list) do
|
||||
table.insert(rows, providerRow(entry))
|
||||
end
|
||||
table.insert(children, ui.scroll({ flexGrow = 1, gap = 4 }, rows))
|
||||
table.insert(children, statusFooter())
|
||||
end
|
||||
|
||||
-- Always present, independent of detection state: it asks a question
|
||||
-- about a name, not about which provider is active.
|
||||
table.insert(children, resolveSection())
|
||||
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
snapshot = noctalia.state.get(STATE_KEY)
|
||||
render()
|
||||
end
|
||||
|
||||
noctalia.state.watch(STATE_KEY, function(value)
|
||||
if type(value) == "table" then
|
||||
snapshot = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
function onCopyServers()
|
||||
if snapshot == nil then
|
||||
return
|
||||
end
|
||||
local text = snapshot.servers
|
||||
if (text == nil or text == "") and snapshot.current ~= nil then
|
||||
text = snapshot.current.ip
|
||||
end
|
||||
if text == nil or text == "" then
|
||||
return
|
||||
end
|
||||
noctalia.copyToClipboard(text, "text/plain")
|
||||
noctalia.notify(tr("title"), tr("copied", { ip = text }))
|
||||
end
|
||||
|
||||
-- Opens the settings window on this plugin's own page (the host supplies the
|
||||
-- plugin id, so a plugin can only ever open its own). It closes the panel on
|
||||
-- the way, which is why nothing is rendered afterwards.
|
||||
function onOpenSettings()
|
||||
noctalia.openSettings()
|
||||
end
|
||||
|
||||
function onClosePanel()
|
||||
panel.close()
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
# DNS Switcher — switch the system DNS (NetworkManager) from the bar.
|
||||
# Based on Ronin-CK's v4 "DNS Switcher", rebuilt on the v5 Luau API: the bar
|
||||
# widget shows the active provider and toggles a panel listing the configured
|
||||
# providers; picking one applies it immediately via `nmcli con mod` +
|
||||
# `nmcli device reapply` (no reactivation, the connection never drops).
|
||||
|
||||
id = "nightwatch75/dns-switcher"
|
||||
name = "DNS Switcher"
|
||||
version = "0.1.1"
|
||||
plugin_api = 17
|
||||
author = "nightwatch75"
|
||||
license = "MIT"
|
||||
# dig (bind-tools/dnsutils) is preferred for the panel's lookup tester;
|
||||
# nslookup is the fallback when dig is missing. Neither is required for the
|
||||
# core switch/apply feature, only for that one panel section.
|
||||
dependencies = ["networkmanager", "dig", "nslookup"]
|
||||
tags = ["bar", "panel", "service", "network", "privacy"]
|
||||
icon = "world"
|
||||
description = "Switch the system DNS between popular providers, custom servers, or the ISP default (NetworkManager)."
|
||||
|
||||
# Plugin-level settings: shared by the widget engine (detection/apply) and
|
||||
# the panel (provider list). Custom servers are five `Name = 1.2.3.4 5.6.7.8`
|
||||
# string fields, custom_1..custom_5; see the comment on them for why.
|
||||
|
||||
[[setting]]
|
||||
key = "providers"
|
||||
type = "string"
|
||||
label_key = "settings.providers.label"
|
||||
description_key = "settings.providers.description"
|
||||
default = "google,cloudflare,opendns,adguard,quad9"
|
||||
|
||||
# Five separate string settings rather than one list, because Noctalia's list
|
||||
# editor shows an existing row as a static label with remove/up/down buttons: a
|
||||
# typo means deleting the row and typing it again. A string renders as a text
|
||||
# field, so each server can be corrected in place. Five covers any realistic
|
||||
# number of custom resolvers.
|
||||
#
|
||||
# The keys are new names on purpose, not a retyped custom_dns. A stored value
|
||||
# whose type no longer matches its declaration makes Noctalia reject every
|
||||
# settings write, so the whole file stops saving; a key that simply no longer
|
||||
# exists is just a warning. That is what 0.0.8 was released to fix.
|
||||
[[setting]]
|
||||
key = "custom_1"
|
||||
type = "string"
|
||||
label_key = "settings.custom_1.label"
|
||||
description_key = "settings.custom.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "custom_2"
|
||||
type = "string"
|
||||
label_key = "settings.custom_2.label"
|
||||
description_key = "settings.custom.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "custom_3"
|
||||
type = "string"
|
||||
label_key = "settings.custom_3.label"
|
||||
description_key = "settings.custom.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "custom_4"
|
||||
type = "string"
|
||||
label_key = "settings.custom_4.label"
|
||||
description_key = "settings.custom.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "custom_5"
|
||||
type = "string"
|
||||
label_key = "settings.custom_5.label"
|
||||
description_key = "settings.custom.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "poll_seconds"
|
||||
type = "int"
|
||||
label_key = "settings.poll_seconds.label"
|
||||
description_key = "settings.poll_seconds.description"
|
||||
default = 10
|
||||
min = 2
|
||||
max = 120
|
||||
|
||||
[[setting]]
|
||||
key = "privilege_command"
|
||||
type = "string"
|
||||
label_key = "settings.privilege_command.label"
|
||||
description_key = "settings.privilege_command.description"
|
||||
default = ""
|
||||
advanced = true
|
||||
|
||||
[[service]]
|
||||
id = "service"
|
||||
entry = "service.luau"
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
# Wide enough for the longest built-in row, "OpenDNS · 208.67.222.222
|
||||
# 208.67.220.220", without eliding the addresses. Height grew in 0.0.13 to
|
||||
# fit the lookup tester below the provider list without starving it.
|
||||
width = 430
|
||||
height = 460
|
||||
placement = "attached"
|
||||
open_near_click = true
|
||||
|
||||
[[widget]]
|
||||
id = "dns-switcher"
|
||||
entry = "dns-switcher.luau"
|
||||
|
||||
# Declared here rather than hard-coded in onClick/onRightClick, so the user
|
||||
# can remap any of them from the bar's own gesture settings. "right"/
|
||||
# "scroll_*" are the exact IPC lines service.luau's onIpc documents.
|
||||
[widget.actions]
|
||||
left = "panel-toggle nightwatch75/dns-switcher:panel"
|
||||
right = "plugin nightwatch75/dns-switcher:service all apply default"
|
||||
scroll_up = "plugin nightwatch75/dns-switcher:service all cycle next"
|
||||
scroll_down = "plugin nightwatch75/dns-switcher:service all cycle prev"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_label"
|
||||
type = "bool"
|
||||
label_key = "settings.show_label.label"
|
||||
description_key = "settings.show_label.description"
|
||||
default = true
|
||||
@@ -0,0 +1,497 @@
|
||||
--!nonstrict
|
||||
-- dns-switcher — singleton DNS engine (detection + apply).
|
||||
--
|
||||
-- Runs once regardless of how many bars show the widget. The widget and the
|
||||
-- panel are pure renderers wired through the plugin's shared state:
|
||||
-- engine publishes "dns_state" = { nonce, current?, servers, conName,
|
||||
-- changing, error?, list }
|
||||
-- UI entries send "apply_request" = { nonce, id, label?, ip? }
|
||||
--
|
||||
-- Detection reads the active connection's ipv4.dns / ipv4.ignore-auto-dns
|
||||
-- profile settings (manual DNS is matched against the providers, otherwise
|
||||
-- shown as "Custom"); without a manual DNS the state is the ISP default.
|
||||
-- Applying runs `nmcli con mod <uuid> … && nmcli device reapply <dev>` —
|
||||
-- reapply pushes the change onto the live connection without reactivating
|
||||
-- it, so the network never drops. The privilege command is empty by default:
|
||||
-- NetworkManager's polkit policy lets active local sessions modify system
|
||||
-- connections on most desktop distros.
|
||||
|
||||
local STATE_KEY = "dns_state"
|
||||
local REQUEST_KEY = "apply_request"
|
||||
|
||||
local BUILTIN = {
|
||||
{ id = "google", label = "Google", ip = "8.8.8.8 8.8.4.4", glyph = "brand-google" },
|
||||
{ id = "cloudflare", label = "Cloudflare", ip = "1.1.1.1 1.0.0.1", glyph = "cloud" },
|
||||
{ id = "opendns", label = "OpenDNS", ip = "208.67.222.222 208.67.220.220", glyph = "world" },
|
||||
{ id = "adguard", label = "AdGuard", ip = "94.140.14.14 94.140.15.15", glyph = "shield-check" },
|
||||
{ id = "quad9", label = "Quad9", ip = "9.9.9.9 149.112.112.112", glyph = "lock" },
|
||||
}
|
||||
local GLYPH_DEFAULT = "router" -- ISP / connection default
|
||||
local GLYPH_UNKNOWN = "globe" -- unrecognized manual DNS
|
||||
local GLYPH_CUSTOM = "server" -- user-defined servers
|
||||
|
||||
local current = nil -- provider entry detected as active; nil = still checking
|
||||
local lastSeen = "" -- runtime resolver IPs from the last successful poll
|
||||
local conName = "" -- active connection name, shown in the panel footer
|
||||
local errMsg = nil -- sticky error label (no nmcli / no connection)
|
||||
local pollTicks = 0
|
||||
local changing = false
|
||||
local checkInFlight = false
|
||||
local nmcliMissing = false
|
||||
local stateNonce = 0
|
||||
|
||||
local function cfg(key)
|
||||
return noctalia.getConfig(key)
|
||||
end
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function pollSeconds()
|
||||
return math.max(2, tonumber(cfg("poll_seconds")) or 10)
|
||||
end
|
||||
|
||||
local function isValidIp(ip)
|
||||
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if a == nil then
|
||||
return false
|
||||
end
|
||||
for _, part in ipairs({ a, b, c, d }) do
|
||||
if #part > 3 or tonumber(part) > 255 then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One or two space-separated IPv4 addresses, same shape nmcli accepts.
|
||||
local function validDnsSpec(spec)
|
||||
local count = 0
|
||||
for token in spec:gmatch("%S+") do
|
||||
count += 1
|
||||
if count > 2 or not isValidIp(token) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return count > 0
|
||||
end
|
||||
|
||||
-- Custom servers: five separate `Name = 1.2.3.4 5.6.7.8` string settings rather
|
||||
-- than one list, so a wrong address can be corrected in the field instead of
|
||||
-- deleted and retyped -- Noctalia's list editor has no per-row edit. Slot order
|
||||
-- is panel order. The keys are listed as literals so `noctalia plugins lint` can
|
||||
-- still match them against the manifest.
|
||||
local CUSTOM_KEYS = { "custom_1", "custom_2", "custom_3", "custom_4", "custom_5" }
|
||||
|
||||
-- The raw slots are the cache signature, so an invalid entry is logged when a
|
||||
-- slot actually changes rather than on every poll.
|
||||
local customCacheSig = nil
|
||||
local customCacheList = {}
|
||||
local function customProviders()
|
||||
local rows = {}
|
||||
for _, key in ipairs(CUSTOM_KEYS) do
|
||||
local row = cfg(key)
|
||||
if type(row) == "string" and trim(row) ~= "" then
|
||||
table.insert(rows, row)
|
||||
end
|
||||
end
|
||||
local sig = table.concat(rows, "\n")
|
||||
if sig == customCacheSig then
|
||||
return customCacheList
|
||||
end
|
||||
customCacheSig = sig
|
||||
customCacheList = {}
|
||||
for _, row in ipairs(rows) do
|
||||
-- The name is everything before the first '='; the rest is the address
|
||||
-- list. A name may therefore not contain '=' itself — such a row fails
|
||||
-- the address check below and is skipped with a log line.
|
||||
local name, spec = row:match("^([^=]*)=(.*)$")
|
||||
local cleanName = trim(name or "")
|
||||
local cleanSpec = trim(spec or "")
|
||||
if cleanName ~= "" and validDnsSpec(cleanSpec) then
|
||||
table.insert(customCacheList, { id = "custom:" .. cleanName, label = cleanName, ip = cleanSpec, glyph = GLYPH_CUSTOM })
|
||||
elseif trim(row) ~= "" then
|
||||
noctalia.log("dns-switcher: ignoring invalid custom server row '" .. row .. "'")
|
||||
end
|
||||
end
|
||||
return customCacheList
|
||||
end
|
||||
|
||||
local function enabledBuiltins()
|
||||
local raw = cfg("providers")
|
||||
if type(raw) ~= "string" then
|
||||
return BUILTIN
|
||||
end
|
||||
-- Cleared setting = no built-ins: only custom servers and the ISP default.
|
||||
if trim(raw) == "" then
|
||||
return {}
|
||||
end
|
||||
local wanted = {}
|
||||
for id in raw:gmatch("[^,%s]+") do
|
||||
wanted[id:lower()] = true
|
||||
end
|
||||
local list = {}
|
||||
for _, provider in ipairs(BUILTIN) do
|
||||
if wanted[provider.id] then
|
||||
table.insert(list, provider)
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function defaultEntry()
|
||||
return { id = "default", label = tr("status_default"), ip = "", glyph = GLYPH_DEFAULT }
|
||||
end
|
||||
|
||||
-- Panel order: enabled built-ins, then custom servers, then the ISP default.
|
||||
local function providerList()
|
||||
local list = {}
|
||||
for _, provider in ipairs(enabledBuiltins()) do
|
||||
table.insert(list, provider)
|
||||
end
|
||||
for _, provider in ipairs(customProviders()) do
|
||||
table.insert(list, provider)
|
||||
end
|
||||
table.insert(list, defaultEntry())
|
||||
return list
|
||||
end
|
||||
|
||||
local function publish()
|
||||
stateNonce += 1
|
||||
noctalia.state.set(STATE_KEY, {
|
||||
nonce = stateNonce,
|
||||
current = current,
|
||||
servers = lastSeen,
|
||||
conName = conName,
|
||||
changing = changing,
|
||||
error = errMsg,
|
||||
list = providerList(),
|
||||
})
|
||||
end
|
||||
|
||||
-- Picks the active connection: prefer wifi/ethernet, else the first
|
||||
-- non-loopback entry. Emits KEY=value lines parsed by the poll callback;
|
||||
-- UUID (colon-free) identifies the connection, DEV drives the reapply.
|
||||
local DETECT_CMD = [[
|
||||
ACT=$(LC_ALL=C nmcli -t -f TYPE,DEVICE,UUID,NAME connection show --active 2>/dev/null)
|
||||
LINE=$(printf '%s\n' "$ACT" | grep -E '^(802-11-wireless|802-3-ethernet):' | head -n 1)
|
||||
[ -n "$LINE" ] || LINE=$(printf '%s\n' "$ACT" | grep -v '^loopback:' | head -n 1)
|
||||
[ -n "$LINE" ] || { echo 'ERR=noconn'; exit 0; }
|
||||
DEV=$(printf '%s' "$LINE" | cut -d: -f2)
|
||||
UUID=$(printf '%s' "$LINE" | cut -d: -f3)
|
||||
echo "NAME=$(printf '%s' "$LINE" | cut -d: -f4-)"
|
||||
echo "CFG=$(LC_ALL=C nmcli -g ipv4.dns connection show uuid "$UUID" 2>/dev/null)"
|
||||
echo "AUTO=$(LC_ALL=C nmcli -g ipv4.ignore-auto-dns connection show uuid "$UUID" 2>/dev/null)"
|
||||
echo "RUN=$(nmcli -g IP4.DNS device show "$DEV" 2>/dev/null | tr '\n' ' ')"
|
||||
]]
|
||||
|
||||
local function updateDnsState(stdout)
|
||||
local fields = {}
|
||||
for line in stdout:gmatch("[^\n]+") do
|
||||
local key, value = line:match("^(%u+)=(.*)$")
|
||||
if key ~= nil then
|
||||
fields[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
if fields.ERR == "noconn" then
|
||||
current = nil
|
||||
errMsg = tr("err_no_connection")
|
||||
return
|
||||
end
|
||||
errMsg = nil
|
||||
conName = fields.NAME or ""
|
||||
|
||||
local runtime = {}
|
||||
for token in (fields.RUN or ""):gmatch("%d+%.%d+%.%d+%.%d+") do
|
||||
if isValidIp(token) then
|
||||
table.insert(runtime, token)
|
||||
end
|
||||
end
|
||||
lastSeen = table.concat(runtime, " ")
|
||||
|
||||
-- Manual DNS lives in the profile (ipv4.dns + ignore-auto-dns yes);
|
||||
-- anything else is the connection default, whatever the LAN hands out.
|
||||
local manual = (fields.AUTO == "yes")
|
||||
local cfgIps = {}
|
||||
for token in (fields.CFG or ""):gmatch("%d+%.%d+%.%d+%.%d+") do
|
||||
if isValidIp(token) then
|
||||
table.insert(cfgIps, token)
|
||||
end
|
||||
end
|
||||
if not manual or #cfgIps == 0 then
|
||||
current = defaultEntry()
|
||||
return
|
||||
end
|
||||
|
||||
-- Customs take precedence over built-ins, so a custom entry that reuses
|
||||
-- a public IP (e.g. a forwarder) keeps its own label.
|
||||
local lookup = {}
|
||||
for _, provider in ipairs(BUILTIN) do
|
||||
for ip in provider.ip:gmatch("%S+") do
|
||||
lookup[ip] = provider
|
||||
end
|
||||
end
|
||||
for _, provider in ipairs(customProviders()) do
|
||||
for ip in provider.ip:gmatch("%S+") do
|
||||
lookup[ip] = provider
|
||||
end
|
||||
end
|
||||
for _, ip in ipairs(cfgIps) do
|
||||
if lookup[ip] ~= nil then
|
||||
current = lookup[ip]
|
||||
return
|
||||
end
|
||||
end
|
||||
current = {
|
||||
id = "unknown",
|
||||
label = tr("status_custom", { ip = cfgIps[1] }),
|
||||
ip = table.concat(cfgIps, " "),
|
||||
glyph = GLYPH_UNKNOWN,
|
||||
}
|
||||
end
|
||||
|
||||
local function pollNow()
|
||||
if checkInFlight or changing or nmcliMissing then
|
||||
return
|
||||
end
|
||||
checkInFlight = true
|
||||
local ok = noctalia.runAsync(DETECT_CMD, function(result)
|
||||
checkInFlight = false
|
||||
if result.exitCode == 0 and not result.timedOut then
|
||||
updateDnsState(result.stdout)
|
||||
elseif current == nil then
|
||||
errMsg = tr("status_no_nmcli")
|
||||
end
|
||||
publish()
|
||||
end, 4000)
|
||||
if not ok then
|
||||
checkInFlight = false
|
||||
end
|
||||
end
|
||||
|
||||
local function apply(provider)
|
||||
if changing then
|
||||
return
|
||||
end
|
||||
-- No "already active" short-circuit: `current` is only ever refreshed by
|
||||
-- the async poll, so it can be stale by up to a full pollSeconds() window
|
||||
-- (shorter but still nonzero right after another apply/cycle). Skipping
|
||||
-- here on a stale match would silently drop a legitimate request instead
|
||||
-- of just doing one harmless idempotent nmcli round trip.
|
||||
if provider.ip ~= "" and not validDnsSpec(provider.ip) then
|
||||
noctalia.notifyError(tr("title"), tr("err_invalid_ip", { ip = provider.ip }))
|
||||
return
|
||||
end
|
||||
|
||||
-- Safety net mirroring the v4 plugin: the spec is already validated, the
|
||||
-- gsub guarantees nothing shell-relevant ever reaches the command line.
|
||||
local safeIp = provider.ip:gsub("[^%d%. ]", "")
|
||||
local mods
|
||||
if safeIp == "" then
|
||||
mods = 'ipv4.dns "" ipv4.ignore-auto-dns no'
|
||||
else
|
||||
mods = 'ipv4.dns "' .. safeIp .. '" ipv4.ignore-auto-dns yes'
|
||||
end
|
||||
local inner = 'ACT=$(LC_ALL=C nmcli -t -f TYPE,DEVICE,UUID connection show --active 2>/dev/null); '
|
||||
.. [[LINE=$(printf '%s\n' "$ACT" | grep -E '^(802-11-wireless|802-3-ethernet):' | head -n 1); ]]
|
||||
.. [=[[ -n "$LINE" ] || LINE=$(printf '%s\n' "$ACT" | grep -v '^loopback:' | head -n 1); ]=]
|
||||
.. [=[[ -n "$LINE" ] || exit 9; ]=]
|
||||
.. 'DEV=$(printf \'%s\' "$LINE" | cut -d: -f2); '
|
||||
.. 'UUID=$(printf \'%s\' "$LINE" | cut -d: -f3); '
|
||||
.. 'nmcli con mod "$UUID" ' .. mods .. ' && nmcli device reapply "$DEV"'
|
||||
|
||||
local priv = cfg("privilege_command")
|
||||
if type(priv) ~= "string" then
|
||||
priv = ""
|
||||
end
|
||||
priv = trim(priv)
|
||||
local cmd = inner
|
||||
if priv ~= "" then
|
||||
cmd = priv .. " sh -c " .. shellQuote(inner)
|
||||
end
|
||||
|
||||
changing = true
|
||||
publish()
|
||||
-- 60s budget so an eventual polkit password prompt can be answered.
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
changing = false
|
||||
if result.exitCode == 0 and not result.timedOut then
|
||||
noctalia.notify(tr("title"), tr("applied", { name = provider.label }))
|
||||
elseif result.timedOut then
|
||||
noctalia.notifyError(tr("title"), tr("err_timeout"))
|
||||
elseif result.exitCode == 9 then
|
||||
noctalia.notifyError(tr("title"), tr("err_no_connection"))
|
||||
elseif result.exitCode == 126 then
|
||||
noctalia.notifyError(tr("title"), tr("err_auth_dismissed"))
|
||||
else
|
||||
local detail = trim(result.stderr or "")
|
||||
if #detail > 200 then
|
||||
detail = detail:sub(1, 200) .. "…"
|
||||
end
|
||||
local body = tr("err_apply_failed")
|
||||
if detail:lower():find("not authorized") or detail:lower():find("insufficient") then
|
||||
body = tr("err_not_authorized")
|
||||
elseif detail ~= "" then
|
||||
body = body .. "\n" .. detail
|
||||
end
|
||||
noctalia.notifyError(tr("title"), body)
|
||||
end
|
||||
publish()
|
||||
pollNow()
|
||||
end, 60000)
|
||||
if not ok then
|
||||
changing = false
|
||||
noctalia.notifyError(tr("title"), tr("err_spawn"))
|
||||
publish()
|
||||
end
|
||||
end
|
||||
|
||||
-- Resolves an apply request against the current provider list (so config
|
||||
-- edits win over stale request payloads), falling back to the request's own
|
||||
-- label/ip for entries that just left the list.
|
||||
local function applyById(id, label, ip)
|
||||
for _, entry in ipairs(providerList()) do
|
||||
if entry.id == id then
|
||||
apply(entry)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if id == "default" or (type(ip) == "string" and validDnsSpec(ip)) then
|
||||
apply({ id = id, label = label or id, ip = id == "default" and "" or ip })
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Steps the active provider to its neighbour in providerList() (wrapping at
|
||||
-- either end). Backs the bar widget's scroll_up/scroll_down gesture default;
|
||||
-- with no detected current provider yet, "next" starts at the first entry
|
||||
-- rather than doing nothing.
|
||||
local function cycleTo(direction)
|
||||
local list = providerList()
|
||||
if #list == 0 then
|
||||
return
|
||||
end
|
||||
local index = 1
|
||||
if current ~= nil then
|
||||
for i, entry in ipairs(list) do
|
||||
if entry.id == current.id then
|
||||
index = i
|
||||
break
|
||||
end
|
||||
end
|
||||
index = index + (direction == "prev" and -1 or 1)
|
||||
if index < 1 then
|
||||
index = #list
|
||||
elseif index > #list then
|
||||
index = 1
|
||||
end
|
||||
end
|
||||
apply(list[index])
|
||||
end
|
||||
|
||||
-- Apply requests from the widget/panel. The nonce is monotonic across
|
||||
-- writers (each seeds from the last request) and guards against replaying a
|
||||
-- stale request after a hot reload of this script.
|
||||
local handledNonce = 0
|
||||
do
|
||||
local pendingReq = noctalia.state.get(REQUEST_KEY)
|
||||
if type(pendingReq) == "table" and type(pendingReq.nonce) == "number" then
|
||||
handledNonce = pendingReq.nonce
|
||||
end
|
||||
end
|
||||
noctalia.state.watch(REQUEST_KEY, function(req)
|
||||
if type(req) ~= "table" or type(req.nonce) ~= "number" or req.nonce <= handledNonce then
|
||||
return
|
||||
end
|
||||
handledNonce = req.nonce
|
||||
if nmcliMissing then
|
||||
return
|
||||
end
|
||||
applyById(req.id, req.label, req.ip)
|
||||
end)
|
||||
|
||||
-- Scriptable switching:
|
||||
-- noctalia msg plugin nightwatch75/dns-switcher:service all apply <id>
|
||||
-- where <id> is a provider id ("google", "default", "custom:<name>"…);
|
||||
-- "poll" forces an immediate re-detection; "cycle next"/"cycle prev" steps to
|
||||
-- the neighbouring provider in the panel's own order (this is what the bar
|
||||
-- widget's scroll_up/scroll_down gesture defaults send, [widget.actions] in
|
||||
-- plugin.toml).
|
||||
function onIpc(event, payload)
|
||||
if nmcliMissing then
|
||||
return
|
||||
end
|
||||
if event == "poll" then
|
||||
pollNow()
|
||||
elseif event == "apply" then
|
||||
local id = type(payload) == "string" and trim(payload) or ""
|
||||
if not applyById(id) then
|
||||
noctalia.notifyError(tr("title"), tr("err_unknown_provider", { id = id }))
|
||||
end
|
||||
elseif event == "cycle" then
|
||||
local direction = type(payload) == "string" and trim(payload) or "next"
|
||||
cycleTo(direction)
|
||||
end
|
||||
end
|
||||
|
||||
-- plugin_api >= 17. A hot reload (editing this file) tears this VM down and a
|
||||
-- fresh one starts moments later, which republishes its own state on load —
|
||||
-- nothing to do for "reload". On disable/uninstall/shutdown mid-apply,
|
||||
-- though, nothing ever republishes again: without this, every widget/panel
|
||||
-- instance reading the shared state would stay frozen on "changing" forever.
|
||||
-- The in-flight nmcli command is not ours to cancel either way — runAsync
|
||||
-- hands back no killable handle, and it is idempotent (con mod + reapply), so
|
||||
-- letting it finish in the background is harmless. This only stops
|
||||
-- describing it as in progress. The DNS choice itself is never touched here:
|
||||
-- it lives in the NetworkManager connection profile, independent of whether
|
||||
-- this plugin is enabled at all.
|
||||
function onExit(_signal, reason)
|
||||
if reason == "reload" then
|
||||
return
|
||||
end
|
||||
if changing then
|
||||
changing = false
|
||||
publish()
|
||||
end
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
-- Settings edits reshape the provider list and may relabel the current
|
||||
-- entry; re-publish and re-check right away.
|
||||
publish()
|
||||
pollNow()
|
||||
end
|
||||
|
||||
function update()
|
||||
if nmcliMissing then
|
||||
return
|
||||
end
|
||||
pollTicks += 1
|
||||
if pollTicks >= pollSeconds() then
|
||||
pollTicks = 0
|
||||
pollNow()
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.setUpdateInterval(1000)
|
||||
if not noctalia.commandExists("nmcli") then
|
||||
nmcliMissing = true
|
||||
errMsg = tr("status_no_nmcli")
|
||||
current = nil
|
||||
publish()
|
||||
noctalia.notifyError(tr("title"), tr("err_no_nmcli"))
|
||||
else
|
||||
publish()
|
||||
pollNow()
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"applied": "DNS switched to {name}",
|
||||
"copied": "DNS servers copied: {ip}",
|
||||
"err_apply_failed": "Failed to apply the DNS settings",
|
||||
"err_auth_dismissed": "Authorization dismissed",
|
||||
"err_invalid_ip": "Invalid DNS address: {ip}",
|
||||
"err_no_connection": "No active NetworkManager connection",
|
||||
"err_no_nmcli": "nmcli not found — install NetworkManager",
|
||||
"err_not_authorized": "NetworkManager refused the change (not authorized). Set the Privilege command setting to 'pkexec' or 'sudo -n'.",
|
||||
"err_spawn": "Could not run nmcli",
|
||||
"err_timeout": "Timed out applying the DNS change (authorization prompt left unanswered?)",
|
||||
"err_unknown_provider": "Unknown provider id: {id}",
|
||||
"resolve_busy": "Resolving…",
|
||||
"resolve_empty": "No records found",
|
||||
"resolve_invalid": "Enter a valid hostname",
|
||||
"resolve_no_tool": "Neither dig nor nslookup is installed",
|
||||
"resolve_placeholder": "hostname, e.g. example.com",
|
||||
"resolve_spawn_failed": "Could not run the lookup",
|
||||
"resolve_timeout": "Timed out",
|
||||
"resolve_title": "DNS lookup",
|
||||
"resolve_via": "via {name} ({server})",
|
||||
"resolve_via_system": "via system resolver",
|
||||
"settings": {
|
||||
"custom": {
|
||||
"description": "One custom resolver, written 'Name = address', with one or two IPv4 addresses: 'Pi-hole = 192.168.1.5' or 'NextDNS = 45.90.28.0 45.90.30.0'. The panel lists the slots in order and skips the empty ones; a row that is not a valid address is skipped and logged."
|
||||
},
|
||||
"custom_1": {
|
||||
"label": "Custom server 1"
|
||||
},
|
||||
"custom_2": {
|
||||
"label": "Custom server 2"
|
||||
},
|
||||
"custom_3": {
|
||||
"label": "Custom server 3"
|
||||
},
|
||||
"custom_4": {
|
||||
"label": "Custom server 4"
|
||||
},
|
||||
"custom_5": {
|
||||
"label": "Custom server 5"
|
||||
},
|
||||
"poll_seconds": {
|
||||
"description": "How often the active DNS is re-read with nmcli.",
|
||||
"label": "Poll interval (seconds)"
|
||||
},
|
||||
"privilege_command": {
|
||||
"description": "Prefix to run nmcli changes as root (e.g. 'pkexec', 'sudo -n'). Empty (default) runs nmcli directly — NetworkManager's polkit policy allows this for active local sessions on most desktop distros.",
|
||||
"label": "Privilege command"
|
||||
},
|
||||
"providers": {
|
||||
"description": "Comma-separated ids included in the scroll cycle: google, cloudflare, opendns, adguard, quad9. Empty = none (custom servers and ISP default only).",
|
||||
"label": "Built-in providers"
|
||||
},
|
||||
"show_label": {
|
||||
"description": "Show the provider name next to the glyph (off = glyph only).",
|
||||
"label": "Show provider name"
|
||||
}
|
||||
},
|
||||
"status_auto": "automatic",
|
||||
"status_checking": "Checking…",
|
||||
"status_custom": "Custom ({ip})",
|
||||
"status_default": "Default (ISP)",
|
||||
"status_no_nmcli": "nmcli unavailable",
|
||||
"status_switching": "Switching…",
|
||||
"tip_close": "Close",
|
||||
"tip_copy": "Copy the active DNS servers",
|
||||
"tip_resolve": "Resolve this name with the active DNS",
|
||||
"tip_settings": "Plugin settings",
|
||||
"title": "DNS Switcher",
|
||||
"tooltip_actions": "Actions",
|
||||
"tooltip_hints": "click: panel · right: reset to ISP · scroll: cycle",
|
||||
"tooltip_provider": "DNS",
|
||||
"tooltip_servers": "Servers",
|
||||
"tooltip_switching": "Applying the DNS change — a password prompt may appear"
|
||||
}
|
||||
Reference in New Issue
Block a user