DEV Community

Aditya K
Aditya K

Posted on

The Ugly Truth About Linux Packaging: I Spent a Month Building a Screen Time Tracker

I built a screen time tracker for Linux. The code took a month. Shipping it took three more days.

Here's what happened.

The idea

Android has Digital Wellbeing. It tracks your screen time, and when you hit your daily limit on an app, it locks you out. It is simple, invasive in a good way, and highly effective.

The Linux desktop had nothing like this. ActivityWatch tracks your usage beautifully, but it does not stop you from doomscrolling. GNOME 48 added a screen time panel, but it only shows a total number—no per-app limits, no lockout screens, no focus timers.

So I decided to build it. I chose Flutter for the dashboard GUI, Dart for a lightweight background daemon, and a custom GNOME Shell extension to talk to Wayland. The concept was straightforward: poll the display server for the active window, log the duration to a local SQLite database, and when a limit is hit, trigger a desktop notification, minimize the window, and overlay a lockout screen.

The month nothing worked

The development started in June. By the first week of July, I had a working prototype. Or so I thought.

It worked perfectly under "laboratory conditions"—which is a polite way of saying it worked only on my laptop, while running manually from my terminal, with my specific system packages. The moment I tried to make it run cleanly as a background service, my system started fighting me.

For the next four weeks, my life was a slow-motion cycle of dev system hell.

The Flutter SDK would clash with system-installed libraries. The Dart AOT compiler refused to compile because of a missing system dependency. The GNOME Shell extension wouldn't load because AT-SPI accessibility wasn't enabled. SQLite WAL mode would lock the database when the GUI and the daemon tried to write at the same time.

I wasn't building features anymore. I was just wrestling with my own operating system. I committed locally to my laptop after every tiny, frustrating fix—dozens of commits that nobody will ever see.

By mid-August, the app was finally stable. I created a public GitHub repository, squashed that entire month of local commits into a single, clean commit titled bf13b7c - Initial release of ScreenGuard v0.1.0, and pushed it live.

I felt like a genius. The code was done. The app was perfect.

Then I had an incredibly stupid idea: What if beginners could install this by copying and pasting a single command?

Day 1: RPM vs. The Single Character

I decided to set up native package repositories for Ubuntu (APT), Fedora (Copr), and Arch Linux (Pacman).

I started with Fedora. I wrote an RPM spec file to build the project on Fedora Copr. The very first build failed immediately. The error was completely unintelligible. After an hour of digging, I found the culprit: the summary line in my spec file had a non-ASCII em-dash (—) instead of a standard hyphen (-). The Copr parser choked on it.

I changed the character and rebuilt. Failed again. This time it was a %setup name collision; the release tarball's directory structure didn't match what the build script expected. Fixed that. Rebuilt. Failed again. The source URLs were pointing to the wrong workspace path in the runner environment.

Because Copr builds from the git repository, I had to push a new commit to GitHub to test every single fix. My clean git history was instantly replaced by a public, chaotic cry for help. It took six commits just to compile a basic RPM.

Day 2: APT and Arch take turns

On Day 2, Debian and Arch took turns kicking me.

For the Debian package, apt-ftparchive kept failing inside my headless GitHub Actions runner. Why? It turned out the runner container didn't have apt-utils installed. I added it. Rebuilt. The GPG signing script broke because GPG was expecting an interactive pinentry passphrase prompt in a headless environment. I had to learn how to configure loopback pinentry in CI. Rebuilt. The GPG key was written to the wrong relative directory because of a cd command in the workflow.

Over on Arch, the package builder couldn't find the desktop icon. The PKGBUILD builder runs in an isolated directory and cannot traverse parent paths (../) to find local assets. I had to bundle the icon inside the source tarball. Next build failed because Arch was automatically generating unneeded "debug" packages which messed up my repository signing script. I had to explicitly disable debug packaging.

Another ten commits pushed publicly, each one representing a different GPG key path error or directory mismatch.

Day 3: The binary that refused to run

On Day 3, the packages finally compiled. I installed the Arch package on a test machine. The GUI opened, looking beautiful. But the background daemon—the actual brain of the tracker—refused to start.

It ran fine when compiled locally. But the packaged version immediately printed Usage: dartvm and exited with code 255.

I spent two days in a dark room. I checked systemd environment variables. I checked library paths. I checked system paths. I rebuilt the daemon from source five times. I ran it through strace to trace system calls. Nothing.

Finally, I pushed a debug commit to output the daemon binary's SHA-256 hash inside the Arch packaging container. That's when the ugly truth hit me: the binary was 6.7MB when compiled locally, but only 5.6MB inside the package.

It was being stripped.

Arch's makepkg runs strip by default on all binaries to save space. But strip doesn't just remove debug symbols from a Dart AOT binary—it corrupts the internal heap structure of the Dart virtual machine.

One line in the PKGBUILD fixed it:

options=(!debug !strip)
Enter fullscreen mode Exit fullscreen mode

The daemon went from corrupted to working instantly. I added xprintidle as a hard dependency, and the system was finally live.

37 public commits in three days.

The ugly truth

Packaging and fixing builds was 70% of the work. Writing the app was the easy part.

Writing code is logical. It follows the rules of your compiler. Packaging is a dark, fragmented world of GPG keys, undocumented package manager quirks, silent binary corruption, and headless CI environment failures.

And the ultimate kicker? After all of that effort, the app only works on X11 and GNOME Wayland.

KDE Wayland? Nope. XFCE? Nope. Hyprland or Sway? No. GNOME Wayland works because we wrote a custom GNOME Shell extension that exports active windows over D-Bus. Every other compositor has its own completely different protocol. You cannot simply "support Linux." You have to pick a specific desktop environment and hope enough people use it.

This is why Linux doesn't have nice, polished consumer apps. It's not because developers don't want to build them. It's because shipping them natively is an exercise in absolute pain.

I almost gave up.

btw ..u can try out the app here: adityakrishnan005-a11y/ScreenGuard

Am I wrong?

Is Linux packaging actually fine and I just made a mess of it? Is there a better, unified way to ship native background daemons and extensions across distros that I am completely missing?

Tell me in the comments.

Top comments (0)