This commit is contained in:
2026-11-04 20:35:46 +01:00
parent 5f4de5e60c
commit 86abc097f4
348 changed files with 10300 additions and 0 deletions
@@ -0,0 +1 @@
{"dnf":{"count":13,"packages":[{"arch":"x86_64","download_bytes":150994773,"download_formatted":"144.0 MiB","evr":"157.0.8103.38-1","install_bytes":473654166,"install_formatted":"451.7 MiB","name":"google-chrome-stable","repo":"google-chrome"},{"arch":"x86_64","download_bytes":237159,"download_formatted":"231.6 KiB","evr":"710-2.fc44","install_bytes":500346,"install_formatted":"488.6 KiB","name":"less","repo":"updates"},{"arch":"x86_64","download_bytes":11154,"download_formatted":"10.9 KiB","evr":"710-2.fc44","install_bytes":2453,"install_formatted":"2.4 KiB","name":"less-color","repo":"updates"},{"arch":"x86_64","download_bytes":14509767,"download_formatted":"13.8 MiB","evr":"26.2.6-1.fc44","install_bytes":55835303,"install_formatted":"53.2 MiB","name":"mesa-dri-drivers","repo":"updates"},{"arch":"x86_64","download_bytes":9467,"download_formatted":"9.2 KiB","evr":"26.2.6-1.fc44","install_bytes":3682,"install_formatted":"3.6 KiB","name":"mesa-filesystem","repo":"updates"},{"arch":"x86_64","download_bytes":144525,"download_formatted":"141.1 KiB","evr":"26.2.6-1.fc44","install_bytes":407983,"install_formatted":"398.4 KiB","name":"mesa-libEGL","repo":"updates"},{"arch":"x86_64","download_bytes":142203,"download_formatted":"138.9 KiB","evr":"26.2.6-1.fc44","install_bytes":387286,"install_formatted":"378.2 KiB","name":"mesa-libGL","repo":"updates"},{"arch":"x86_64","download_bytes":15617,"download_formatted":"15.3 KiB","evr":"26.2.6-1.fc44","install_bytes":19996,"install_formatted":"19.5 KiB","name":"mesa-libgbm","repo":"updates"},{"arch":"x86_64","download_bytes":29137089,"download_formatted":"27.8 MiB","evr":"26.2.6-1.fc44","install_bytes":182459359,"install_formatted":"174.0 MiB","name":"mesa-vulkan-drivers","repo":"updates"},{"arch":"noarch","download_bytes":11984,"download_formatted":"11.7 KiB","evr":"3.14.10-1.fc44","install_bytes":23,"install_formatted":"23 B","name":"python-unversioned-command","repo":"updates"},{"arch":"x86_64","download_bytes":30236,"download_formatted":"29.5 KiB","evr":"3.14.10-1.fc44","install_bytes":29354,"install_formatted":"28.7 KiB","name":"python3","repo":"updates"},{"arch":"x86_64","download_bytes":10672326,"download_formatted":"10.2 MiB","evr":"3.14.10-1.fc44","install_bytes":46317997,"install_formatted":"44.2 MiB","name":"python3-libs","repo":"updates"},{"arch":"x86_64","download_bytes":643432,"download_formatted":"628.4 KiB","evr":"5.7-1.fc44","install_bytes":1437679,"install_formatted":"1.4 MiB","name":"swayimg","repo":"updates"}],"status":"done","total_download_bytes":206559732,"total_download_formatted":"197.0 MiB","total_install_bytes":761055627,"total_install_formatted":"725.8 MiB"},"flatpak":{"count":0,"packages":{},"status":"done","total_download_bytes":0,"total_download_formatted":"0 B","total_install_bytes":0,"total_install_formatted":"0 B"},"last_check":"04/11 20:23","last_check_timestamp":1793820226,"last_update_timestamp":1793730402,"reboot_required":false,"status":"IDLE","total_count":13,"total_download_bytes":206559732,"total_download_formatted":"197.0 MiB","total_install_bytes":761055627,"total_install_formatted":"725.8 MiB"}
@@ -0,0 +1,59 @@
## [1.1.0] - 2026-10-26
### Added
- **Reboot Required Detection**: Detects pending system restart via `dnf needs-restarting -r` with warning banner in panel and widget status indicator.
- **Package Search Filter**: Search bar to filter pending DNF and Flatpak packages by name, repository, or runtime.
- **Header Overview**: Relative last check and last full upgrade timestamps in panel header.
- **Weekly Check Interval**: Extended check interval limit up to 7 days (10080 min).
- **Terminal Confirmation**: Added `-y` flag to terminal updates for promptless execution.
### Fixed
- **Badge Overflow**: Truncated COPR repo names, suppressed redundant native arch and official Fedora repo badges, and capped badge width.
- **State Persistence**: Cached check results in `pluginDataDir()` across restarts to prevent redundant checks on login.
- **Terminal Update Sync**: Triggered auto-recheck via IPC when terminal updates complete, and closed panel on launch.
## [1.0.3] - 2026-10-06
### Added
- **Plugin Version in Header**: Display installed version next to panel title in `panel.luau`.
- **Flatpak Runtime Updates**: Added `include_flatpak_runtimes` setting and row badge in `panel.luau`.
### Fixed
- Fixes noctalia-dev/community-plugins#922: **Flatpak Ghost Updates**: Added `--app` flag to `flatpak remote-ls --updates` to filter unmatched theme extensions and EOL runtimes.
- **Flatpak Selection Crash**: Defined `flatpakCount` scope in `updateFlatpak()` to fix nil comparison crash and infinite loading.
- **Background Upgrade Protection**: Raised background transaction timeout to 10m and wrapped helper with `systemd-inhibit` to prevent SIGKILL and mid-upgrade aborts.
- **Flatpak Parameter Limits**: Removed duplicate Flatpak refs, relaxed validation, and simplified full updates.
- **Timeout Error Reporting**: Added `result.timedOut` handling to DNF and Flatpak update errors advising terminal execution.
## [1.0.2] - 2026-09-22
### Added
- **Installer Script for passwordless background updates**: Added `scripts/install.sh` for remote/local install, group checks (`wheel`/`sudo`), SELinux restore, and uninstallation.
### Fixed
- **Check Retry**: Retried DNF/Flatpak checks once when they time out.
- **Stream Exclusions**: Fixed exclusions for RPM modules and epochs containing colons.
- **Polkit Compatibility**: Added support for both `wheel` and `sudo` groups.
- **Error Capture**: Captured DNF error output when `stderr` is empty.
## [1.0.1] - 2026-09-21
### Security
- **Helper**: Restricted privileged DNF commands to supported operations.
- **Input Validation**: Added validation for DNF package names and Flatpak refs.
- **Shell Quoting**: Safely escaped dynamic terminal arguments.
### Fixed
- **Terminal Updates**: Combined DNF and Flatpak updates into one terminal session.
- **Flatpak Updates**: Fixed full Flatpak updates to include runtimes.
- **Size Parsing**: Improved Flatpak update size parsing.
- **Package Keys**: Fixed inconsistent Flatpak package keys.
## [1.0.0] - 2026-09-19
### Added
- Initial release of DNF Updater.
- DNF5 and Flatpak update monitoring and management.
- Flyout panel with package selection and size estimation.
- Terminal and passwordless Polkit background update modes.
- Status bar widget with update count badges and indicators.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 strob3
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,119 @@
# 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.
## Plugin
| Field | Value |
| --- | --- |
| ID | `strob3/dnf-updater` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service` |
## Requirements
The plugin expects the following dependencies on `$PATH`:
- `dnf5` - required for DNF, COPR, and RPM package management. Fedora 41+ uses DNF5 by default.
- `flatpak` - optional, Flatpak application manager
- `pkexec` - (Polkit authentication for non-interactive privileged package installation)
### Passwordless Background Upgrades (Optional)
To enable background upgrades without a password prompt, install the included hardened helper script and Polkit rule with:
```sh
curl -fsSL https://raw.githubusercontent.com/strob3/dnf-updater/main/scripts/install.sh | sudo bash
```
Alternatively, if you have cloned the repository locally:
```sh
sudo ./scripts/install.sh
```
To remove the helper and Polkit rule later:
```sh
curl -fsSL https://raw.githubusercontent.com/strob3/dnf-updater/main/scripts/install.sh | sudo bash -s -- --uninstall
# or: sudo ./scripts/install.sh --uninstall
```
## Usage
Add the DNF Updater widget to the bar using Noctalia's bar widget picker. Left-click the widget to open the updates panel. You can also toggle the panel or bind it to a compositor shortcut:
```sh
noctalia msg panel-toggle strob3/dnf-updater:panel
```
### Shortcuts & Keybindings
You can also toggle the panel with a hotkey or shell alias:
**Niri** (`~/.config/niri/config.kdl`):
```kdl
binds {
Mod+U { spawn "noctalia" "msg" "panel-toggle" "strob3/dnf-updater:panel"; }
}
```
**Hyprland** (`hyprland.conf`):
```conf
bind = $mainMod, U, exec, noctalia msg panel-toggle strob3/dnf-updater:panel
```
**Shell Alias** (`~/.bashrc` or `~/.zshrc`):
```sh
alias dnf-updater="noctalia msg panel-toggle strob3/dnf-updater:panel"
alias dnf-check="noctalia msg plugin strob3/dnf-updater:service service check"
```
### Controls
| Action | Target | Description |
| --- | --- | --- |
| **Left Click** | Bar Widget | Open or close the updates panel |
| **Right Click** | Bar Widget | Refresh the updates |
| **Middle Click** | Bar Widget | Open plugin settings in *Settings → Plugins* |
| **Search Input** | Panel Top | Filter packages in real time by name, repo, or ref |
| **Click Row** | Package Row | Toggle package selection (include or exclude from update) |
| **Chevron Click** | Section Header | Collapse or expand DNF or Flatpak package section |
| **Check Click** | Section Header | Select all or deselect all packages in section |
| **Refresh Icon** | Panel Header | Re-check repositories for latest updates |
| **Update Button** | Panel Bottom | Apply updates for all selected packages |
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `check_interval` | `int` | `30` | Interval in minutes between automatic checks (`0` to `10080`, `0` disables). |
| `enable_dnf` | `bool` | `true` | Enable checking and upgrading DNF5 system packages. |
| `enable_flatpak` | `bool` | `true` | Enable checking and upgrading Flatpak applications. |
| `include_flatpak_runtimes` | `bool` | `false` | Include Flatpak runtime and extension updates alongside applications. |
| `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. |
| `glyph` | `glyph` | `package` | Widget icon glyph displayed in the bar. |
## IPC
Trigger background service operations using `noctalia msg`:
```sh
# Check for updates immediately
noctalia msg plugin strob3/dnf-updater:service service check
# Apply updates for all pending packages
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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
id = "strob3/dnf-updater"
name = "DNF Updater"
version = "1.1.0"
plugin_api = 43
author = "strob3"
license = "MIT"
icon = "package"
description = "Manage DNF and Flatpak updates."
dependencies = ["dnf5", "flatpak", "pkexec"]
tags = ["bar", "panel", "service", "system", "fedora", "utility"]
[[setting]]
key = "check_interval"
type = "int"
label_key = "settings.check_interval.label"
description_key = "settings.check_interval.description"
default = 30
min = 0
max = 10080
[[setting]]
key = "enable_dnf"
type = "bool"
label_key = "settings.enable_dnf.label"
description_key = "settings.enable_dnf.description"
default = true
[[setting]]
key = "enable_flatpak"
type = "bool"
label_key = "settings.enable_flatpak.label"
description_key = "settings.enable_flatpak.description"
default = true
[[setting]]
key = "include_flatpak_runtimes"
type = "bool"
label_key = "settings.include_flatpak_runtimes.label"
description_key = "settings.include_flatpak_runtimes.description"
default = false
[[setting]]
key = "show_widget_when_clean"
type = "bool"
label_key = "settings.show_widget_when_clean.label"
description_key = "settings.show_widget_when_clean.description"
default = true
[[setting]]
key = "notify_on_updates"
type = "bool"
label_key = "settings.notify_on_updates.label"
description_key = "settings.notify_on_updates.description"
default = true
[[setting]]
key = "update_in_terminal"
type = "bool"
label_key = "settings.update_in_terminal.label"
description_key = "settings.update_in_terminal.description"
default = true
[[service]]
id = "service"
entry = "service.luau"
[[widget]]
id = "widget"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "package"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 420
height = 500
placement = "attached"
open_near_click = true
@@ -0,0 +1,25 @@
/* Noctalia DNF Updater - Passwordless Update Policy
*
* Grants authorization for the dedicated dnf-updater-helper script
* to active local users in the wheel or sudo group.
*
* The helper strictly restricts operations to 'upgrade' with validated
* exclusions, preventing arbitrary command execution as root.
*
* Install:
* curl -fsSL https://raw.githubusercontent.com/strob3/dnf-updater/main/scripts/install.sh | sudo bash
* (or locally: sudo ./scripts/install.sh)
*
* Remove:
* sudo ./scripts/install.sh --uninstall
*/
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.policykit.exec" &&
action.lookup("program") == "/usr/local/bin/dnf-updater-helper" &&
subject.active == true && subject.local == true &&
(subject.isInGroup("wheel") || subject.isInGroup("sudo"))) {
return polkit.Result.YES;
}
return polkit.Result.NOT_HANDLED;
});
@@ -0,0 +1,67 @@
#!/bin/bash
#
# DNF Updater - Privileged Update Helper
#
# Restricts privileged execution strictly to system package upgrades
# with validated exclusions. Prevents arbitrary command execution or
# malicious package installations.
#
set -euo pipefail
# Help / usage
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: dnf-updater-helper upgrade [--exclude=<pkg1,pkg2,...>]"
exit 0
fi
# Ensure standard, safe environment
export PATH="/usr/bin:/bin:/usr/sbin:/sbin"
unset IFS
unset LD_PRELOAD LD_LIBRARY_PATH PYTHONPATH PYTHONHOME DNF5_PLUGINS
# Require root
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Error: dnf-updater-helper must be run as root." >&2
exit 1
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
exit 1
fi
shift
# Validate any remaining arguments.
VALID_ARGS=()
for arg in "$@"; do
if [[ "$arg" =~ ^--exclude=[a-zA-Z0-9._+*:-][a-zA-Z0-9._+*:,-]*$ ]]; then
VALID_ARGS+=("$arg")
else
echo "Error: invalid argument rejected: '$arg'" >&2
exit 1
fi
done
# Resolve DNF binary
DNF_BIN=""
for bin in "/usr/bin/dnf5" "/usr/bin/dnf"; do
if [[ -x "$bin" ]]; then
DNF_BIN="$bin"
break
fi
done
if [[ -z "$DNF_BIN" ]]; then
echo "Error: neither /usr/bin/dnf5 nor /usr/bin/dnf was found." >&2
exit 1
fi
# Execute upgrade w/o interaction, inhibiting sleep/shutdown if systemd-inhibit is available
if command -v systemd-inhibit >/dev/null 2>&1; then
exec systemd-inhibit --what=shutdown:sleep:idle --who="dnf-updater" --why="Performing system package upgrade" "$DNF_BIN" upgrade -y "${VALID_ARGS[@]}"
else
exec "$DNF_BIN" upgrade -y "${VALID_ARGS[@]}"
fi
@@ -0,0 +1,119 @@
#!/bin/bash
#
# DNF Updater - Privileged Helper & Polkit Policy Installer
#
# Installs or uninstalls the privileged helper and Polkit rule.
# Supports both local repository invocation and remote execution via:
# curl -fsSL https://raw.githubusercontent.com/strob3/dnf-updater/main/scripts/install.sh | sudo bash
#
set -euo pipefail
DEST_BIN="/usr/local/bin/dnf-updater-helper"
DEST_POLKIT="/etc/polkit-1/rules.d/50-dnf-updater.rules"
REPO_RAW_URL="https://raw.githubusercontent.com/strob3/dnf-updater/main"
# Help / usage
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $0 [install|--uninstall]"
echo ""
echo "Commands:"
echo " install Install helper to $DEST_BIN and rule to $DEST_POLKIT (default)"
echo " --uninstall Remove helper and Polkit rule from the system"
exit 0
fi
# Verify root privileges
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "Error: Installation requires root privileges. Please run with sudo:" >&2
echo " sudo $0 $@" >&2
exit 1
fi
ACTION="${1:-install}"
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 " - $DEST_BIN"
echo " - $DEST_POLKIT"
exit 0
;;
install|--install|-i)
;;
*)
echo "Error: unknown argument '$ACTION'" >&2
echo "Usage: $0 [install|--uninstall]" >&2
exit 1
;;
esac
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)"
SRC_HELPER=""
SRC_POLKIT=""
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..."
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..."
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)..."
TMP_DIR="$(mktemp -d)"
CLEANUP_TMP=true
trap 'rm -rf "$TMP_DIR"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL "${REPO_RAW_URL}/scripts/dnf-updater-helper.sh" -o "${TMP_DIR}/dnf-updater-helper.sh"
curl -fsSL "${REPO_RAW_URL}/polkit/50-dnf-updater.rules" -o "${TMP_DIR}/50-dnf-updater.rules"
elif command -v wget >/dev/null 2>&1; then
wget -qO "${TMP_DIR}/dnf-updater-helper.sh" "${REPO_RAW_URL}/scripts/dnf-updater-helper.sh"
wget -qO "${TMP_DIR}/50-dnf-updater.rules" "${REPO_RAW_URL}/polkit/50-dnf-updater.rules"
else
echo "Error: neither curl nor wget was found to download required files." >&2
exit 1
fi
SRC_HELPER="${TMP_DIR}/dnf-updater-helper.sh"
SRC_POLKIT="${TMP_DIR}/50-dnf-updater.rules"
fi
# Install privileged helper
install -Dm755 "$SRC_HELPER" "$DEST_BIN"
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)"
# Fix SELinux context if SELinux is available
if command -v restorecon >/dev/null 2>&1; then
restorecon -F "$DEST_BIN" "$DEST_POLKIT" 2>/dev/null || true
fi
# Check if target user belongs to wheel or sudo
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"
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."
@@ -0,0 +1,983 @@
--!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
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,117 @@
{
"actions": {
"check_now": "Check Now",
"clear_search": "Clear",
"close": "Close",
"collapse": "Collapse section",
"deselect_all": "Deselect all",
"exclude_pkg": "Exclude from update",
"expand": "Expand section",
"include_pkg": "Include in update",
"open_settings": "Settings",
"search_placeholder": "Search packages…",
"select_all": "Select all",
"update_all": "Update All",
"update_all_size": "Update All ({count} · {size})",
"update_dnf": "Update DNF",
"update_flatpak": "Update Flatpak",
"update_selected": "Update Selected ({count})",
"update_selected_size": "Update Selected ({count} · {size})"
},
"notifications": {
"update_complete_body": "System updates have completed successfully.",
"update_complete_title": "Update Complete",
"update_failed_body": "An error occurred while installing updates.",
"update_failed_title": "Update Failed",
"updates_available_body": "{count} pending updates are ready to install.",
"updates_available_title": "Updates Available"
},
"sections": {
"dnf": "DNF · {count} updates",
"dnf_selected": "DNF · {selected}/{total} updates",
"flatpak": "Flatpak · {count} updates",
"flatpak_selected": "Flatpak · {selected}/{total} updates",
"runtime": "Runtime"
},
"settings": {
"check_interval": {
"description": "How often to automatically check for updates in minutes (0 to disable).",
"label": "Check Interval (Minutes)"
},
"enable_dnf": {
"description": "Check for and manage DNF5 system package updates.",
"label": "Enable DNF"
},
"enable_flatpak": {
"description": "Check for and manage Flatpak application updates.",
"label": "Enable Flatpak"
},
"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"
},
"show_widget_when_clean": {
"description": "Show the bar widget even when there are no pending updates.",
"label": "Show Widget When Clean"
},
"update_in_terminal": {
"description": "Launch update commands inside a terminal emulator instead of running in background with polkit.",
"label": "Run Updates in Terminal"
}
},
"status": {
"checking": "Checking for updates…",
"dnf_checking": "Checking DNF…",
"dnf_done": "DNF up to date",
"dnf_error": "DNF error",
"dnf_idle": "DNF idle",
"dnf_updating": "Updating DNF…",
"everything_up_to_date": "Everything is up to date.",
"flatpak_checking": "Checking Flatpak…",
"flatpak_done": "Flatpak up to date",
"flatpak_error": "Flatpak error",
"flatpak_idle": "Flatpak idle",
"flatpak_updating": "Updating Flatpak…",
"flatpak_waiting": "Flatpak waiting…",
"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",
"storage": "Storage",
"system": "System",
"total": "Total"
},
"title": "DNF Updater",
"tooltip": {
"all_up_to_date": "All packages up to date",
"disk_size": "Disk size",
"download_size": "Download size",
"updates_available": "{count} updates available"
}
}
@@ -0,0 +1,99 @@
--!nonstrict
-- Bar widget entry: displays pending update badge and status in Noctalia bar.
local glyph = noctalia.getConfig("glyph") or "package"
local showWhenClean = noctalia.getConfig("show_widget_when_clean") ~= false
local updateState = noctalia.state.get("update_state")
local function render()
glyph = noctalia.getConfig("glyph") or "package"
showWhenClean = noctalia.getConfig("show_widget_when_clean") ~= false
if not updateState then
barWidget.setGlyph(glyph)
barWidget.setText("…")
barWidget.setTooltip(noctalia.tr("status.checking"))
barWidget.setVisible(true)
return
end
local status = updateState.status or "IDLE"
local total = updateState.total_count or 0
if status == "CHECKING" and total == 0 then
barWidget.setGlyph(glyph)
barWidget.setText("…")
barWidget.setTooltip(noctalia.tr("status.checking"))
barWidget.setVisible(true)
elseif status == "UPDATING" then
barWidget.setGlyph("refresh")
barWidget.setText(total > 0 and tostring(total) or "…")
barWidget.setTooltip(noctalia.tr("status.updating"))
barWidget.setVisible(true)
elseif status == "ERROR" and total == 0 then
barWidget.setGlyph("alert-circle")
barWidget.setText("!")
barWidget.setTooltip(updateState.error_message or noctalia.tr("status.dnf_error"))
barWidget.setVisible(true)
elseif total > 0 then
barWidget.setGlyph(glyph)
barWidget.setText(tostring(total))
local tooltipEntries = {}
if updateState.reboot_required then
table.insert(tooltipEntries, { key = noctalia.tr("summary.system"), value = noctalia.tr("status.reboot_required") })
end
table.insert(tooltipEntries, { key = noctalia.tr("summary.dnf"), value = tostring(updateState.dnf and updateState.dnf.count or 0) })
table.insert(tooltipEntries, { key = noctalia.tr("summary.flatpak"), value = tostring(updateState.flatpak and updateState.flatpak.count or 0) })
table.insert(tooltipEntries, { key = noctalia.tr("summary.total"), value = tostring(total) })
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
table.insert(tooltipEntries, { key = noctalia.tr("summary.last_checked"), value = updateState.last_check or "–" })
barWidget.setTooltip(tooltipEntries)
barWidget.setVisible(true)
else
-- Total == 0 (all up to date)
if updateState.reboot_required then
barWidget.setVisible(true)
barWidget.setGlyph("rotate-cw")
barWidget.setText("")
barWidget.setTooltip(noctalia.tr("status.reboot_required"))
elseif showWhenClean then
barWidget.setVisible(true)
barWidget.setGlyph(glyph)
barWidget.setText("")
local tooltipText = noctalia.tr("tooltip.all_up_to_date")
if updateState.last_check then
tooltipText = tooltipText .. " (" .. updateState.last_check .. ")"
end
barWidget.setTooltip(tooltipText)
else
barWidget.setVisible(false)
end
end
end
-- Live updates from background service
noctalia.state.watch("update_state", function(newState)
updateState = newState
render()
end)
function update()
render()
end
function onClick()
noctalia.togglePanel("strob3/dnf-updater:panel")
end
function onRightClick()
noctalia.state.set("service_command", { action = "check_now", ts = os.time() })
end
function onMiddleClick()
noctalia.openSettings()
end
render()
Submodule .local/state/noctalia/plugins/sources/community/repo added at 08bcf05199
Submodule .local/state/noctalia/plugins/sources/official/repo added at 510773a6eb