Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions linux-updater/README.md

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions linux-updater/backends/apt.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
--!nonstrict
-- apt backend: native Debian-family support (Debian, Ubuntu, Mint, Pop!_OS,
-- Zorin, MX). Two constraints shape it:
--
-- 1. `apt-get update` needs root, so the unprivileged check reads the index
-- lists the system's own apt-daily timers keep fresh (a self-check nags
-- when those timers are off). The background run refreshes the lists
-- itself, under its own escalation, right before upgrading.
-- 2. apt has no per-run --exclude, so the plugin ignore list is applied by
-- holding the packages for the duration of the transaction
-- (apt-mark hold ... / unhold ...), the same tool a user would reach for.
--
-- debconf prompts need no DEBIAN_FRONTEND here: with no controlling tty it
-- falls back to its noninteractive frontend on its own, and the detached
-- runner never has one. Rollback is opportunistic, from the .deb files in
-- /var/cache/apt/archives: Debian keeps them by default, Ubuntu routinely
-- cleans them — the run probe greys out packages whose old file is gone,
-- so the panel never offers a rollback it cannot perform.

return function(env)
local MAX_LISTED = env.MAX_LISTED
local shellQuote = env.shellQuote

local backend = {
id = "apt",
caps = { bgUpdate = true, size = false, rollback = "cache", news = false, aur = false },
checkTool = "apt-get",
missingToolKey = "err_no_apt",
nativeLabelKey = "source.system",
escalateProgram = "/usr/bin/apt-get",
-- One "Unpacking name (version) ..." line per package; "Setting up"
-- would double-count the same set.
progressPattern = "^Unpacking ",
news = nil,
polkitRuleName = "49-linux-updater-apt.rules",
systemIgnoreLabelKey = "ignored_tag_hold",
}

backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update
* run instead of one per apt call). Authentication is kept for ~5 minutes,
* like sudo's timestamp. Scope: only pkexec launching apt-get or apt-mark,
* only for an active local session of a sudo/wheel member.
* Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-apt.rules
*/
polkit.addRule(function(action, subject) {
var prog = action.lookup("program");
if (action.id == "org.freedesktop.policykit.exec" &&
(prog == "/usr/bin/apt-get" || prog == "/usr/bin/apt-mark") &&
subject.active && subject.local &&
(subject.isInGroup("sudo") || subject.isInGroup("wheel"))) {
return polkit.Result.AUTH_ADMIN_KEEP;
}
});
]=]

-- `apt list --upgradable` needs no root and reports both versions:
-- name/suite newver arch [upgradable from: oldver]
-- Phased updates Ubuntu holds back are absent from this list — that is
-- deliberate on their side, not a parsing gap. Held packages carry
-- "[upgradable from: ...]" too and are filtered by the parser below via
-- the system hold list appended after a separator line.
-- LC_ALL=C: apt localizes the "[upgradable from: ...]" bracket the
-- parser keys on, so a non-English locale would count zero updates.
backend.checkCommand = [[LC_ALL=C apt list --upgradable 2>/dev/null; echo '::HOLDS'; apt-mark showhold 2>/dev/null]]

function backend.parseCheck(output, ignored, ignoredOut)
local items = {}
local n = 0
local holds = {}
local inHolds = false
for line in (output or ""):gmatch("[^\n]+") do
if line == "::HOLDS" then
inHolds = true
elseif inHolds then
holds[line] = true
end
end
for line in (output or ""):gmatch("[^\n]+") do
if line == "::HOLDS" then
break
end
local name, to, from = line:match("^([^/%s]+)/%S+%s+(%S+)%s+%S+%s+%[upgradable from: ([^%]]+)%]")
if name ~= nil then
if holds[name] then
table.insert(ignoredOut, { name = name, from = from, to = to, source = "system" })
elseif ignored[name] then
table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" })
else
n += 1
if #items < MAX_LISTED then
table.insert(items, { name = name, from = from, to = to })
end
end
end
end
return n, items
end

local UPGRADE_FLAGS = " -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade"

-- Two pkexec calls (update, then upgrade) instead of one pkexec'd shell:
-- the polkit rule can then stay scoped to apt-get/apt-mark, and with
-- keep-authorization one password still covers the run. The upgrade is
-- gated (&&) on the refresh and the holds: if placing a hold fails, the
-- upgrade must not run and update packages the user ignored. The unhold
-- cleanup stays unconditional.
function backend.buildBackgroundCommand(ignored)
if #ignored == 0 then
return "pkexec apt-get -qq update && pkexec apt-get" .. UPGRADE_FLAGS
end
local names = table.concat(ignored, " ")
return "pkexec apt-get -qq update && pkexec apt-mark hold " .. names
.. " && pkexec apt-get" .. UPGRADE_FLAGS
.. "; s=$?; pkexec apt-mark unhold " .. names .. "; exit $s"
end

