Skip to content
Merged
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
15 changes: 15 additions & 0 deletions dnf-updater/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 2 additions & 8 deletions dnf-updater/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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=<pkg>` 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)
130 changes: 76 additions & 54 deletions dnf-updater/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,58 @@ 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
if pkg.id and pkg.id ~= "" then return pkg.id end
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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -496,7 +520,7 @@ local function renderDnfSection(state)
gap = 8,
onClick = function()
if not isBusy then
toggleExcludeDnf(pkg.name)
toggleExcludeDnf(pkgKey)
end
end,
}, {
Expand Down Expand Up @@ -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",
}),
}),
Expand Down Expand Up @@ -907,6 +921,7 @@ end

function onOpen(_context)
updateState = noctalia.state.get("update_state")
pruneExclusions(updateState)
searchQuery = ""
render()
end
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion dnf-updater/plugin.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions dnf-updater/scripts/dnf-updater-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ set -euo pipefail

# Help / usage
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: dnf-updater-helper upgrade [--exclude=<pkg1,pkg2,...>]"
echo "Usage: dnf-updater-helper upgrade [--refresh] [--exclude=<pkg1,pkg2,...>]"
exit 0
fi

Expand All @@ -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=<pkg1,pkg2,...>]" >&2
echo "Usage: dnf-updater-helper upgrade [--refresh] [--exclude=<pkg1,pkg2,...>]" >&2
exit 1
fi
shift
Expand All @@ -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
Expand Down
Loading
Loading