diff --git a/dnf-updater/CHANGELOG.md b/dnf-updater/CHANGELOG.md index 7e8f03cb2..be6e5b0ad 100644 --- a/dnf-updater/CHANGELOG.md +++ b/dnf-updater/CHANGELOG.md @@ -1,3 +1,18 @@ +## [1.2.0] - 2026-11-12 + +### Added +- **Disk Space Impact**: Tooltip now displays the real disk space added or freed by updates alongside the download size. +- **Last Updated Time**: Moved last update time from panel to tooltip. +- **Refresh Metadata Setting**: Added an optional `refresh_on_update` setting to refresh DNF cache before upgrading (#7). + +### Fixed +- **Replaced Packages Count**: Fixed packages that replace older versions (like `kernel-headers`) not showing up in the update list. +- **Multi-arch Packages**: Fixed size and selection issues when both 32-bit and 64-bit versions of a package are installed. +- **Faster Checks**: Update checks now use local cache directly, which finishes faster. +- **Cleaned Up Skipped Packages**: Unchecked packages are now reset when updates change instead of staying skipped forever. +- **Panel Header**: Removed redundant timestamps to cleanup panel. +- **Last Update Memory**: Fixed last update time being lost when restarting the shell without checking (#6). + ## [1.1.0] - 2026-10-26 ### Added diff --git a/dnf-updater/README.md b/dnf-updater/README.md index c0a3e8204..a04c0c4e4 100644 --- a/dnf-updater/README.md +++ b/dnf-updater/README.md @@ -1,7 +1,7 @@ # DNF Updater ![dnf-updater thumbnail](thumbnail.webp) -DNF Updater monitors and manages pending DNF5 and Flatpak updates directly from the Noctalia bar. It provides a lightweight update badge in the bar, per-package selection to skip or apply specific upgrades, package search filtering, reboot required detection, download size estimation, and safe execution through Polkit or an interactive terminal. +DNF Updater monitors and manages pending DNF5 and Flatpak updates directly from the Noctalia bar. It provides a lightweight update badge in the bar, per-package selection to skip or apply specific upgrades, package search filtering, reboot required detection, download size and disk impact estimation, and safe execution through Polkit or an interactive terminal. ## Plugin @@ -94,6 +94,7 @@ alias dnf-check="noctalia msg plugin strob3/dnf-updater:service service check" | `show_widget_when_clean` | `bool` | `true` | Show the widget icon in the bar even when system is up to date. | | `notify_on_updates` | `bool` | `true` | Send a desktop notification when new pending updates are found. | | `update_in_terminal` | `bool` | `true` | Launch update commands inside your terminal emulator instead of background Polkit execution. | +| `refresh_on_update` | `bool` | `false` | Pass `--refresh` to DNF on upgrade to force refreshing repository metadata cache. | | `glyph` | `glyph` | `package` | Widget icon glyph displayed in the bar. | ## IPC @@ -108,12 +109,5 @@ noctalia msg plugin strob3/dnf-updater:service service check noctalia msg plugin strob3/dnf-updater:service service update ``` -## Notes - -- **Privilege Separation**: Noctalia never runs as root. System upgrades invoke hardened helper script with explicit arguments via Polkit, or launch inside the user's terminal emulator when configured. -- **Selective Upgrades**: When packages are deselected, DNF5 is invoked with `--exclude=` arguments and Flatpak is invoked with only selected application IDs. -- **Background Checks**: Update queries use non-blocking JSON queries (`dnf5 check-upgrade --json` and `flatpak remote-ls --updates -j`) to prevent freezing the shell. -- **Reboot Detection**: Checks `dnf needs-restarting -r` to detect when core packages (kernel, systemd, glibc) require a system restart. - ## License [MIT](LICENSE) diff --git a/dnf-updater/panel.luau b/dnf-updater/panel.luau index 62b38da1d..6bbbb708b 100644 --- a/dnf-updater/panel.luau +++ b/dnf-updater/panel.luau @@ -103,6 +103,14 @@ local function getPackageDownloadBytes(pkg) return 0 end +local function getDnfKey(pkg) + if not pkg then return "" end + if pkg.name and pkg.arch and pkg.arch ~= "" then + return pkg.name .. "." .. pkg.arch + end + return pkg.name or "" +end + local function getFlatpakKey(pkg) if not pkg then return "" end if pkg.ref and pkg.ref ~= "" then return pkg.ref end @@ -110,11 +118,43 @@ local function getFlatpakKey(pkg) return pkg.name or "" end -local function toggleExcludeDnf(name) - if excludedDnf[name] then - excludedDnf[name] = nil +local function pruneExclusions(state) + if not state then return end + + if state.dnf and state.dnf.packages then + local currentDnfKeys = {} + for _, pkg in ipairs(state.dnf.packages) do + currentDnfKeys[getDnfKey(pkg)] = true + end + for k in pairs(excludedDnf) do + if not currentDnfKeys[k] then + excludedDnf[k] = nil + end + end else - excludedDnf[name] = true + excludedDnf = {} + end + + if state.flatpak and state.flatpak.packages then + local currentFpKeys = {} + for _, pkg in ipairs(state.flatpak.packages) do + currentFpKeys[getFlatpakKey(pkg)] = true + end + for k in pairs(excludedFlatpak) do + if not currentFpKeys[k] then + excludedFlatpak[k] = nil + end + end + else + excludedFlatpak = {} + end +end + +local function toggleExcludeDnf(key) + if excludedDnf[key] then + excludedDnf[key] = nil + else + excludedDnf[key] = true end if render then render() @@ -135,18 +175,19 @@ end local function toggleAllDnf(packages) local anyExcluded = false for _, pkg in ipairs(packages) do - if excludedDnf[pkg.name] then + local k = getDnfKey(pkg) + if excludedDnf[k] then anyExcluded = true break end end if anyExcluded then for _, pkg in ipairs(packages) do - excludedDnf[pkg.name] = nil + excludedDnf[getDnfKey(pkg)] = nil end else for _, pkg in ipairs(packages) do - excludedDnf[pkg.name] = true + excludedDnf[getDnfKey(pkg)] = true end end if render then @@ -187,25 +228,6 @@ local function tr(key, params) return text end -local function formatAgo(timestamp) - local minutes = math.floor((os.time() - timestamp) / 60) - if minutes < 1 then - return tr("ago.now") - elseif minutes < 60 then - return tr("ago.minutes", { n = minutes }) - elseif minutes < 1440 then - return tr("ago.hours", { n = math.floor(minutes / 60) }) - end - return tr("ago.days", { n = math.floor(minutes / 1440) }) -end - -local function formatLastChecked(state) - if state and (state.last_check_timestamp or 0) > 0 then - return tr("status.last_checked", { time = formatAgo(state.last_check_timestamp) }) - end - return tr("status.never_checked") -end - local function statusCard(state) local status = state and state.status or "IDLE" local total = state and state.total_count or 0 @@ -369,7 +391,8 @@ local function renderDnfSection(state) local selectedCount = 0 local selectedBytes = 0 for _, pkg in ipairs(packages) do - if not excludedDnf[pkg.name] then + local k = getDnfKey(pkg) + if not excludedDnf[k] then selectedCount += 1 selectedBytes += getPackageDownloadBytes(pkg) end @@ -440,7 +463,8 @@ local function renderDnfSection(state) if isMatch then visibleCount += 1 - local isExcluded = excludedDnf[pkg.name] == true + local pkgKey = getDnfKey(pkg) + local isExcluded = excludedDnf[pkgKey] == true local arch = (pkg.arch ~= machineArch) and (pkg.arch or "") or "" local repo = "" if pkg.repo and pkg.repo ~= "" and not officialRepos[pkg.repo] then @@ -487,7 +511,7 @@ local function renderDnfSection(state) end table.insert(items, ui.row({ - key = "dnf-pkg-" .. tostring(idx) .. "-" .. (pkg.name or "pkg"), + key = "dnf-pkg-" .. tostring(idx) .. "-" .. pkgKey, padding = 8, paddingH = 12, fill = isExcluded and "surface_container" or "surface", @@ -496,7 +520,7 @@ local function renderDnfSection(state) gap = 8, onClick = function() if not isBusy then - toggleExcludeDnf(pkg.name) + toggleExcludeDnf(pkgKey) end end, }, { @@ -724,28 +748,18 @@ render = function() local header = ui.row({ align = "center", gap = 8 }, { ui.glyph({ name = "package", size = 16, color = "primary" }), - ui.column({ key = "panel-title-block", gap = 0 }, { - ui.row({ align = "center", gap = 6 }, { - ui.label({ - key = "panel-title", - text = tr("title"), - fontSize = 15, - fontWeight = "bold", - color = "on_surface", - }), - ui.label({ - key = "panel-version", - text = pluginVersion ~= "" and ("v" .. pluginVersion) or "", - fontSize = 11, - color = "on_surface_variant", - }), + ui.row({ key = "panel-title-block", align = "center", gap = 6 }, { + ui.label({ + key = "panel-title", + text = tr("title"), + fontSize = 15, + fontWeight = "bold", + color = "on_surface", }), ui.label({ - key = "panel-last-checked", - text = (state and (state.last_update_timestamp or 0) > 0) - and (formatLastChecked(state) .. " · " .. tr("status.last_update", { time = formatAgo(state.last_update_timestamp) })) - or formatLastChecked(state), - fontSize = 10, + key = "panel-version", + text = pluginVersion ~= "" and ("v" .. pluginVersion) or "", + fontSize = 11, color = "on_surface_variant", }), }), @@ -907,6 +921,7 @@ end function onOpen(_context) updateState = noctalia.state.get("update_state") + pruneExclusions(updateState) searchQuery = "" render() end @@ -928,10 +943,13 @@ end function onUpdateAll() local excludedDnfList = {} + local seenDnf = {} if updateState and updateState.dnf and updateState.dnf.packages then for _, pkg in ipairs(updateState.dnf.packages) do - if excludedDnf[pkg.name] then - table.insert(excludedDnfList, pkg.name) + local k = getDnfKey(pkg) + if excludedDnf[k] and not seenDnf[k] then + seenDnf[k] = true + table.insert(excludedDnfList, k) end end end @@ -957,10 +975,13 @@ end function onUpdateDnf() local excludedDnfList = {} + local seenDnf = {} if updateState and updateState.dnf and updateState.dnf.packages then for _, pkg in ipairs(updateState.dnf.packages) do - if excludedDnf[pkg.name] then - table.insert(excludedDnfList, pkg.name) + local k = getDnfKey(pkg) + if excludedDnf[k] and not seenDnf[k] then + seenDnf[k] = true + table.insert(excludedDnfList, k) end end end @@ -1003,6 +1024,7 @@ end -- Subscribe to state changes from background service noctalia.state.watch("update_state", function(newState) updateState = newState + pruneExclusions(newState) render() end) diff --git a/dnf-updater/plugin.toml b/dnf-updater/plugin.toml index b7b3a5261..51eff1974 100644 --- a/dnf-updater/plugin.toml +++ b/dnf-updater/plugin.toml @@ -1,6 +1,6 @@ id = "strob3/dnf-updater" name = "DNF Updater" -version = "1.1.0" +version = "1.2.0" plugin_api = 43 author = "strob3" license = "MIT" @@ -60,6 +60,13 @@ label_key = "settings.update_in_terminal.label" description_key = "settings.update_in_terminal.description" default = true +[[setting]] +key = "refresh_on_update" +type = "bool" +label_key = "settings.refresh_on_update.label" +description_key = "settings.refresh_on_update.description" +default = false + [[service]] id = "service" entry = "service.luau" diff --git a/dnf-updater/scripts/dnf-updater-helper.sh b/dnf-updater/scripts/dnf-updater-helper.sh index 25e6c0cdb..39b4c001d 100755 --- a/dnf-updater/scripts/dnf-updater-helper.sh +++ b/dnf-updater/scripts/dnf-updater-helper.sh @@ -11,7 +11,7 @@ set -euo pipefail # Help / usage if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - echo "Usage: dnf-updater-helper upgrade [--exclude=]" + echo "Usage: dnf-updater-helper upgrade [--refresh] [--exclude=]" exit 0 fi @@ -29,7 +29,7 @@ fi ACTION="${1:-}" if [[ "$ACTION" != "upgrade" ]]; then echo "Error: unsupported action '${ACTION}'. Only 'upgrade' is allowed." >&2 - echo "Usage: dnf-updater-helper upgrade [--exclude=]" >&2 + echo "Usage: dnf-updater-helper upgrade [--refresh] [--exclude=]" >&2 exit 1 fi shift @@ -39,6 +39,8 @@ VALID_ARGS=() for arg in "$@"; do if [[ "$arg" =~ ^--exclude=[a-zA-Z0-9._+*:-][a-zA-Z0-9._+*:,-]*$ ]]; then VALID_ARGS+=("$arg") + elif [[ "$arg" == "--refresh" ]]; then + VALID_ARGS+=("$arg") else echo "Error: invalid argument rejected: '$arg'" >&2 exit 1 diff --git a/dnf-updater/scripts/install.sh b/dnf-updater/scripts/install.sh index 88170ad12..e1252e694 100755 --- a/dnf-updater/scripts/install.sh +++ b/dnf-updater/scripts/install.sh @@ -36,7 +36,7 @@ case "$ACTION" in uninstall|--uninstall|-u) echo "Removing DNF Updater privileged helper and Polkit rule..." rm -f "$DEST_BIN" "$DEST_POLKIT" - echo "✓ Successfully removed:" + echo "Successfully removed:" echo " - $DEST_BIN" echo " - $DEST_POLKIT" exit 0 @@ -50,7 +50,7 @@ case "$ACTION" in ;; esac -echo "==> Installing DNF Updater privileged helper and Polkit rule..." +echo "Installing DNF Updater privileged helper and Polkit rule..." # Determine script location if running locally SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" @@ -61,15 +61,15 @@ CLEANUP_TMP=false TMP_DIR="" if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/dnf-updater-helper.sh" && -f "${SCRIPT_DIR}/../polkit/50-dnf-updater.rules" ]]; then - echo " → Using local repository files..." + echo "Using local repository files..." SRC_HELPER="${SCRIPT_DIR}/dnf-updater-helper.sh" SRC_POLKIT="${SCRIPT_DIR}/../polkit/50-dnf-updater.rules" elif [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/scripts/dnf-updater-helper.sh" && -f "${SCRIPT_DIR}/polkit/50-dnf-updater.rules" ]]; then - echo " → Using local repository files..." + echo "Using local repository files..." SRC_HELPER="${SCRIPT_DIR}/scripts/dnf-updater-helper.sh" SRC_POLKIT="${SCRIPT_DIR}/polkit/50-dnf-updater.rules" else - echo " → Fetching latest files from GitHub ($REPO_RAW_URL)..." + echo "Fetching latest files from GitHub ($REPO_RAW_URL)..." TMP_DIR="$(mktemp -d)" CLEANUP_TMP=true trap 'rm -rf "$TMP_DIR"' EXIT @@ -91,11 +91,11 @@ fi # Install privileged helper install -Dm755 "$SRC_HELPER" "$DEST_BIN" -echo " ✓ Installed privileged helper: $DEST_BIN (mode 0755)" +echo "Installed privileged helper: $DEST_BIN (mode 0755)" # Install Polkit rule install -Dm644 "$SRC_POLKIT" "$DEST_POLKIT" -echo " ✓ Installed Polkit policy: $DEST_POLKIT (mode 0644)" +echo "Installed Polkit policy: $DEST_POLKIT (mode 0644)" # Fix SELinux context if SELinux is available if command -v restorecon >/dev/null 2>&1; then @@ -107,13 +107,13 @@ INVOKER="${SUDO_USER:-$USER}" if [[ -n "$INVOKER" && "$INVOKER" != "root" ]]; then if ! id -nG "$INVOKER" 2>/dev/null | grep -Ewq "wheel|sudo"; then echo "" - echo " ⚠ Warning: User '$INVOKER' is not in the 'wheel' or 'sudo' group." - echo " Polkit requires administrative group membership for passwordless updates." - echo " Add your user with: sudo usermod -aG wheel $INVOKER" + echo "Warning: User '$INVOKER' is not in the 'wheel' or 'sudo' group." + echo "Polkit requires administrative group membership for passwordless updates." + echo "Add your user with: sudo usermod -aG wheel $INVOKER" fi fi echo "" -echo "✓ Installation complete!" -echo "💡 Tip: To enable background updates without terminal popups, ensure 'Run Updates in Terminal'" -echo " is turned off in Noctalia Settings → Plugins → DNF Updater." +echo "Installation complete." +echo "To enable background updates without terminal popups, ensure 'Run Updates in Terminal'" +echo "is turned off in Noctalia Settings → Plugins → DNF Updater." diff --git a/dnf-updater/service.luau b/dnf-updater/service.luau index 85c89ee4d..4b293ac24 100644 --- a/dnf-updater/service.luau +++ b/dnf-updater/service.luau @@ -8,21 +8,26 @@ 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 refreshOnUpdate = noctalia.getConfig("refresh_on_update") == true local state = { status = "IDLE", -- "IDLE" | "CHECKING" | "UPDATING" | "SUCCESS" | "ERROR" total_count = 0, total_download_bytes = 0, total_install_bytes = 0, + total_delta_bytes = 0, total_download_formatted = "0 B", total_install_formatted = "0 B", + total_delta_formatted = "0 B", dnf = { count = 0, packages = {}, total_download_bytes = 0, total_install_bytes = 0, + total_delta_bytes = 0, total_download_formatted = "0 B", total_install_formatted = "0 B", + total_delta_formatted = "0 B", status = "idle", -- "idle" | "checking" | "updating" | "done" | "error" error = nil, }, @@ -31,8 +36,10 @@ local state = { packages = {}, total_download_bytes = 0, total_install_bytes = 0, + total_delta_bytes = 0, total_download_formatted = "0 B", total_install_formatted = "0 B", + total_delta_formatted = "0 B", status = "idle", -- "idle" | "checking" | "updating" | "done" | "error" error = nil, }, @@ -65,6 +72,15 @@ local function formatBytes(bytes) end end +local function formatDeltaBytes(bytes) + if not bytes or bytes == 0 then + return "0 B" + end + local sign = bytes > 0 and "+" or "-" + local absVal = math.abs(bytes) + return sign .. formatBytes(absVal) +end + local function parseSizeToBytes(sizeStr) if not sizeStr or type(sizeStr) ~= "string" or sizeStr == "" then return 0 @@ -233,44 +249,67 @@ local function checkDnf(onFinished, isRetry) end local decoded, err = noctalia.json.decode(raw) - if type(decoded) == "table" and type(decoded.upgrades) == "table" then + if type(decoded) == "table" and (type(decoded.upgrades) == "table" or type(decoded.obsoleting_packages) == "table") then local packages = {} - for _, item in ipairs(decoded.upgrades) do + local seenPkgs = {} + + local function addPackage(item) 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 = "", - }) + local arch = item.arch or "" + local key = item.name .. "." .. arch + if not seenPkgs[key] then + seenPkgs[key] = true + table.insert(packages, { + name = item.name, + evr = item.evr or "", + arch = arch, + repo = item.repository or "", + download_bytes = 0, + install_bytes = 0, + download_formatted = "", + install_formatted = "", + }) + end + end + end + + if type(decoded.upgrades) == "table" then + for _, item in ipairs(decoded.upgrades) do + addPackage(item) end end + if type(decoded.obsoleting_packages) == "table" then + for _, item in ipairs(decoded.obsoleting_packages) do + addPackage(item) + 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) + -- Query individual package download and install sizes from local cache + noctalia.runAsync({ dnfBin, "repoquery", "--cacheonly", "--upgrades", "--queryformat", "%{name}\t%{arch}\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] = { + local name, arch, dl, inst = line:match("([^\t]+)\t([^\t]+)\t([^\t]+)\t([^\t]+)") + if name and arch then + local s = { dl = tonumber(dl) or 0, inst = tonumber(inst) or 0, } + sizeMap[name .. "." .. arch] = s + if not sizeMap[name] then + sizeMap[name] = s + end end end for _, pkg in ipairs(packages) do - local s = sizeMap[pkg.name] + local s = sizeMap[pkg.name .. "." .. (pkg.arch or "")] or sizeMap[pkg.name] if s then pkg.download_bytes = s.dl pkg.install_bytes = s.inst @@ -282,19 +321,57 @@ local function checkDnf(onFinished, isRetry) 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) + local rpmArgs = { "rpm", "-q", "--queryformat", "%{name}\t%{arch}\t%{SIZE}\n" } + local seenNames = {} + for _, pkg in ipairs(packages) do + if not seenNames[pkg.name] then + seenNames[pkg.name] = true + table.insert(rpmArgs, pkg.name) + end + end + + noctalia.runAsync(rpmArgs, function(resRpm) + local oldSizeMap = {} + if resRpm and resRpm.stdout and resRpm.stdout ~= "" then + for line in resRpm.stdout:gmatch("[^\r\n]+") do + local name, arch, sz = line:match("([^\t]+)\t([^\t]+)\t([^\t]+)") + if name and arch and sz then + local num = tonumber(sz) or 0 + oldSizeMap[name .. "." .. arch] = num + if not oldSizeMap[name] then + oldSizeMap[name] = num + end + end + end + end + + local dnfTotalDelta = 0 + for _, pkg in ipairs(packages) do + local oldSize = oldSizeMap[pkg.name .. "." .. (pkg.arch or "")] or oldSizeMap[pkg.name] or 0 + local delta = (pkg.install_bytes or 0) - oldSize + pkg.delta_bytes = delta + pkg.delta_formatted = formatDeltaBytes(delta) + dnfTotalDelta += delta + end + + state.dnf.total_delta_bytes = dnfTotalDelta + state.dnf.total_delta_formatted = formatDeltaBytes(dnfTotalDelta) + 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, 10000) end, 30000) else state.dnf.total_download_bytes = 0 state.dnf.total_install_bytes = 0 + state.dnf.total_delta_bytes = 0 state.dnf.total_download_formatted = "0 B" state.dnf.total_install_formatted = "0 B" + state.dnf.total_delta_formatted = "0 B" state.dnf.status = "done" state.dnf.error = nil onFinished(true) @@ -412,13 +489,53 @@ local function checkFlatpak(onFinished, isRetry) 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) + + if #packages > 0 then + noctalia.runAsync({ "flatpak", "list", "--columns=ref,size" }, function(resList) + local oldFpSizes = {} + if resList and resList.stdout and resList.stdout ~= "" then + for line in resList.stdout:gmatch("[^\r\n]+") do + local ref, sz = line:match("(%S+)%s+(.+)") + if ref and sz then + local b = parseSizeToBytes(sz) + oldFpSizes[ref] = b + local shortRef = ref:gsub("^[^/]+/", "") + oldFpSizes[shortRef] = b + end + end + end + + local fpTotalDelta = 0 + for _, pkg in ipairs(packages) do + local shortRef = (pkg.ref or ""):gsub("^[^/]+/", "") + local oldSize = oldFpSizes[pkg.ref] or oldFpSizes[shortRef] or 0 + local delta = (pkg.install_bytes or 0) - oldSize + pkg.delta_bytes = delta + pkg.delta_formatted = formatDeltaBytes(delta) + fpTotalDelta += delta + end + + state.flatpak.total_delta_bytes = fpTotalDelta + state.flatpak.total_delta_formatted = formatDeltaBytes(fpTotalDelta) + 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) + end, 10000) + else + state.flatpak.total_download_bytes = 0 + state.flatpak.total_install_bytes = 0 + state.flatpak.total_delta_bytes = 0 + state.flatpak.total_download_formatted = "0 B" + state.flatpak.total_install_formatted = "0 B" + state.flatpak.total_delta_formatted = "0 B" + state.flatpak.status = "done" + state.flatpak.error = nil + onFinished(true) + end else if result.exitCode ~= 0 then state.flatpak.status = "error" @@ -475,6 +592,7 @@ local function refreshLastUpdate() if latest > 0 and latest ~= state.last_update_timestamp then state.last_update_timestamp = latest publishState() + saveState() end end) end @@ -537,8 +655,10 @@ local function checkAll(force) 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_delta_bytes = (state.dnf.total_delta_bytes or 0) + (state.flatpak.total_delta_bytes or 0) state.total_download_formatted = formatBytes(state.total_download_bytes) state.total_install_formatted = formatBytes(state.total_install_bytes) + state.total_delta_formatted = formatDeltaBytes(state.total_delta_bytes) state.last_check = noctalia.formatTime("%d/%m %H:%M") state.last_check_timestamp = os.time() @@ -598,11 +718,13 @@ local function updateDnf(excludedList, onFinished) end local validExcluded = {} + local seenExcluded = {} if excludedList and type(excludedList) == "table" then for _, name in ipairs(excludedList) do - if isValidPackageName(name) then + if isValidPackageName(name) and not seenExcluded[name] then + seenExcluded[name] = true table.insert(validExcluded, name) - else + elseif not isValidPackageName(name) then print(string.format("[dnf-updater] Warning: invalid DNF package name skipped: %s", tostring(name))) end end @@ -624,6 +746,9 @@ local function updateDnf(excludedList, onFinished) if updateInTerminal then local sudoCmd = noctalia.commandExists("sudo") and "sudo" or "pkexec" local cmd = sudoCmd .. " " .. dnfBin .. " upgrade -y" + if refreshOnUpdate then + cmd = cmd .. " --refresh" + end if excludeArg then cmd = cmd .. " " .. shellQuote(excludeArg) end @@ -645,6 +770,9 @@ local function updateDnf(excludedList, onFinished) else args = { "pkexec", dnfBin, "upgrade", "-y" } end + if refreshOnUpdate then + table.insert(args, "--refresh") + end if excludeArg then table.insert(args, excludeArg) end @@ -808,6 +936,9 @@ local function updateAll(excludedDnf, selectedFlatpaks) if dnfNeedsUpdate and dnfBin then local sudoCmd = noctalia.commandExists("sudo") and "sudo" or "pkexec" local dnfCmd = sudoCmd .. " " .. dnfBin .. " upgrade -y" + if refreshOnUpdate then + dnfCmd = dnfCmd .. " --refresh" + end if #validExcluded > 0 then dnfCmd = dnfCmd .. " " .. shellQuote("--exclude=" .. table.concat(validExcluded, ",")) end @@ -901,12 +1032,19 @@ local function updateAll(excludedDnf, selectedFlatpaks) end end +local lastCommandTimestamp = os.time() + -- 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.ts and cmd.ts <= lastCommandTimestamp then + return + end + lastCommandTimestamp = cmd.ts or os.time() + if cmd.action == "check_now" then checkAll(true) elseif cmd.action == "update_all" then @@ -937,6 +1075,7 @@ function onConfigChanged() includeFlatpakRuntimes = noctalia.getConfig("include_flatpak_runtimes") == true notifyOnUpdates = noctalia.getConfig("notify_on_updates") ~= false updateInTerminal = noctalia.getConfig("update_in_terminal") ~= false + refreshOnUpdate = noctalia.getConfig("refresh_on_update") == true if checkDue() then checkAll(true) end @@ -980,4 +1119,6 @@ local restored = loadState() publishState() if not restored or checkDue() then checkAll(true) +else + refreshLastUpdate() end diff --git a/dnf-updater/thumbnail.webp b/dnf-updater/thumbnail.webp index ff6f057d7..055fb56c5 100644 Binary files a/dnf-updater/thumbnail.webp and b/dnf-updater/thumbnail.webp differ diff --git a/dnf-updater/translations/en.json b/dnf-updater/translations/en.json index 7e10addc4..61749dc57 100644 --- a/dnf-updater/translations/en.json +++ b/dnf-updater/translations/en.json @@ -18,12 +18,6 @@ "update_selected": "Update Selected ({count})", "update_selected_size": "Update Selected ({count} · {size})" }, - "ago": { - "days": "{n} d ago", - "hours": "{n} h ago", - "minutes": "{n} min ago", - "now": "just now" - }, "notifications": { "update_complete_body": "System updates have completed successfully.", "update_complete_title": "Update Complete", @@ -32,9 +26,6 @@ "updates_available_body": "{count} pending updates are ready to install.", "updates_available_title": "Updates Available" }, - "search": { - "no_matches": "No packages match \"{query}\"" - }, "sections": { "dnf": "DNF · {count} updates", "dnf_selected": "DNF · {selected}/{total} updates", @@ -55,14 +46,14 @@ "description": "Check for and manage Flatpak application updates.", "label": "Enable Flatpak" }, - "glyph": { - "description": "Icon glyph to display in the Noctalia bar widget.", - "label": "Widget Icon" - }, "include_flatpak_runtimes": { "description": "Include Flatpak runtime and extension updates alongside applications.", "label": "Include Flatpak Runtimes" }, + "glyph": { + "description": "Icon glyph to display in the Noctalia bar widget.", + "label": "Widget Icon" + }, "notify_on_updates": { "description": "Send a desktop notification when new pending updates are found.", "label": "Notify on New Updates" @@ -74,6 +65,10 @@ "update_in_terminal": { "description": "Launch update commands inside a terminal emulator instead of running in background with polkit.", "label": "Run Updates in Terminal" + }, + "refresh_on_update": { + "description": "Pass --refresh to DNF on upgrade to force refreshing repository metadata cache.", + "label": "Refresh Metadata on Upgrade" } }, "status": { @@ -90,19 +85,29 @@ "flatpak_idle": "Flatpak idle", "flatpak_updating": "Updating Flatpak…", "flatpak_waiting": "Flatpak waiting…", - "last_checked": "Last checked: {time}", + "last_checked": "Last check {time}", "last_update": "last update {time}", "never_checked": "Never checked", "reboot_required": "Restart required to complete updates", "skipped": "Skipped", "updating": "Updating packages…" }, + "search": { + "no_matches": "No packages match \"{query}\"" + }, + "ago": { + "days": "{n} d ago", + "hours": "{n} h ago", + "minutes": "{n} min ago", + "now": "just now" + }, "summary": { "disk_size": "Disk Size", "dnf": "DNF", "download": "Download", "flatpak": "Flatpak", "last_checked": "Last Checked", + "last_update": "Last Updated", "storage": "Storage", "system": "System", "total": "Total" @@ -110,6 +115,7 @@ "title": "DNF Updater", "tooltip": { "all_up_to_date": "All packages up to date", + "disk_delta": "Disk impact", "disk_size": "Disk size", "download_size": "Download size", "updates_available": "{count} updates available" diff --git a/dnf-updater/widget.luau b/dnf-updater/widget.luau index e10437458..cfbb6dd19 100644 --- a/dnf-updater/widget.luau +++ b/dnf-updater/widget.luau @@ -6,6 +6,24 @@ local showWhenClean = noctalia.getConfig("show_widget_when_clean") ~= false local updateState = noctalia.state.get("update_state") +local function formatAgo(timestamp) + if not timestamp or timestamp <= 0 then + return nil + end + local minutes = math.floor((os.time() - timestamp) / 60) + if minutes < 1 then + return noctalia.tr("ago.now") or "just now" + elseif minutes < 60 then + local s = noctalia.tr("ago.minutes") or "{n} min ago" + return s:gsub("{n}", tostring(minutes)) + elseif minutes < 1440 then + local s = noctalia.tr("ago.hours") or "{n} h ago" + return s:gsub("{n}", tostring(math.floor(minutes / 60))) + end + local s = noctalia.tr("ago.days") or "{n} d ago" + return s:gsub("{n}", tostring(math.floor(minutes / 1440))) +end + local function render() glyph = noctalia.getConfig("glyph") or "package" showWhenClean = noctalia.getConfig("show_widget_when_clean") ~= false @@ -49,7 +67,16 @@ local function render() if updateState.total_download_formatted and updateState.total_download_formatted ~= "0 B" then table.insert(tooltipEntries, { key = noctalia.tr("tooltip.download_size"), value = updateState.total_download_formatted }) end + if updateState.total_delta_formatted and updateState.total_delta_formatted ~= "" then + table.insert(tooltipEntries, { key = noctalia.tr("tooltip.disk_delta"), value = updateState.total_delta_formatted }) + end table.insert(tooltipEntries, { key = noctalia.tr("summary.last_checked"), value = updateState.last_check or "–" }) + if updateState.last_update_timestamp and updateState.last_update_timestamp > 0 then + local ago = formatAgo(updateState.last_update_timestamp) + if ago then + table.insert(tooltipEntries, { key = noctalia.tr("summary.last_update"), value = ago }) + end + end barWidget.setTooltip(tooltipEntries) barWidget.setVisible(true) else @@ -63,11 +90,19 @@ local function render() barWidget.setVisible(true) barWidget.setGlyph(glyph) barWidget.setText("") - local tooltipText = noctalia.tr("tooltip.all_up_to_date") + local tooltipEntries = { + { key = noctalia.tr("summary.system"), value = noctalia.tr("tooltip.all_up_to_date") }, + } if updateState.last_check then - tooltipText = tooltipText .. " (" .. updateState.last_check .. ")" + table.insert(tooltipEntries, { key = noctalia.tr("summary.last_checked"), value = updateState.last_check }) + end + if updateState.last_update_timestamp and updateState.last_update_timestamp > 0 then + local ago = formatAgo(updateState.last_update_timestamp) + if ago then + table.insert(tooltipEntries, { key = noctalia.tr("summary.last_update"), value = ago }) + end end - barWidget.setTooltip(tooltipText) + barWidget.setTooltip(tooltipEntries) else barWidget.setVisible(false) end