I Built a Native Wayland Activity Tracker in C++23 at 16 — HPR's Journey from 10MB Hobby Project to a Real Tool
Six weeks ago HPR didn't exist. Today it has an AUR package, a Lua 5.4 extension engine, native backends for Hyprland, GNOME, KDE, Cinnamon, and niri, a real user on Fedora GNOME 50, and its first Ko-fi donation.
This is the honest story of how that happened.
Why I built it
I'm Plexescor. I'm 16, self-taught, living in Hanumangarh, India. I wanted to know where my time actually went while coding. Every existing option was either:
- A Python daemon eating 200MB of RAM
- A cloud service requiring an account
- An Electron app
- X11-only with broken Wayland support
I run Hyprland. None of them worked properly. So I built my own.
v0.2 — The thing that actually ran
The first public release was embarrassingly simple. It tracked your active window, accumulated time per app, and wrote everything to SQLite. That's it.
But it worked. On Hyprland, GNOME, KDE, and Windows. The cross-platform part was the hardest — each compositor exposes window focus completely differently:
-
Hyprland:
hyprctl activewindowIPC call - GNOME Wayland: no native API, required a custom shell extension
-
KDE: KWin D-Bus scripting via
qdbus6 -
Windows:
GetForegroundWindow()Win32 API
Four completely different implementations for one question: "what window is focused right now?"
RAM was ~10MB. CPU was 1-3%. Startup was instant. The architecture was already right even if the features weren't there yet.
v0.3 — Foundation work nobody sees
This release was almost entirely backend refactoring. I rewrote the event system into a proper pub/sub EventHub — a centralized in-process bus with typed std::variant payloads. Every thread that needed to talk to another thread went through it instead of directly touching shared state.
The shared state model also got locked down properly:
namespace AppState {
struct AppState {
std::string currentWindow;
std::map<std::string, uint64_t> timeLog_PerApp;
std::map<std::string, uint64_t> timeLog_PerTab;
std::map<std::pair<std::string, std::string>,
std::vector<uint64_t>> switchHistory;
};
extern AppState state;
extern std::mutex stateMutex;
}
Lock, copy, release, work on the copy. One rule. Every thread follows it.
Not glamorous. Nobody cared. But v0.69's extension engine stress testing 1000 simultaneous threads only worked because the foundation was solid.
v0.4 — First version that felt real
Historical data view via date picker. Insights engine. Custom themes via interpreted .slint mode.
The interpreted mode deserves explanation. Slint normally compiles your UI into the binary at build time. I added a second code path that loads .slint files from disk at runtime instead. Users can now modify HPR's entire visual design without touching C++ or recompiling. Just edit the file, hit RELOAD UI, changes are live.
This was also the release where I made the architectural decision that still holds: one .db SQLite file per day, one folder per month.
~/.local/share/HPR/HPR_DB/
05-26/
01-05-26.db
02-05-26.db
A normal day is 19KB. A full year is under 50MB. You can open any specific day in DB Browser for SQLite without touching HPR. Delete last month by deleting the folder. No export step, no proprietary format.
v0.5 — Going open source
HPR originally had a free tier and a premium closed-source tier. I killed that entirely in v0.5.
Everything, current and future, is free forever. The premium version's source got merged into the main repo.
This release also added browser tab tracking without any browser extension — HPR just reads the window title. When Chrome is focused, the title contains the tab name. Parse it. Done. Works on every supported platform because they all already had window title getters.
KDE Fedora users also stopped being second-class citizens. I added auto-detection for qdbus6 vs qdbus-qt6 vs qdbus at startup so it just works regardless of distro.
v0.6 — The release that made HPR actually useful
Three things shipped together that changed what HPR was:
VS Code project tracking. No extension. No plugin. VS Code puts the project name in its window title: filename - projectname - Visual Studio Code. Strip the suffix, find the last separator, extract the project name. Works everywhere already because the window title getter existed on every platform. Per-project time breakdowns with zero setup.
Cinnamon support. org.Cinnamon.Eval D-Bus lets you evaluate JavaScript inside the live Cinnamon process. I use it to query global.display.focus_window directly. Works on both X11 and Cinnamon's experimental Wayland session with no code branching.
Native system tray on Linux. No GTK. No Qt. Pure libdbus-1. HPR registers as org.kde.StatusNotifierItem — the same protocol Discord and Steam use. Works with Waybar on Hyprland, KDE's tray, and Cinnamon's panel out of the box.
This was also the release that landed on the AUR:
yay -S hpr
v0.69 — The extension engine
This is the release I'm most proud of technically.
HPR now ships a sandboxed Lua 5.4 extension engine. Drop a .lua file into ~/.config/HPR/extensions/ and HPR loads it automatically. Each extension runs in its own isolated VM on a dedicated OS thread. A broken extension cannot affect HPR's tracking loop, UI, or any other extension.
The minimal extension:
function onTick(delta)
print(HPR.getCurrentWindow_E())
end
That's two lines. It runs.
What you can actually do:
- Read the active window in real time
- Query live time logs per app, per tab, per VS Code project
- Run shell commands (dangerous ones are blocked at the API level with an explicit blocklist)
- Full HTTP GET/POST with custom headers
- Spin up an embedded HTTP server inside your extension
- Direct SQLite access — write your own tables, query any past day by date
- Subscribe to HPR's internal EventHub — window switches, midnight rollover, UI ready, custom events between extensions
- Push data into HPR's Slint UI and register callbacks for UI interactions
- Register a fully custom window detection backend for any compositor HPR doesn't natively support
- Override 26+ core C++ functions directly from Lua via the Function Overriding API
Stress test: 1000 simultaneous extensions. HPR started in under 300ms, settled at 5.6% CPU, database came up clean after a hard kill.
The first real extension I shipped alongside it was an ActivityWatch URL Parasite — it impersonates an ActivityWatch client and feeds HPR's tracking data into ActivityWatch's API, letting HPR users show up in AW dashboards without running AW's own watcher.
v0.7 — Hot reload everything
No more restarting HPR to pick up changes.
Each loaded extension now has RELOAD, DISABLE buttons. A top-level RESCAN button picks up new .lua files dropped into the folder without restarting. The RELOAD UI button reloads the entire Slint interface from disk without losing tracking state.
Iterate on your theme in real time. Fix an extension bug and reload it without losing your day's tracking data.
v0.8 — niri and the full feature set
niri support landed. Uses niri msg IPC. Works out of the box.
App Limits and Goals shipped as the first genuinely productivity-focused feature. Set a daily cap on any app — HPR sends a notification when you hit it and optionally force-quits the application automatically. Goals work the other way — set a minimum daily target and track progress live.
Advanced users can intercept the limit-reached event via the Function Overriding API and run custom Lua logic — suppress the notification, send a webhook, log it somewhere, anything.
Multi-day historical queries replaced the single date picker. Three modes: single day, last N days, custom date range. Multiple daily .db files merge on a background thread without pausing live tracking.
v0.9 — Pattern analysis and the timeline
The Day Construction Timeline maps your entire day onto a zoomable, scrollable canvas. Not just numbers — a visual record of exactly what you were doing and when.
Gap detection handles reboots and closed sessions properly. If HPR was closed for 5 hours, the naive approach stretches your last active app across the entire gap. HPR's reconstruction engine detects focus gaps and caps the preceding segment to 1 minute before transitioning to Unknown. Your logs stay accurate.
Advanced Pattern Analysis added 9 cross-day metrics. The one I find most useful personally: Focus Dip Hour — the specific hour where your concentration crashes and application switches spike most consistently across all your historical data. Mine is 3PM. Knowing that is useful.
The full metric list:
- Escape Pattern — average daily switches from work app to browser
- Return Rate — how often you immediately bounce back after a browser escape
- Average Focus Session duration
- Most Distracted Day of the week
- Productive Days this week
- Screen Time vs your N-day average
- Focus Dip Hour
- Deep Work Before Noon percentage
- Weekend vs Weekday work app usage
v0.9.1 — Headless mode
HPR can now run without a UI entirely. Tray-only mode. Useful for servers, minimal setups, or anyone who just wants the tracking running in the background without a window.
Where it is now
Platform support: Hyprland, GNOME, KDE Plasma 6+, Cinnamon, niri, Windows 10/11.
Install:
# Arch Linux
yay -S hpr
# Windows
# Inno Setup installer on GitHub releases
# Linux manual
chmod +x installHPRConfigAndUi.sh && ./installHPRConfigAndUi.sh && ./HPR
First donation: $20 from Jesse Kramer. Completely voluntary. For a free open source tool built by a 16-year-old. I stared at the Ko-fi notification for a while.
GitHub: github.com/plexescor/HPR
What I actually learned
The hardest part wasn't the code. The hardest part was the Wayland ecosystem. Every compositor does everything differently and documentation is either nonexistent or buried in mailing list threads from 2019. The KDE js:: filter in the window name normalizer exists because KWin's own JavaScript runtime briefly appears as the active window during injection and silently accumulates time in your logs if you don't catch it. I found that at midnight staring at weird data.
Timing bugs are subtle. Using system_clock for duration measurement is a classic mistake — NTP corrections and DST transitions corrupt your accumulated totals. HPR uses steady_clock for measurement and system_clock only for display timestamps. Different clocks, different jobs.
Ship early. v0.2 was embarrassingly minimal. I shipped it anyway. The feedback shaped everything that came after.
The architecture matters more than the features. The EventHub, the single mutex over shared state, the one-file-per-day database layout — these decisions made every subsequent feature straightforward to add. Bad early architecture would have made the extension engine impossible.
What's next
The foundation is done. What comes next is polish, more compositor backends if people ask for them, and wherever the extension ecosystem goes.
If HPR is useful to you, a Ko-fi helps keep it going. If you hit a bug, open an issue — I respond to every one.
HPR is free and open source. Built solo. C++23, Slint 1.16.1, SQLite3, Lua 5.4.
github.com/plexescor/HPR









Top comments (0)