Building a Custom Window Backend
HPR natively supports GNOME, KDE, Cinnamon, Hyprland, niri, and Windows. But the Linux compositor landscape is vast — Sway, Niri, River, Wayfire, and many others exist and are actively used. Rather than waiting for official support, you can add tracking for any compositor yourself by writing a short Lua extension and dropping it in your extensions folder.
This tutorial will walk you through every part of HPR.registerBackend_E() in detail. By the end
you will understand exactly what each argument does, why it exists, and how to write one for your own system.
The full finished example at the bottom is fewer than 30 lines.
This tutorial assumes you have a working knowledge of Lua. If you are new to Lua, please read the Programming in Lua guide first. No C++ knowledge is required — you will only write Lua code.
All examples in this tutorial use Hyprland as the demonstration compositor because it has a clean,
well-documented IPC tool called hyprctl that makes the code easy to read. Hyprland is
already natively supported by HPR — we are only using it here as a teaching example. The same pattern
applies directly to Sway (swaymsg), Niri (niri msg), River
(riverctl), or anything else that exposes an IPC command.
If you write a Lua extension for a compositor or desktop environment that is already natively supported by HPR (such as Hyprland, GNOME, KDE, Cinnamon, niri, or Windows), your extension's implementation will be preferred and prioritized over the built-in one!
Here's how it works under the hood: HPR maintains a list of all registered window tracking backends. During startup, it evaluates this list in reverse order (from newest to oldest). Since Lua extensions are loaded after HPR's native backends are registered, your custom backend gets positioned at the end of the list. If it matches the environment, HPR will select your backend and skip the native one entirely. This makes it incredibly easy to customize or override window-tracking logic on any system without ever modifying the C++ source code!
Step 1 — Create your extension file
Create a new file anywhere inside your HPR extensions folder. The name doesn't matter as long as it ends in
.lua. You can also create a subfolder to keep things organized.
~/.config/HPR/extensions/sway-backend/sway.lua
%APPDATA%\HPR\HPR_Config\extensions\my-backend\main.lua
Step 2 — Define the init() function
Backend registration must happen inside the init() function, not at the top level of your script
and not inside onTick(delta). HPR calls init() exactly once at startup before the
tick
loop begins. This guarantees your backend is registered before HPR starts its first window detection cycle.
If you put registerBackend_E inside onTick(delta), HPR would attempt to register the
same
backend once every second, causing duplicate registrations and undefined behavior. Always use
init() for one-time setup.
function init() HPR.registerBackend_E( -- 6 arguments go here ) end
Step 3 — The 6 arguments of registerBackend_E
The function takes 6 positional arguments in a fixed order. Here is each one explained in detail.
Argument 1 — name (string)
A unique string identifier for your backend. This is used by HPR internally for logging — you will see it
printed in the terminal as [HPR] Selected backend: YourName. It also needs to match what your
compositor puts in the $XDG_CURRENT_DESKTOP environment variable, because that is what HPR
passes into the matchesEnvironment function described next.
To find out what your compositor sets, open a terminal and run:
echo $XDG_CURRENT_DESKTOP
On Hyprland this prints Hyprland. On Sway it typically prints sway. Use that
exact string as your backend name.
"Hyprland", -- argument 1
Argument 2 — matchesEnvironment
(function)
A function that receives a single string argument env containing the current value of
$XDG_CURRENT_DESKTOP. HPR calls this function for every registered backend during startup to
decide which one to activate. Return true if env indicates your compositor is
running. Return false otherwise.
Lua's string:find returns the start index of the match on success and nil on
failure. Comparing it to ~= nil converts that into a plain boolean. This is the standard Lua
idiom for string contains checks.
function(env) return env:find("Hyprland") ~= nil end, -- argument 2
Argument 3 — initialize (function)
A function that HPR calls once after selecting your backend as the active one. This is your opportunity to do any per-backend setup — checking that required tools are installed, establishing a persistent connection if your compositor supports it, or just printing a confirmation message so you know your backend loaded correctly.
For most compositors that are queried via one-shot CLI commands like hyprctl or
swaymsg, there is nothing to initialize. You can leave the body empty or just print a
message.
function() print("Hyprland backend loaded") end, -- argument 3
Argument 4 — getCurrentWindow
(function)
This is the core tracking function. It must return a string representing the application class of
the currently focused window — things like "firefox", "code",
"kitty", or "steam". HPR uses this value to accumulate time, match aliases, and
display statistics.
The class is different from the window title. The class is a stable identifier for the application itself. The title changes constantly (it usually shows the open document or webpage). For HPR's per-app tracking, you want the class.
Using jq to parse the JSON output of hyprctl is the cleanest approach. If
jq is not available on your system you can use Lua's string manipulation functions instead,
but jq is available on virtually every Linux system that runs a modern Wayland compositor.
To prevent accidental or malicious destruction from extensions, HPR actively filters and blocks dangerous commands. If you plan/planned to use dangerous commands, FUCK OFF 🖕🖕.
Some examples of blocked commands:
rm, rmdir, chmod, sudo, su,
mkfs, fdisk, shutdown, reboot, curl,
wget, python, bash, del, reg,
apt, pip, systemctl, and common shell injection patterns like
| sh or $(rm).
function() local result = HPR.runSystemCommand_E( "hyprctl -j activewindow | jq -r '.class'" ) return result end, -- argument 4
Argument 5 — getCurrentTitle
(function)
Similar to getCurrentWindow but returns the window title — the full text shown in the
title bar, like "GitHub — Mozilla Firefox" or
"main.cpp - HPR - Visual Studio Code". HPR uses this for browser tab tracking, VS Code
project detection, and its pattern analysis engine.
Without a working title, HPR can still track which app you are using but loses the ability to distinguish between different websites in the same browser or different projects in the same editor. It is worth implementing correctly.
function()
local result = HPR.runSystemCommand_E(
"hyprctl -j activewindow | jq -r '.title'"
)
return result
end, -- argument 5
Argument 6 — getCurrentPid
(function)
Returns a string representing the process ID (PID) of the currently focused window. HPR uses this value to correlate active windows with their parent processes for advanced tracking features and terminal workspace context detection.
function() local result = HPR.runSystemCommand_E( "hyprctl -j activewindow | jq -r '.pid'" ) return result end -- argument 6
The Complete Extension
Putting all six arguments together, here is the complete working Hyprland backend extension.
Note that we define HPR.authorName and HPR.extensionName.
Declaring these metadata variables is a best practice that is EXTREMELY HIGHLY
RECOMMENDED (though not required for execution). You can place them at the top level or inside init(). If you omit them entirely, HPR will still load and run your extension successfully, but it will show up in the Loaded Extensions manager UI under a randomly generated name.
Teardown Warning: Strict 200ms exit grace period!
If you decide to add an onExit() lifecycle hook to your custom backend to perform teardown or
cleanup, ensure it completes quickly! HPR gives every extension thread a strict 200 milliseconds grace
period to complete its exit operations. Exceeding this budget causes HPR to forcefully detach the extension
thread (calling ext->thread.detach()) and force close the entire application with
_exit(0) to prevent lingering background hangs. This intentional safety measure will instantly
cut off all other running extensions, so keep your teardown logic highly optimized.
-- Declare extension metadata at the top level or inside init() (highly recommended for UI registration!) HPR.authorName = "Plexescor" HPR.extensionName = "Hyprland Custom Backend HPR Extension" function init() HPR.registerBackend_E( "Hyprland", function(env) return env:find("Hyprland") ~= nil end, function() print("Hyprland backend loaded") end, function() local result = HPR.runSystemCommand_E( "hyprctl -j activewindow | jq -r '.class'" ) return result end, function() local result = HPR.runSystemCommand_E( "hyprctl -j activewindow | jq -r '.title'" ) return result end, function() local result = HPR.runSystemCommand_E( "hyprctl -j activewindow | jq -r '.pid'" ) return result end ) end
Adapting for Other Compositors
The pattern is identical for every compositor. The only things that change are the name string, the environment check string, and the system commands. Here is a quick reference for common compositors:
Sway
Environment variable is
typically sway. Uses swaymsg for IPC with JSON output.
-- name "sway" -- matchesEnvironment env:find("sway") ~= nil -- getCurrentWindow HPR.runSystemCommand_E("swaymsg -t get_tree | jq -r '.. | select(.focused?) | .app_id // empty'") -- getCurrentTitle HPR.runSystemCommand_E("swaymsg -t get_tree | jq -r '.. | select(.focused?) | .name // empty'") -- getCurrentPid HPR.runSystemCommand_E("swaymsg -t get_tree | jq -r '.. | select(.focused?) | .pid // empty'")
Niri
Environment variable is
typically niri. Uses niri msg for IPC.
-- name "niri" -- matchesEnvironment env:find("niri") ~= nil -- getCurrentWindow HPR.runSystemCommand_E("niri msg focused-window | jq -r '.app_id // empty'") -- getCurrentTitle HPR.runSystemCommand_E("niri msg focused-window | jq -r '.title // empty'") -- getCurrentPid HPR.runSystemCommand_E("niri msg focused-window | jq -r '.pid // empty'")
If you write a working backend for a compositor not listed here, consider submitting it to the HPR GitHub. Community-contributed backends will be listed on this site so other users on the same compositor can find them.