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>
346 lines
13 KiB
Luau
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
|