I spent the last month building what should have been a simple update checker for my G4 Fedora 44 box. Turned into a proper rabbit hole once I discovered the system was hanging after three days of suspend. Here’s what I learned.
The Problem
I run a lot of machines. Rather than babysitting each one for updates, I wanted something like CachyOS has: a tray icon that checks hourly, shows a green dot when up to date, red when updates are pending, and handles the background automation so I never have to think about it.
Sounds simple, right? Well, I built it. And it broke my suspend after three days. System would hang on login screen, couldn’t get to Plasma, TTY only. Hard reboot was the only way out.
The Architecture
The updater works as three separate pieces that talk to each other:
1. The main script (bash, runs manually or via systemd) Does the actual work: dnf sync, flatpak update, cruft cleanup, kernel trimming.
2. The checker (bash, runs hourly via systemd timer) Counts pending updates and writes the numbers to cache files. Also fires desktop notifications.
3. The tray (Python, AppIndicator3, user-level systemd service) Reads the cache files every 5 seconds and shows the appropriate icon: yellow while checking, red if updates found, green if all clear.
They communicate through files in /var/cache/linuxtweaks/. No D-Bus complexity, no pipes, just files. Dead simple.
The Main Bash Script
#!/usr/bin/env bash
set -uo pipefail
This runs the whole thing with strict error checking. Any unset variable or failed command exits immediately. Keeps you from silently corrupting your system.
Colours and output helpers
nc='\033[0m'
red='\033[0;31m'
grn='\033[0;32m'
ylw='\033[1;33m'
msg() { echo -e "${blu}➤${nc} $*"; }
ok() { echo -e "${grn}✔${nc} $*"; }
warn() { echo -e "${ylw}⚠${nc} $*"; }
err() { echo -e "${red}✖${nc} $*"; }
I’ve been doing this for years across every script I write. Makes terminal output readable at a glance: blue arrow for progress, green checkmark for success, yellow warning, red error. The head() function adds visual section breaks.
Configuration block
app_dir="/usr/local/bin/LinuxTweaks"
cache_dir="/var/cache/linuxtweaks"
log_file="/var/log/linuxtweaks-fedora-updater.log"
kernels_to_keep=2
Everything is parameterized. If paths ever change, you only update this one block. The checker, tray, timer, all reference these same values during install.
The do_install function
This is where it gets interesting. The script is self-installing. You run:
sudo /usr/local/bin/LinuxTweaks/fedora-updater.sh --install
And it does all this in one shot: downloads the icon, installs the bash checker script, installs the Python tray, creates systemd units, creates the desktop entry. This self-installing pattern means you can literally just drop the script on a machine and run it once. Everything else is automated from there.
The Checker Script
This runs every hour and writes status to cache files.
dnf makecache -q 2>/dev/null || true
dnf_updates=$(dnf check-update -q 2>/dev/null | grep -Ev '^\$|^Last metadata')
dnf_count=$(echo -n "$dnf_updates" | grep -c '^[A-Za-z0-9]' || true)
echo "$dnf_count" > "$cache_dir/dnf-count"
It counts DNF updates by parsing the check-update output. The grep filters out header noise. Writes the count to a file that the tray reads. Same pattern for flatpak, kernel pending state, and reboot requirements. Each gets its own cache file.
Then it fires a desktop notification if the count changed from last time. This is how you first find out updates are available without having to look at the tray.
The Critical Fix: Persistent=false
This is where I almost lost it.
The original timer had Persistent=true. This means: if systemd misses a scheduled run because the system was asleep, it replays all the missed runs when it wakes up.
Over three days of suspend, that’s roughly 72 missed hourly runs. On resume, all 72 try to fire at once. All 72 hit DNF simultaneously. DNF deadlocks on the package cache lock. System hangs. Login screen freezes.
The fix:
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
Persistent=false
RandomizedDelaySec=2min
Persistent=false means: don’t replay missed runs. Just start fresh.
RandomizedDelaySec=2min means: add a random 0-2 minute delay to each run, so if the system does wake up and multiple checks try to run, they’re staggered instead of piling on at the same instant.
This took me three days to figure out. The error logs showed nothing. The system just quietly hung. No way to know the timer was the culprit without digging deep into systemd behavior.
The Tray: Python AppIndicator3
I chose AppIndicator3 because it’s what CachyOS uses, and because it speaks native D-Bus StatusNotifierItem. Unlike the old GTK StatusIcon, it doesn’t require XWayland on Wayland systems.
GREEN = (0.16, 0.68, 0.32, 1.0)
RED = (0.86, 0.16, 0.16, 1.0)
YELLOW = (1.0, 0.84, 0.0, 1.0)
The app composites coloured dots onto the icon. Green dot = up to date. Red dot = updates available. Yellow dot = currently checking.
The is_checking detection
def is_checking():
"""Check if fedora-update-check.sh OR fedora-updater.sh --full is running"""
try:
result = subprocess.run(["pgrep", "-f", "fedora-update-check.sh"],
capture_output=True, timeout=1)
if result.returncode == 0:
return True
result = subprocess.run(["pgrep", "-f", "fedora-updater.sh.*--full"],
capture_output=True, timeout=1)
return result.returncode == 0
except Exception:
return False
This detects if either the hourly checker or a manual full update is running. If yes, show yellow.
Why does this matter? Without it, you never see the yellow state because the checker runs too fast. By the time the tray wakes up to refresh, checking is already done. The yellow dot would flash past and you’d never notice.
The fix was two-fold: detect the running process with pgrep, and refresh the tray’s display every 5 seconds instead of 30.
REFRESH_MS = 5 * 1000 # 5 seconds
Now you actually see yellow while checking happens.
The refresh loop
def refresh(self):
checking = is_checking()
dnf_count = read_int(f"{CACHE_DIR}/dnf-count")
flatpak_count = read_int(f"{CACHE_DIR}/flatpak-count")
total = dnf_count + flatpak_count
if checking:
self.status_item.set_label("🟡 Checking for updates...")
if self.has_checking_icon:
self.indicator.set_icon_full(ICON_CHECKING, "Checking...")
elif total > 0:
self.status_item.set_label(f"🔴 {total} update(s) available")
if self.has_alert_icon:
self.indicator.set_icon_full(ICON_ALERT, "Updates available")
else:
self.status_item.set_label("🟢 System up to date")
if self.has_ok_icon:
self.indicator.set_icon_full(ICON_OK, "Up to date")
return True
Priority order: yellow (checking) beats red (updates) beats green (clean).
This runs every 5 seconds. It reads the cache files, checks if any update processes are running, and updates the menu label and icon accordingly.
Service Configuration: Timeout Protection
[Service]
Type=oneshot
ExecStart=/usr/local/bin/LinuxTweaks/fedora-update-check.sh
TimeoutStartSec=90
TimeoutStopSec=10
Here’s a lesson I learned the hard way: systemd services hang forever by default if the process doesn’t exit.
If DNF deadlocks or gets stuck, the checker will sit there forever waiting. The tray will show outdated status. Nothing moves.
TimeoutStartSec=90 means: give the checker script 90 seconds to start and do its work. After 90s, force-kill it.
TimeoutStopSec=10 means: when stopping, wait 10 seconds for clean shutdown. After that, SIGKILL.
This prevents one hung checker from cascading into system-wide issues.
Running Updates Manually
sudo /usr/local/bin/LinuxTweaks/fedora-updater.sh --full
This runs all four phases in sequence: do_dnf, do_autoremove, do_flatpak, do_cruft.
You can run individual phases too if you only want specific updates.
after_run: Immediate Green
after_run() {
head "Done"
ok "Log: $log_file"
# Reset ALL cache counters immediately
echo 0 > "$cache_dir/update-count"
echo 0 > "$cache_dir/dnf-count"
echo 0 > "$cache_dir/flatpak-count"
# Force immediate tray refresh
if [[ -x "$checker_dest" ]]; then
"$checker_dest" > /dev/null 2>&1 || true
fi
notify "System sync complete."
if [[ "$(cat "$cache_dir/reboot-required" 2>/dev/null)" == "1" ]]; then
warn "A newer kernel was installed ... reboot when convenient."
else
ok "No reboot required — tray is now showing green."
fi
}
This runs after all four update phases finish. It resets the counters to zero. This forces the tray to immediately turn green, because it’s reading the cache files every 5 seconds and will see the zero counts right away. No waiting 30 seconds for the next cycle.
The user sees: run updates, watch the tray, see it go red (there are updates), then green (all done).
Why This Matters
Before I built this, I was manually checking for updates every few weeks. That’s how you end up running old software with known vulnerabilities. I wanted something that Just Works in the background, shows me status at a glance, and never breaks the system.
The three-day suspend hang taught me that automation at scale is dangerous if you don’t understand the semantics. Persistent=true seems innocent until your system has been asleep for three days and 72 tasks queue up.
The 5-second refresh interval and is_checking detection taught me that users want immediate feedback. Seeing the yellow dot while checking happens is satisfying. Without it, the system feels broken.
The cache file approach taught me that shared memory is simpler than D-Bus complexity. Three processes reading and writing files is more reliable than trying to coordinate signals and subscriptions.
Lessons Learned
Persistent=true is dangerous for long-suspend systems. Always use Persistent=false if you have mobile devices or machines that sleep days at a time.
Detect running processes to give real-time feedback. Users need to see yellow while checking happens, not guess.
Keep refresh intervals short for interactive systems. 5 seconds feels alive. 30 seconds feels dead.
Use timeouts on systemd services. One hung process shouldn’t bring the system down.
Cache files are simpler than message passing. Three bash processes writing to /var/cache is more robust than D-Bus coordination.
Self-installing scripts mean you never have to remember how to deploy them. One command sets everything up.
The updater has been running solid for two weeks now. No hangs, no deadlocks, no missed notifications. The suspend test is coming, but I’m confident the Persistent=false fix will hold.