function backend.buildTerminalCommand(ignored)
local upgrade = "sudo apt-get update && sudo apt-get full-upgrade"
if #ignored == 0 then
return upgrade
end
local names = table.concat(ignored, " ")
return "sudo apt-get update && sudo apt-mark hold " .. names
.. " && sudo apt-get full-upgrade; s=$?; sudo apt-mark unhold " .. names .. "; exit $s"
end

-- `apt-get -s` omits the "Need to get" line in simulate mode, so a
-- cheap size estimate is not available; capability off.
backend.sizeCommand = nil

-- "name version" per line for the given (quoted) names; the engine uses
-- it after an interactive terminal run to keep only the packages whose
-- installed version actually moved.
function backend.installedVersionsCommand(quotedNames)
return "dpkg-query -W -f '${Package} ${Version}\\n' " .. table.concat(quotedNames, " ") .. " 2>/dev/null"
end

backend.rebootCommand = [[test -f /var/run/reboot-required && echo missing || echo present]]

-- sh helper: prints the cached .deb for "name version". The filename
-- encodes the epoch colon as %3a ("1:2.0-1" -> "1%3a2.0-1"); the arch
-- part is globbed (amd64/arm64/all).
backend.findPkgSh = [[find_pkg() {
ver=$(printf '%s' "$2" | sed 's/:/%3a/g')
for f in /var/cache/apt/archives/"$1"_"$ver"_*.deb; do
if [ -f "$f" ]; then printf '%s\n' "$f"; return 0; fi
done
return 1
}]]

-- apt-get resolves dependencies for local .deb paths and refuses the
-- whole transaction if the downgrade would break other packages —
-- the same semantics the pacman cache path relies on.
backend.rollbackInstall = "pkexec apt-get install -y --allow-downgrades"
.. " -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"

-- Recursive installed-dependency names of a package, one per line, for
-- the run-mates resolution (virtual <names> unwrapped).
function backend.depsListCommand(name)
return "apt-cache depends --recurse --installed " .. shellQuote(name)
.. [[ 2>/dev/null | awk '/^ *(Pre)?Depends:/{gsub(/[<>]/,"",$2); print $2}']]
end

-- Installed reverse dependencies count for "$1"; the first two rdepends
-- lines are the package name and the "Reverse Depends:" header.
backend.reverseDepsCountSh = [[n=$(apt-cache rdepends --installed "$1" 2>/dev/null | tail -n +3 | wc -l)]]

-- The unprivileged check depends on the apt-daily timers keeping the
-- package lists fresh; offer to enable them when they are off.
backend.selfChecks = {
{
id = "apt_timers",
testCommand = [[systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1 && echo ok || echo fail]],
hintKey = "selfcheck_apt_timers",
actionKey = "selfcheck_fix",
tipKey = "selfcheck_apt_timers_tip",
fixCommand = "pkexec systemctl enable --now apt-daily.timer apt-daily-upgrade.timer",
},
}

return backend
end
158 changes: 158 additions & 0 deletions linux-updater/backends/dnf.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
--!nonstrict
-- dnf backend: native Fedora-family support. Chosen over the generic
-- PackageKit backend because dnf brings two things PackageKit cannot:
-- old-version info for the check list (via rpm) and a real transaction
-- rollback (`dnf history undo`), which is safer than any file-cache
-- approach — dnf reverses the exact transaction, dependencies included.
--
-- caps.rollback = "native": the engine records the dnf transaction id
-- after a successful run (txIdCommand/parseTxId) and undoes whole runs
-- with rollbackNativeCommand; per-package rollback is not offered.

return function(env)
local MAX_LISTED = env.MAX_LISTED

local backend = {
id = "dnf",
caps = { bgUpdate = true, size = false, rollback = "native", news = false, aur = false },
checkTool = "dnf",
missingToolKey = "err_no_dnf",
nativeLabelKey = "source.system",
escalateProgram = "/usr/bin/dnf",
-- dnf5: "[ 3/12] Upgrading name-..." (per package; the paired
-- "Removing" of the old version is deliberately not counted).
-- dnf4: " Upgrading : name-...". The summary header
-- ("Upgrading:" / " Upgrading: 5 packages") matches neither branch.
progressPattern = "^\\[ *[0-9]+/[0-9]+\\] (Upgrading|Installing|Downgrading|Reinstalling) |^ +(Upgrading|Installing|Downgrading|Reinstalling) +: ",
news = nil,
polkitRuleName = "49-linux-updater-dnf.rules",
}

backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update
* run instead of one per dnf transaction). Authentication is kept for
* ~5 minutes, like sudo's timestamp. Scope: only pkexec launching dnf,
* only for an active local session of a wheel member.
* Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-dnf.rules
*/
polkit.addRule(function(action, subject) {
var prog = action.lookup("program");
if (action.id == "org.freedesktop.policykit.exec" &&
(prog == "/usr/bin/dnf" || prog == "/usr/bin/dnf5" ||
prog == "/usr/bin/dnf-3") &&
subject.active && subject.local &&
subject.isInGroup("wheel")) {
return polkit.Result.AUTH_ADMIN_KEEP;
}
});
]=]

-- `dnf check-update` exits 100 when updates exist, 0 when none, 1 on a
-- real failure — and prints only the NEW version. The old one comes
-- from rpm, joined in the shell so Lua parses a small "name|old|new"
-- list instead of the whole rpm database.
backend.checkCommand = [[out=$(LC_ALL=C dnf -q check-update 2>/dev/null); code=$?
if [ "$code" -eq 0 ]; then exit 0; fi
if [ "$code" -ne 100 ]; then exit "$code"; fi
printf '%s\n' "$out" | awk 'NF>=3 && $1 ~ /\./ && $2 ~ /^[0-9]/ {print $1, $2}' | while read -r na ver; do
name=${na%.*}
old=$(rpm -q --qf '%{EVR}\n' "$name" 2>/dev/null | head -n 1)
printf '%s|%s|%s\n' "$name" "$old" "$ver"
done
exit 0]]

function backend.parseCheck(output, ignored, ignoredOut)
local items = {}
local n = 0
for line in (output or ""):gmatch("[^\n]+") do
local name, from, to = line:match("^([^|]+)|([^|]*)|(.+)$")
if name ~= nil and name ~= "" then
if ignored[name] then
table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" })
else
n += 1
if #items < MAX_LISTED then
table.insert(items, { name = name, from = from, to = to })
end
end
end
end
return n, items
end

local function excludeFlags(ignored)
local flags = ""
for _, name in ipairs(ignored) do
flags = flags .. " --exclude=" .. name
end
return flags
end

function backend.buildBackgroundCommand(ignored)
return "pkexec dnf -y --refresh upgrade" .. excludeFlags(ignored)
end

function backend.buildTerminalCommand(ignored)
return "sudo dnf --refresh upgrade" .. excludeFlags(ignored)
end

backend.sizeCommand = nil

-- "name version" per line for the given (quoted) names; the engine uses
-- it after an interactive terminal run to keep only the packages whose
-- installed version actually moved. %{EVR} matches the check's rpm side.
function backend.installedVersionsCommand(quotedNames)
return "rpm -q --qf '%{NAME} %{EVR}\\n' " .. table.concat(quotedNames, " ") .. " 2>/dev/null"
end

-- needs-restarting (dnf-utils) knows about services and libraries, not
-- just the kernel; fall back to the kernel-modules check without it.
backend.rebootCommand = [[if command -v needs-restarting >/dev/null 2>&1; then
needs-restarting -r >/dev/null 2>&1; [ "$?" -eq 1 ] && echo missing || echo present
else
test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing
fi]]

-- Newest transaction id, queried right after a successful run; the
-- history strip's whole-run rollback undoes exactly that transaction.
backend.txIdCommand = "LC_ALL=C dnf history list 2>/dev/null | head -n 6"

function backend.parseTxId(output)
for line in (output or ""):gmatch("[^\n]+") do
local id = line:match("^%s*(%d+)%s")
if id ~= nil then
return tonumber(id)
end
end
return nil
end

function backend.rollbackNativeCommand(txId)
return "pkexec dnf -y history undo " .. tostring(txId)
end

-- Per-package rollback: downgrade to the exact recorded version
-- (name-[epoch:]version-release). Only possible while that version is
-- still in an enabled repository — Fedora's base repo keeps the GA
-- version forever, but intermediate updates disappear.
function backend.rollbackItemCommand(item)
local name = tostring(item.name or "")
local from = tostring(item.from or "")
if name:match("^[%w._+-]+$") == nil or from:match("^[%w:._+~^-]+$") == nil then
return nil
end
return "pkexec dnf -y downgrade " .. env.shellQuote(name .. "-" .. from)
end

-- Availability probe for "$1" (name) "$2" (version), run when a history
-- run is opened: sets c=ok when the exact old version is still in the
-- repositories, c=miss otherwise, so the panel can grey the button and
-- say why BEFORE a doomed attempt. -C keeps it on the local metadata
-- cache — no network, and an unreadable cache degrades to miss.
backend.itemProbeSh = [[if [ -n "$(dnf -q -C repoquery --qf '%{name}' "$1-$2" 2>/dev/null | head -n 1)" ]; then c=ok; else c=miss; fi]]

-- Shown instead of the generic run-failure text when a per-package
-- rollback exits non-zero: the by-far-usual cause is worth naming.
backend.rollbackFailHintKey = "err_dnf_rollback_unavailable"

return backend
end
Loading