← Back to Docs

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.

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.

💡 Overriding Native Backends

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.

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

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