Files
community-plugins/dns-switcher/panel.luau
T
d2287159e0 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>
2026-08-08 10:56:26 -04:00

346 lines
13 KiB
Luau

--!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