← Back to Docs

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.

Before you start

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.

About the Example

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.

Linux
~/.config/HPR/extensions/sway-backend/sway.lua
Windows
%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.

skeleton
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.

Dangerous Commands Blocked

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.

hyprland.lua
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'")
Submit your backend

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.