984 lines
30 KiB
Luau
984 lines
30 KiB
Luau
--!nonstrict
|
|
-- Headless [[service]] entry: monitors and manages DNF5 and Flatpak updates.
|
|
-- Publishes state updates to noctalia.state so the bar widget and panel stay synchronized.
|
|
|
|
local checkInterval = noctalia.getConfig("check_interval") or 30
|
|
local enableDnf = noctalia.getConfig("enable_dnf") ~= false
|
|
local enableFlatpak = noctalia.getConfig("enable_flatpak") ~= false
|
|
local notifyOnUpdates = noctalia.getConfig("notify_on_updates") ~= false
|
|
local updateInTerminal = noctalia.getConfig("update_in_terminal") ~= false
|
|
local includeFlatpakRuntimes = noctalia.getConfig("include_flatpak_runtimes") == true
|
|
|
|
local state = {
|
|
status = "IDLE", -- "IDLE" | "CHECKING" | "UPDATING" | "SUCCESS" | "ERROR"
|
|
total_count = 0,
|
|
total_download_bytes = 0,
|
|
total_install_bytes = 0,
|
|
total_download_formatted = "0 B",
|
|
total_install_formatted = "0 B",
|
|
dnf = {
|
|
count = 0,
|
|
packages = {},
|
|
total_download_bytes = 0,
|
|
total_install_bytes = 0,
|
|
total_download_formatted = "0 B",
|
|
total_install_formatted = "0 B",
|
|
status = "idle", -- "idle" | "checking" | "updating" | "done" | "error"
|
|
error = nil,
|
|
},
|
|
flatpak = {
|
|
count = 0,
|
|
packages = {},
|
|
total_download_bytes = 0,
|
|
total_install_bytes = 0,
|
|
total_download_formatted = "0 B",
|
|
total_install_formatted = "0 B",
|
|
status = "idle", -- "idle" | "checking" | "updating" | "done" | "error"
|
|
error = nil,
|
|
},
|
|
last_check = nil,
|
|
last_check_timestamp = 0,
|
|
last_update_timestamp = 0,
|
|
reboot_required = false,
|
|
error_message = nil,
|
|
}
|
|
|
|
local previousCount = 0
|
|
local isChecking = false
|
|
local isUpdating = false
|
|
|
|
local function formatBytes(bytes)
|
|
if not bytes or bytes <= 0 then
|
|
return "0 B"
|
|
end
|
|
local units = { "B", "KiB", "MiB", "GiB", "TiB" }
|
|
local b = tonumber(bytes) or 0
|
|
local uIndex = 1
|
|
while b >= 1024 and uIndex < #units do
|
|
b = b / 1024
|
|
uIndex = uIndex + 1
|
|
end
|
|
if uIndex == 1 then
|
|
return string.format("%d %s", b, units[uIndex])
|
|
else
|
|
return string.format("%.1f %s", b, units[uIndex])
|
|
end
|
|
end
|
|
|
|
local function parseSizeToBytes(sizeStr)
|
|
if not sizeStr or type(sizeStr) ~= "string" or sizeStr == "" then
|
|
return 0
|
|
end
|
|
local s = sizeStr:gsub(",", ".")
|
|
local numStr, unit = s:match("([%d%.]+).-([%a]+)")
|
|
if not numStr or not unit then
|
|
return 0
|
|
end
|
|
local num = tonumber(numStr)
|
|
if not num or num <= 0 then
|
|
return 0
|
|
end
|
|
unit = string.upper(unit)
|
|
if unit == "B" or unit == "BYTES" then
|
|
return math.floor(num)
|
|
elseif unit == "KIB" then
|
|
return math.floor(num * 1024)
|
|
elseif unit == "KB" or unit == "K" then
|
|
return math.floor(num * 1000)
|
|
elseif unit == "MIB" then
|
|
return math.floor(num * 1024 * 1024)
|
|
elseif unit == "MB" or unit == "M" then
|
|
return math.floor(num * 1000 * 1000)
|
|
elseif unit == "GIB" then
|
|
return math.floor(num * 1024 * 1024 * 1024)
|
|
elseif unit == "GB" or unit == "G" then
|
|
return math.floor(num * 1000 * 1000 * 1000)
|
|
elseif unit == "TIB" then
|
|
return math.floor(num * 1024 * 1024 * 1024 * 1024)
|
|
elseif unit == "TB" or unit == "T" then
|
|
return math.floor(num * 1000 * 1000 * 1000 * 1000)
|
|
end
|
|
return math.floor(num)
|
|
end
|
|
|
|
-- Persisted so a shell restart shows the last result instead of re-checking: with a
|
|
-- daily/weekly interval, checking on every login defeated the point.
|
|
local function stateFile()
|
|
local dir = noctalia.pluginDataDir()
|
|
return dir and (dir .. "/state.json") or nil
|
|
end
|
|
|
|
local function saveState()
|
|
local path = stateFile()
|
|
local encoded = path and noctalia.json.encode(state)
|
|
if encoded then
|
|
noctalia.writeFile(path, encoded)
|
|
end
|
|
end
|
|
|
|
local function loadState()
|
|
local path = stateFile()
|
|
if not path or not noctalia.fileExists(path) then
|
|
return false
|
|
end
|
|
local contents = noctalia.readFile(path)
|
|
local saved = contents and noctalia.json.decode(contents)
|
|
if type(saved) ~= "table" or type(saved.last_check_timestamp) ~= "number" then
|
|
return false
|
|
end
|
|
state = saved
|
|
state.status = state.error_message and "ERROR" or (state.total_count == 0 and "SUCCESS" or "IDLE")
|
|
state.reboot_required = saved.reboot_required == true
|
|
previousCount = state.total_count or 0
|
|
return true
|
|
end
|
|
|
|
local function checkDue()
|
|
return checkInterval > 0 and os.time() - (state.last_check_timestamp or 0) >= checkInterval * 60
|
|
end
|
|
|
|
local function publishState()
|
|
noctalia.state.set("update_state", state)
|
|
end
|
|
|
|
local function getDnfBinary()
|
|
if noctalia.commandExists("dnf5") then
|
|
return "dnf5"
|
|
elseif noctalia.commandExists("dnf") then
|
|
return "dnf"
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function isValidPackageName(name)
|
|
if type(name) ~= "string" or name == "" then
|
|
return false
|
|
end
|
|
if name:sub(1, 1) == "-" then
|
|
return false
|
|
end
|
|
return name:match("^[%w%._+%-]+$") ~= nil
|
|
end
|
|
|
|
local function isValidFlatpakId(id)
|
|
if type(id) ~= "string" or id == "" then
|
|
return false
|
|
end
|
|
if id:sub(1, 1) == "-" then
|
|
return false
|
|
end
|
|
return id:match("^[%w%._%-%/]+$") ~= nil
|
|
end
|
|
|
|
local function shellQuote(str)
|
|
return "'" .. string.gsub(str, "'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function checkDnf(onFinished, isRetry)
|
|
if not enableDnf then
|
|
state.dnf.count = 0
|
|
state.dnf.packages = {}
|
|
state.dnf.total_download_bytes = 0
|
|
state.dnf.total_install_bytes = 0
|
|
state.dnf.total_download_formatted = "0 B"
|
|
state.dnf.total_install_formatted = "0 B"
|
|
state.dnf.status = "idle"
|
|
state.dnf.error = nil
|
|
onFinished(true)
|
|
return
|
|
end
|
|
|
|
local dnfBin = getDnfBinary()
|
|
if not dnfBin then
|
|
state.dnf.count = 0
|
|
state.dnf.packages = {}
|
|
state.dnf.total_download_bytes = 0
|
|
state.dnf.total_install_bytes = 0
|
|
state.dnf.total_download_formatted = "0 B"
|
|
state.dnf.total_install_formatted = "0 B"
|
|
state.dnf.status = "error"
|
|
state.dnf.error = "DNF package manager not found"
|
|
onFinished(false)
|
|
return
|
|
end
|
|
|
|
state.dnf.status = "checking"
|
|
state.dnf.error = nil
|
|
|
|
noctalia.runAsync({ dnfBin, "check-upgrade", "--json" }, function(result)
|
|
if result.timedOut then
|
|
if not isRetry then
|
|
print("[dnf-updater] DNF check timed out; retrying once...")
|
|
checkDnf(onFinished, true)
|
|
return
|
|
end
|
|
state.dnf.status = "error"
|
|
state.dnf.error = "DNF check timed out"
|
|
onFinished(false)
|
|
return
|
|
end
|
|
|
|
local raw = noctalia.string.trim(result.stdout)
|
|
if raw == "" then
|
|
state.dnf.count = 0
|
|
state.dnf.packages = {}
|
|
state.dnf.total_download_bytes = 0
|
|
state.dnf.total_install_bytes = 0
|
|
state.dnf.total_download_formatted = "0 B"
|
|
state.dnf.total_install_formatted = "0 B"
|
|
state.dnf.status = "done"
|
|
state.dnf.error = nil
|
|
onFinished(true)
|
|
return
|
|
end
|
|
|
|
local decoded, err = noctalia.json.decode(raw)
|
|
if type(decoded) == "table" and type(decoded.upgrades) == "table" then
|
|
local packages = {}
|
|
for _, item in ipairs(decoded.upgrades) do
|
|
if type(item) == "table" and item.name then
|
|
table.insert(packages, {
|
|
name = item.name,
|
|
evr = item.evr or "",
|
|
arch = item.arch or "",
|
|
repo = item.repository or "",
|
|
download_bytes = 0,
|
|
install_bytes = 0,
|
|
download_formatted = "",
|
|
install_formatted = "",
|
|
})
|
|
end
|
|
end
|
|
state.dnf.count = #packages
|
|
state.dnf.packages = packages
|
|
|
|
if #packages > 0 then
|
|
-- Query individual package download and install sizes
|
|
noctalia.runAsync({ dnfBin, "repoquery", "--upgrades", "--queryformat", "%{name}\t%{downloadsize}\t%{installsize}\n" }, function(resSizes)
|
|
local dnfTotalDownload = 0
|
|
local dnfTotalInstall = 0
|
|
if resSizes.exitCode == 0 and resSizes.stdout and resSizes.stdout ~= "" then
|
|
local sizeMap = {}
|
|
for line in resSizes.stdout:gmatch("[^\r\n]+") do
|
|
local name, dl, inst = line:match("([^\t]+)\t([^\t]+)\t([^\t]+)")
|
|
if name then
|
|
sizeMap[name] = {
|
|
dl = tonumber(dl) or 0,
|
|
inst = tonumber(inst) or 0,
|
|
}
|
|
end
|
|
end
|
|
|
|
for _, pkg in ipairs(packages) do
|
|
local s = sizeMap[pkg.name]
|
|
if s then
|
|
pkg.download_bytes = s.dl
|
|
pkg.install_bytes = s.inst
|
|
pkg.download_formatted = formatBytes(s.dl)
|
|
pkg.install_formatted = formatBytes(s.inst)
|
|
dnfTotalDownload += s.dl
|
|
dnfTotalInstall += s.inst
|
|
end
|
|
end
|
|
end
|
|
|
|
state.dnf.total_download_bytes = dnfTotalDownload
|
|
state.dnf.total_install_bytes = dnfTotalInstall
|
|
state.dnf.total_download_formatted = formatBytes(dnfTotalDownload)
|
|
state.dnf.total_install_formatted = formatBytes(dnfTotalInstall)
|
|
state.dnf.status = "done"
|
|
state.dnf.error = nil
|
|
onFinished(true)
|
|
end, 30000)
|
|
else
|
|
state.dnf.total_download_bytes = 0
|
|
state.dnf.total_install_bytes = 0
|
|
state.dnf.total_download_formatted = "0 B"
|
|
state.dnf.total_install_formatted = "0 B"
|
|
state.dnf.status = "done"
|
|
state.dnf.error = nil
|
|
onFinished(true)
|
|
end
|
|
else
|
|
-- If JSON parsing failed or command returned non-standard error
|
|
if result.exitCode ~= 0 and result.exitCode ~= 100 then
|
|
state.dnf.status = "error"
|
|
state.dnf.error = result.stderr ~= "" and result.stderr or (err or "DNF check failed")
|
|
onFinished(false)
|
|
else
|
|
state.dnf.count = 0
|
|
state.dnf.packages = {}
|
|
state.dnf.total_download_bytes = 0
|
|
state.dnf.total_install_bytes = 0
|
|
state.dnf.total_download_formatted = "0 B"
|
|
state.dnf.total_install_formatted = "0 B"
|
|
state.dnf.status = "done"
|
|
state.dnf.error = nil
|
|
onFinished(true)
|
|
end
|
|
end
|
|
end, 30000)
|
|
end
|
|
|
|
local function checkFlatpak(onFinished, isRetry)
|
|
if not enableFlatpak then
|
|
state.flatpak.count = 0
|
|
state.flatpak.packages = {}
|
|
state.flatpak.total_download_bytes = 0
|
|
state.flatpak.total_install_bytes = 0
|
|
state.flatpak.total_download_formatted = "0 B"
|
|
state.flatpak.total_install_formatted = "0 B"
|
|
state.flatpak.status = "idle"
|
|
state.flatpak.error = nil
|
|
onFinished(true)
|
|
return
|
|
end
|
|
|
|
if not noctalia.commandExists("flatpak") then
|
|
state.flatpak.count = 0
|
|
state.flatpak.packages = {}
|
|
state.flatpak.total_download_bytes = 0
|
|
state.flatpak.total_install_bytes = 0
|
|
state.flatpak.total_download_formatted = "0 B"
|
|
state.flatpak.total_install_formatted = "0 B"
|
|
state.flatpak.status = "error"
|
|
state.flatpak.error = "Flatpak not installed"
|
|
onFinished(false)
|
|
return
|
|
end
|
|
|
|
state.flatpak.status = "checking"
|
|
state.flatpak.error = nil
|
|
|
|
local flatpakArgs = { "flatpak", "remote-ls", "--updates", "--columns=name,application,version,branch,origin,installed-size,download-size,ref", "-j" }
|
|
if not includeFlatpakRuntimes then
|
|
table.insert(flatpakArgs, 4, "--app")
|
|
end
|
|
|
|
noctalia.runAsync(flatpakArgs, function(result)
|
|
if result.timedOut then
|
|
if not isRetry then
|
|
print("[dnf-updater] Flatpak check timed out; retrying once...")
|
|
checkFlatpak(onFinished, true)
|
|
return
|
|
end
|
|
state.flatpak.status = "error"
|
|
state.flatpak.error = "Flatpak check timed out"
|
|
onFinished(false)
|
|
return
|
|
end
|
|
|
|
local raw = noctalia.string.trim(result.stdout)
|
|
if raw == "" then
|
|
state.flatpak.count = 0
|
|
state.flatpak.packages = {}
|
|
state.flatpak.total_download_bytes = 0
|
|
state.flatpak.total_install_bytes = 0
|
|
state.flatpak.total_download_formatted = "0 B"
|
|
state.flatpak.total_install_formatted = "0 B"
|
|
state.flatpak.status = "done"
|
|
state.flatpak.error = nil
|
|
onFinished(true)
|
|
return
|
|
end
|
|
|
|
local decoded, err = noctalia.json.decode(raw)
|
|
if type(decoded) == "table" then
|
|
local packages = {}
|
|
local flatpakTotalDownload = 0
|
|
local flatpakTotalInstall = 0
|
|
for _, item in ipairs(decoded) do
|
|
if type(item) == "table" then
|
|
local dlBytes = parseSizeToBytes(item.download_size)
|
|
local instBytes = parseSizeToBytes(item.installed_size)
|
|
flatpakTotalDownload += dlBytes
|
|
flatpakTotalInstall += instBytes
|
|
|
|
local isRuntime = (item.ref and item.ref:find("^runtime/") ~= nil) or false
|
|
table.insert(packages, {
|
|
name = item.name or (item.application_id or "Unknown"),
|
|
id = (item.application_id and item.application_id ~= "") and item.application_id or (item.ref or (item.name or "")),
|
|
ref = item.ref or "",
|
|
version = item.version or (item.branch or ""),
|
|
branch = item.branch or "",
|
|
origin = item.origin or "",
|
|
is_runtime = isRuntime,
|
|
download_bytes = dlBytes,
|
|
install_bytes = instBytes,
|
|
download_formatted = (item.download_size and item.download_size ~= "") and item.download_size or formatBytes(dlBytes),
|
|
install_formatted = (item.installed_size and item.installed_size ~= "") and item.installed_size or formatBytes(instBytes),
|
|
})
|
|
end
|
|
end
|
|
state.flatpak.count = #packages
|
|
state.flatpak.packages = packages
|
|
state.flatpak.total_download_bytes = flatpakTotalDownload
|
|
state.flatpak.total_install_bytes = flatpakTotalInstall
|
|
state.flatpak.total_download_formatted = formatBytes(flatpakTotalDownload)
|
|
state.flatpak.total_install_formatted = formatBytes(flatpakTotalInstall)
|
|
state.flatpak.status = "done"
|
|
state.flatpak.error = nil
|
|
onFinished(true)
|
|
else
|
|
if result.exitCode ~= 0 then
|
|
state.flatpak.status = "error"
|
|
state.flatpak.error = result.stderr ~= "" and result.stderr or (err or "Flatpak check failed")
|
|
onFinished(false)
|
|
else
|
|
state.flatpak.count = 0
|
|
state.flatpak.packages = {}
|
|
state.flatpak.total_download_bytes = 0
|
|
state.flatpak.total_install_bytes = 0
|
|
state.flatpak.total_download_formatted = "0 B"
|
|
state.flatpak.total_install_formatted = "0 B"
|
|
state.flatpak.status = "done"
|
|
state.flatpak.error = nil
|
|
onFinished(true)
|
|
end
|
|
end
|
|
end, 30000)
|
|
end
|
|
|
|
-- Read from dnf history rather than recorded on our own runs: terminal updates are
|
|
-- fire-and-forget, and a manual `dnf upgrade` counts too. Only an upgrade with no
|
|
-- package args is a full update; Flatpak keeps no history, so it is not tracked.
|
|
local function isFullUpgrade(commandLine)
|
|
local verbSeen = false
|
|
for word in (commandLine or ""):gmatch("%S+") do
|
|
if verbSeen then
|
|
if word:sub(1, 1) ~= "-" then
|
|
return false
|
|
end
|
|
elseif word == "upgrade" or word == "update" or word == "distro-sync" then
|
|
verbSeen = true
|
|
end
|
|
end
|
|
return verbSeen
|
|
end
|
|
|
|
local function refreshLastUpdate()
|
|
local dnfBin = getDnfBinary()
|
|
if not dnfBin then
|
|
return
|
|
end
|
|
noctalia.runAsync({ dnfBin, "history", "list", "--json" }, function(result)
|
|
local transactions = result and result.exitCode == 0 and noctalia.json.decode(result.stdout or "")
|
|
if type(transactions) ~= "table" then
|
|
return
|
|
end
|
|
local latest = 0
|
|
for _, t in ipairs(transactions) do
|
|
if t.status == "Ok" and (t.end_time or 0) > latest and isFullUpgrade(t.command_line) then
|
|
latest = t.end_time
|
|
end
|
|
end
|
|
if latest > 0 and latest ~= state.last_update_timestamp then
|
|
state.last_update_timestamp = latest
|
|
publishState()
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function checkReboot()
|
|
if noctalia.fileExists("/run/reboot-required") or noctalia.fileExists("/var/run/reboot-required") then
|
|
if not state.reboot_required then
|
|
state.reboot_required = true
|
|
publishState()
|
|
end
|
|
return
|
|
end
|
|
|
|
local dnfBin = getDnfBinary()
|
|
if not dnfBin then
|
|
return
|
|
end
|
|
|
|
noctalia.runAsync({ dnfBin, "--disablerepo=*", "needs-restarting", "-r", "--json" }, function(result)
|
|
local isReboot = false
|
|
if result then
|
|
if result.exitCode == 1 then
|
|
isReboot = true
|
|
elseif result.exitCode == 0 and result.stdout and result.stdout ~= "" then
|
|
local data = noctalia.json.decode(result.stdout)
|
|
if type(data) == "table" and data[1] and data[1].reboot_required == true then
|
|
isReboot = true
|
|
end
|
|
end
|
|
end
|
|
if isReboot ~= state.reboot_required then
|
|
state.reboot_required = isReboot
|
|
publishState()
|
|
end
|
|
end, 10000)
|
|
end
|
|
|
|
local function checkAll(force)
|
|
if isChecking or isUpdating then
|
|
return
|
|
end
|
|
|
|
isChecking = true
|
|
state.status = "CHECKING"
|
|
publishState()
|
|
refreshLastUpdate()
|
|
checkReboot()
|
|
|
|
local dnfDone = false
|
|
local flatpakDone = false
|
|
local dnfSuccess = false
|
|
local flatpakSuccess = false
|
|
|
|
local function finalize()
|
|
if not dnfDone or not flatpakDone then
|
|
return
|
|
end
|
|
|
|
isChecking = false
|
|
state.total_count = state.dnf.count + state.flatpak.count
|
|
state.total_download_bytes = (state.dnf.total_download_bytes or 0) + (state.flatpak.total_download_bytes or 0)
|
|
state.total_install_bytes = (state.dnf.total_install_bytes or 0) + (state.flatpak.total_install_bytes or 0)
|
|
state.total_download_formatted = formatBytes(state.total_download_bytes)
|
|
state.total_install_formatted = formatBytes(state.total_install_bytes)
|
|
state.last_check = noctalia.formatTime("%d/%m %H:%M")
|
|
state.last_check_timestamp = os.time()
|
|
|
|
if state.dnf.error and state.flatpak.error then
|
|
state.status = "ERROR"
|
|
state.error_message = state.dnf.error .. " | " .. state.flatpak.error
|
|
elseif state.dnf.error then
|
|
state.status = "ERROR"
|
|
state.error_message = state.dnf.error
|
|
elseif state.flatpak.error then
|
|
state.status = "ERROR"
|
|
state.error_message = state.flatpak.error
|
|
else
|
|
state.status = state.total_count == 0 and "SUCCESS" or "IDLE"
|
|
state.error_message = nil
|
|
end
|
|
|
|
-- Send notification when new updates are detected
|
|
if notifyOnUpdates and state.total_count > previousCount and state.total_count > 0 then
|
|
noctalia.notify(
|
|
noctalia.tr("notifications.updates_available_title"),
|
|
noctalia.trp("notifications.updates_available_body", state.total_count, { count = state.total_count })
|
|
)
|
|
end
|
|
previousCount = state.total_count
|
|
|
|
publishState()
|
|
saveState()
|
|
end
|
|
|
|
checkDnf(function(ok)
|
|
dnfDone = true
|
|
dnfSuccess = ok
|
|
finalize()
|
|
end)
|
|
|
|
checkFlatpak(function(ok)
|
|
flatpakDone = true
|
|
flatpakSuccess = ok
|
|
finalize()
|
|
end)
|
|
end
|
|
|
|
-- runInTerminal is fire-and-forget, so the terminal itself pings us back when done
|
|
-- (`;`, not `&&`: a failed or partial run still changes what is pending).
|
|
local function runInTerminalThenRecheck(cmd)
|
|
noctalia.runInTerminal(cmd .. "; noctalia msg plugin strob3/dnf-updater:service all check")
|
|
end
|
|
|
|
local function updateDnf(excludedList, onFinished)
|
|
local dnfBin = getDnfBinary()
|
|
if not dnfBin then
|
|
state.dnf.status = "error"
|
|
state.dnf.error = "DNF not found"
|
|
if onFinished then onFinished(false) end
|
|
return
|
|
end
|
|
|
|
local validExcluded = {}
|
|
if excludedList and type(excludedList) == "table" then
|
|
for _, name in ipairs(excludedList) do
|
|
if isValidPackageName(name) then
|
|
table.insert(validExcluded, name)
|
|
else
|
|
print(string.format("[dnf-updater] Warning: invalid DNF package name skipped: %s", tostring(name)))
|
|
end
|
|
end
|
|
end
|
|
|
|
local dnfCount = state.dnf.count or 0
|
|
if excludedList and type(excludedList) == "table" and #validExcluded >= dnfCount and dnfCount > 0 then
|
|
state.dnf.status = "done"
|
|
publishState()
|
|
if onFinished then onFinished(true) end
|
|
return
|
|
end
|
|
|
|
local excludeArg = nil
|
|
if #validExcluded > 0 then
|
|
excludeArg = "--exclude=" .. table.concat(validExcluded, ",")
|
|
end
|
|
|
|
if updateInTerminal then
|
|
local sudoCmd = noctalia.commandExists("sudo") and "sudo" or "pkexec"
|
|
local cmd = sudoCmd .. " " .. dnfBin .. " upgrade -y"
|
|
if excludeArg then
|
|
cmd = cmd .. " " .. shellQuote(excludeArg)
|
|
end
|
|
runInTerminalThenRecheck(cmd)
|
|
state.dnf.status = "done"
|
|
publishState()
|
|
if onFinished then onFinished(true) end
|
|
return
|
|
end
|
|
|
|
state.dnf.status = "updating"
|
|
state.status = "UPDATING"
|
|
publishState()
|
|
|
|
local helperBin = "/usr/local/bin/dnf-updater-helper"
|
|
local args
|
|
if noctalia.fileExists(helperBin) then
|
|
args = { "pkexec", helperBin, "upgrade" }
|
|
else
|
|
args = { "pkexec", dnfBin, "upgrade", "-y" }
|
|
end
|
|
if excludeArg then
|
|
table.insert(args, excludeArg)
|
|
end
|
|
|
|
noctalia.runAsync(args, function(result)
|
|
if result.exitCode == 0 then
|
|
state.dnf.status = "done"
|
|
state.dnf.error = nil
|
|
if onFinished then onFinished(true) end
|
|
else
|
|
state.dnf.status = "error"
|
|
local errMsg = "DNF upgrade failed"
|
|
if result.timedOut then
|
|
errMsg = "DNF upgrade timed out (consider running updates in terminal)"
|
|
elseif result.stderr and result.stderr ~= "" then
|
|
errMsg = result.stderr
|
|
elseif result.stdout and result.stdout ~= "" then
|
|
errMsg = result.stdout
|
|
end
|
|
state.dnf.error = errMsg
|
|
if onFinished then onFinished(false) end
|
|
end
|
|
publishState()
|
|
end, 600000)
|
|
end
|
|
|
|
local function updateFlatpak(selectedList, onFinished)
|
|
if not noctalia.commandExists("flatpak") then
|
|
state.flatpak.status = "error"
|
|
state.flatpak.error = "Flatpak not found"
|
|
if onFinished then onFinished(false) end
|
|
return
|
|
end
|
|
|
|
local validSelected = nil
|
|
if selectedList and type(selectedList) == "table" then
|
|
validSelected = {}
|
|
local seen = {}
|
|
for _, ref in ipairs(selectedList) do
|
|
if isValidFlatpakId(ref) and not seen[ref] then
|
|
seen[ref] = true
|
|
table.insert(validSelected, ref)
|
|
elseif not isValidFlatpakId(ref) then
|
|
print(string.format("[dnf-updater] Warning: invalid Flatpak ID skipped: %s", tostring(ref)))
|
|
end
|
|
end
|
|
end
|
|
|
|
if selectedList and type(selectedList) == "table" and #selectedList == 0 and (state.flatpak.count or 0) > 0 then
|
|
state.flatpak.status = "done"
|
|
publishState()
|
|
if onFinished then onFinished(true) end
|
|
return
|
|
end
|
|
|
|
if selectedList and type(selectedList) == "table" and #selectedList > 0 and validSelected and #validSelected == 0 then
|
|
print("[dnf-updater] Warning: all selected Flatpak IDs were invalid; skipping update")
|
|
state.flatpak.status = "done"
|
|
publishState()
|
|
if onFinished then onFinished(true) end
|
|
return
|
|
end
|
|
|
|
local flatpakCount = (state.flatpak and state.flatpak.count) or 0
|
|
local hasSubset = validSelected and #validSelected > 0 and #validSelected < flatpakCount
|
|
|
|
if updateInTerminal then
|
|
local cmd = "flatpak update -y"
|
|
if hasSubset then
|
|
for _, ref in ipairs(validSelected) do
|
|
cmd = cmd .. " " .. shellQuote(ref)
|
|
end
|
|
end
|
|
runInTerminalThenRecheck(cmd)
|
|
state.flatpak.status = "done"
|
|
publishState()
|
|
if onFinished then onFinished(true) end
|
|
return
|
|
end
|
|
|
|
state.flatpak.status = "updating"
|
|
state.status = "UPDATING"
|
|
publishState()
|
|
|
|
local args = { "flatpak", "update", "-y", "--noninteractive" }
|
|
if hasSubset then
|
|
for _, ref in ipairs(validSelected) do
|
|
table.insert(args, ref)
|
|
end
|
|
end
|
|
|
|
noctalia.runAsync(args, function(result)
|
|
if result.exitCode == 0 then
|
|
state.flatpak.status = "done"
|
|
state.flatpak.error = nil
|
|
if onFinished then onFinished(true) end
|
|
else
|
|
state.flatpak.status = "error"
|
|
local errMsg = "Flatpak update failed"
|
|
if result.timedOut then
|
|
errMsg = "Flatpak update timed out (consider running updates in terminal)"
|
|
elseif result.stderr and result.stderr ~= "" then
|
|
errMsg = result.stderr
|
|
elseif result.stdout and result.stdout ~= "" then
|
|
errMsg = result.stdout
|
|
end
|
|
state.flatpak.error = errMsg
|
|
if onFinished then onFinished(false) end
|
|
end
|
|
publishState()
|
|
end, 600000)
|
|
end
|
|
|
|
local function updateAll(excludedDnf, selectedFlatpaks)
|
|
if isUpdating or isChecking then
|
|
return
|
|
end
|
|
|
|
isUpdating = true
|
|
state.status = "UPDATING"
|
|
|
|
local dnfCount = (state.dnf and state.dnf.count) or 0
|
|
local flatpakCount = (state.flatpak and state.flatpak.count) or 0
|
|
|
|
local validExcluded = {}
|
|
if excludedDnf and type(excludedDnf) == "table" then
|
|
for _, name in ipairs(excludedDnf) do
|
|
if isValidPackageName(name) then
|
|
table.insert(validExcluded, name)
|
|
end
|
|
end
|
|
end
|
|
local dnfNeedsUpdate = enableDnf and dnfCount > 0 and (#validExcluded < dnfCount)
|
|
|
|
local validSelected = nil
|
|
if selectedFlatpaks and type(selectedFlatpaks) == "table" then
|
|
validSelected = {}
|
|
local seen = {}
|
|
for _, ref in ipairs(selectedFlatpaks) do
|
|
if isValidFlatpakId(ref) and not seen[ref] then
|
|
seen[ref] = true
|
|
table.insert(validSelected, ref)
|
|
end
|
|
end
|
|
end
|
|
local flatpakNeedsUpdate = enableFlatpak and flatpakCount > 0
|
|
if flatpakNeedsUpdate and selectedFlatpaks and type(selectedFlatpaks) == "table" and #validSelected == 0 then
|
|
flatpakNeedsUpdate = false
|
|
end
|
|
|
|
if not dnfNeedsUpdate and not flatpakNeedsUpdate then
|
|
isUpdating = false
|
|
checkAll(true)
|
|
return
|
|
end
|
|
|
|
if updateInTerminal then
|
|
local cmds = {}
|
|
local dnfBin = getDnfBinary()
|
|
|
|
if dnfNeedsUpdate and dnfBin then
|
|
local sudoCmd = noctalia.commandExists("sudo") and "sudo" or "pkexec"
|
|
local dnfCmd = sudoCmd .. " " .. dnfBin .. " upgrade -y"
|
|
if #validExcluded > 0 then
|
|
dnfCmd = dnfCmd .. " " .. shellQuote("--exclude=" .. table.concat(validExcluded, ","))
|
|
end
|
|
table.insert(cmds, dnfCmd)
|
|
end
|
|
|
|
if flatpakNeedsUpdate then
|
|
local fpCmd = "flatpak update -y"
|
|
if validSelected and #validSelected > 0 and #validSelected < flatpakCount then
|
|
for _, ref in ipairs(validSelected) do
|
|
fpCmd = fpCmd .. " " .. shellQuote(ref)
|
|
end
|
|
end
|
|
table.insert(cmds, fpCmd)
|
|
end
|
|
|
|
if #cmds > 0 then
|
|
local fullCmd = table.concat(cmds, " && ")
|
|
runInTerminalThenRecheck(fullCmd)
|
|
if dnfNeedsUpdate then state.dnf.status = "done" end
|
|
if flatpakNeedsUpdate then state.flatpak.status = "done" end
|
|
state.status = "SUCCESS"
|
|
publishState()
|
|
end
|
|
|
|
isUpdating = false
|
|
return
|
|
end
|
|
|
|
if dnfNeedsUpdate and flatpakNeedsUpdate then
|
|
state.dnf.status = "updating"
|
|
state.flatpak.status = "waiting"
|
|
publishState()
|
|
|
|
updateDnf(excludedDnf, function(dnfOk)
|
|
state.flatpak.status = "updating"
|
|
publishState()
|
|
|
|
updateFlatpak(selectedFlatpaks, function(flatpakOk)
|
|
isUpdating = false
|
|
if dnfOk and flatpakOk then
|
|
noctalia.notify(
|
|
noctalia.tr("notifications.update_complete_title"),
|
|
noctalia.tr("notifications.update_complete_body")
|
|
)
|
|
else
|
|
noctalia.notifyError(
|
|
noctalia.tr("notifications.update_failed_title"),
|
|
noctalia.tr("notifications.update_failed_body")
|
|
)
|
|
end
|
|
checkAll(true)
|
|
end)
|
|
end)
|
|
elseif dnfNeedsUpdate then
|
|
state.dnf.status = "updating"
|
|
publishState()
|
|
updateDnf(excludedDnf, function(ok)
|
|
isUpdating = false
|
|
if ok then
|
|
noctalia.notify(
|
|
noctalia.tr("notifications.update_complete_title"),
|
|
noctalia.tr("notifications.update_complete_body")
|
|
)
|
|
else
|
|
noctalia.notifyError(
|
|
noctalia.tr("notifications.update_failed_title"),
|
|
noctalia.tr("notifications.update_failed_body")
|
|
)
|
|
end
|
|
checkAll(true)
|
|
end)
|
|
elseif flatpakNeedsUpdate then
|
|
state.flatpak.status = "updating"
|
|
publishState()
|
|
updateFlatpak(selectedFlatpaks, function(ok)
|
|
isUpdating = false
|
|
if ok then
|
|
noctalia.notify(
|
|
noctalia.tr("notifications.update_complete_title"),
|
|
noctalia.tr("notifications.update_complete_body")
|
|
)
|
|
else
|
|
noctalia.notifyError(
|
|
noctalia.tr("notifications.update_failed_title"),
|
|
noctalia.tr("notifications.update_failed_body")
|
|
)
|
|
end
|
|
checkAll(true)
|
|
end)
|
|
end
|
|
end
|
|
|
|
-- Watch for commands from widget and panel
|
|
noctalia.state.watch("service_command", function(cmd)
|
|
if type(cmd) ~= "table" or not cmd.action then
|
|
return
|
|
end
|
|
|
|
if cmd.action == "check_now" then
|
|
checkAll(true)
|
|
elseif cmd.action == "update_all" then
|
|
updateAll(cmd.excluded_dnf, cmd.selected_flatpak)
|
|
elseif cmd.action == "update_dnf" then
|
|
if not isUpdating and not isChecking then
|
|
isUpdating = true
|
|
updateDnf(cmd.excluded_dnf, function()
|
|
isUpdating = false
|
|
checkAll(true)
|
|
end)
|
|
end
|
|
elseif cmd.action == "update_flatpak" then
|
|
if not isUpdating and not isChecking then
|
|
isUpdating = true
|
|
updateFlatpak(cmd.selected_flatpak, function()
|
|
isUpdating = false
|
|
checkAll(true)
|
|
end)
|
|
end
|
|
end
|
|
end)
|
|
|
|
function onConfigChanged()
|
|
checkInterval = noctalia.getConfig("check_interval") or 30
|
|
enableDnf = noctalia.getConfig("enable_dnf") ~= false
|
|
enableFlatpak = noctalia.getConfig("enable_flatpak") ~= false
|
|
includeFlatpakRuntimes = noctalia.getConfig("include_flatpak_runtimes") == true
|
|
notifyOnUpdates = noctalia.getConfig("notify_on_updates") ~= false
|
|
updateInTerminal = noctalia.getConfig("update_in_terminal") ~= false
|
|
if checkDue() then
|
|
checkAll(true)
|
|
end
|
|
end
|
|
|
|
function onIpc(event, payload)
|
|
local p = (type(payload) == "table") and payload or {}
|
|
if event == "check" or event == "check_now" then
|
|
checkAll(true)
|
|
elseif event == "update" or event == "update_all" then
|
|
updateAll(p.excluded_dnf, p.selected_flatpak)
|
|
elseif event == "update_dnf" then
|
|
if not isUpdating then
|
|
isUpdating = true
|
|
updateDnf(p.excluded_dnf, function()
|
|
isUpdating = false
|
|
checkAll(true)
|
|
end)
|
|
end
|
|
elseif event == "update_flatpak" then
|
|
if not isUpdating then
|
|
isUpdating = true
|
|
updateFlatpak(p.selected_flatpak, function()
|
|
isUpdating = false
|
|
checkAll(true)
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Timer tick: runs once every 60 seconds (60000ms)
|
|
noctalia.setUpdateInterval(60000)
|
|
|
|
function update()
|
|
if checkDue() then
|
|
checkAll(false)
|
|
end
|
|
end
|
|
|
|
local restored = loadState()
|
|
publishState()
|
|
if not restored or checkDue() then
|
|
checkAll(true)
|
|
end
|