Building a Custom Window Backend
HPR natively supports GNOME, KDE, Cinnamon, Hyprland, 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.
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 — isUsable (function)
A function that returns true if your backend can currently provide valid window data, or
false if not. HPR calls this to verify that your backend is actually functional before
committing to it. It is also called periodically to detect if the backend has stopped working.
A common pattern is to run your compositor's IPC command and check whether the output contains the field
you need. For Hyprland, a valid hyprctl -j activewindow response always contains the key
"class". If there is no active window at all, the output will be empty or
Invalid, so the check naturally returns false in those cases too.
function() local result = HPR.runSystemCommand_E("hyprctl -j activewindow") return string.find(result, "class") ~= nil end, -- argument 4
Argument 5 — 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 5
Argument 6 — 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 6
The Complete Extension
Putting all six arguments together, here is the complete working Hyprland backend extension. This is the exact
pattern to follow for any other compositor — just replace the hyprctl commands with whatever IPC
tool your compositor provides.
function init() HPR.authorName = "Plexescor" HPR.extensionName = "Hyprland Custom Backend HPR Extension" 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") return string.find(result, "class") ~= nil 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 ) 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'")
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'")
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.