Hey friend! Let's build something awesome together.

QuickStart Guide

Writing extensions for HPR is incredibly simple once you get the basics down. Let's skip the dry theory and build your very first working tracker in 3 minutes flat!

Where do files go?

Before doing anything, find your system's designated extensions folder. HPR will look inside this directory recursively when it boots up:

🐧 Linux

~/.config/HPR/extensions/

πŸͺŸ Windows

%APPDATA%\HPR\HPR_Config\extensions\

The Absolute 5-Line Minimum

Create a file called hello.lua in that folder. To load successfully, HPR always needs metadata variables defined right at the top. Here is the absolute smallest extension that runs:

hello.lua
HPR.authorName = "DevFriend"
HPR.extensionName = "Mini"
function init()
    print("Hello from inside HPR!")
end

Do I really need HPR.authorName and HPR.extensionName?

Technically, these lines are not strictly required to make your code run β€” your script will still execute fine without them! However, defining them is strongly recommended. They register your extension inside HPR's Extensions tab, which is also where hot reloading lives. If you skip them, HPR will still load and run your extension, but it will show up in the Extensions tab with a randomly generated name β€” so you can still Reload or Disable it, but good luck figuring out which one it is if you have multiple unnamed extensions. Make sure to define them at the top level of your script and not inside a function, otherwise HPR won't be able to read them.

Hot Reloading

HPR supports hot reloading extensions without restarting the app! Head to the Extensions tab in the UI where you will find three buttons:

RESCAN β€” Scans your extensions folder recursively for any new .lua files you dropped in (including inside subfolders) and loads them instantly. Also brings back any extensions you previously disabled.

RELOAD β€” Restarts a specific extension from scratch. If you edited an extension's code and want the changes to take effect, hit this.

DISABLE β€” Stops a specific extension and removes it from the list. The .lua file stays on disk, so hitting RESCAN will bring it back whenever you want.

Note: Extensions without HPR.authorName and HPR.extensionName will still appear in the Extensions tab, but with a randomly generated name. You can still Reload or Disable them β€” just harder to tell apart if you have multiple unnamed ones.

Ticking and Getting the Active Window

Let's make our extension actually do something useful. By returning a number from init(), we can set a tick interval (in milliseconds) and receive window details inside onTick(delta):

hello.lua
HPR.authorName = "DevFriend"
HPR.extensionName = "ActiveWatcher"

function init()
    return 1000 -- Ticks every 1000ms (1 second)
end

function onTick(delta)
    local currentApp = HPR.getCurrentWindow_E()
    print("Currently focused: " .. currentApp .. " | Delta: " .. delta .. "ms")
end

Breaking Down the Timing

Let's make sure we understand exactly how HPR manages timing under the hood:

  • What is the return value of init()?
    It sets the tick schedule (in milliseconds) specifying how frequently the HPR thread manager will call your onTick() function. It is important to know that if you don't return anything from init(), HPR defaults to firing onTick() every 1000ms (1 second).
  • What is the delta parameter in plain english?
    The delta parameter represents the actual, real-world milliseconds that have passed since your last tick. In a perfect world, if you return 1000 from init(), delta would be exactly 1000.0. But if your operating system is heavily loaded, performing CPU-heavy compilation, or scheduling background writes, the thread sleep might run slightly late. For example, your tick might fire at 1023ms. Using the actual delta value instead of assuming a constant 1000ms lets you perform incredibly precise timing calculations!

The Complete, Safe Masterpiece

First, what is HPR's Event Hub?

Under the hood, HPR is a beautifully multi-threaded beast. It runs several independent systems all at the same time on separate background threads: the window tracker, the database writer, the daily midnight rollover scheduler, and your custom Lua extensions. The Event Hub is HPR's internal messaging backbone that lets all these isolated systems talk to each other by broadcasting signals.

Think of the Event Hub like a cork bulletin board in the middle of HPR's headquarters. Any system can walk up and pin a message to it. Any extension that is subscribed to that specific message type gets notified instantly the exact millisecond it is pinnedβ€”no periodic checking required! Instead of your extension constantly asking "hey, did the window change yet?" every single second, HPR's window tracker itself walks up and taps your extension on the shoulder the instant a change occurs.

Polling (onTick) vs. Event-Driven (connect_E)

The onTick polling approach we built in Step 3 works fine, but let's be honest: it is kind of dumb. Your extension has to wake up every few hundred milliseconds, run code to check the state, and go back to sleepβ€”even if you've been working in the exact same text editor window for three hours straight! That is a waste of clock cycles.

Event-driven programming is way smarter. Your callback function only wakes up and executes when something actually happens in the real world. For window tracking in particular, events are the correct tool because users do not switch windows on a fixed, predictable schedule.

How connect_E works (and your subscription receipt)

To tune in, we call HPR.connect_E(). This function takes two arguments: the name of the event string we want to listen to, and a callback function to run when the event occurs.

Crucially, HPR.connect_E() returns a subscription ID integer. Think of this ID as your claim receipt. It is the only way HPR knows how to identify your listener, and it is the only way you can cancel that subscription later. Because of this, you should always save this ID in a variable at the very top of your script so that your entire file has access to it.

HPR's Automatic RAII Connection Tracking

When HPR runs your extension, it spins up an isolated Lua virtual machine. While this VM is alive, your callback function exists in memory, and HPR's C++ event registry holds a reference to that Lua function.

To prevent classic use-after-free bugs and segmentation faults (segfaults) upon extension shutdown, HPR's C++ manager features an elegant, automatic RAII lifecycle tracking architecture. The core engine dynamically registers and monitors every Event Hub subscription established by your extension.

When your extension is stopped, disabled, or reloaded, HPR automatically and cleanly disconnects all active subscriptions from the C++ event loop before the Lua VM is destroyed. Manual cleanup via HPR.disconnect_E() is completely optional but still a great practice to keep your code organized!

What does WINDOW_CHANGED actually pass to us?

When the WINDOW_CHANGED event fires, the Event Hub passes a table with detailed window information directly to your callback function's argument. Specifically, this table contains two highly useful string properties: data.fromWindow (the class name of the window you just left) and data.toWindow (the class name of the window you just focused, e.g. "chrome", "code", or "spotify"). This makes writing complex, event-driven window transitions effortless!

Here is the complete, robust, and safe template. Copy and paste it to see events in action:

hello.lua
HPR.authorName = "DevFriend"
HPR.extensionName = "EventMaster"

local changeSubId = nil

function init()
    print("Starting EventMaster...")
    
    -- Listen to a REAL built-in event when focus changes.
    -- data will be a table containing fromWindow and toWindow strings
    changeSubId = HPR.connect_E("WINDOW_CHANGED", function(data)
        print("[Event] Active application class switched to: " .. data.toWindow)
    end)
    
    return 3000 -- Run onTick every 3 seconds
end

function onTick(delta)
    print("[Tick] Tick delta: " .. delta .. "ms")
end

function onExit()
    print("Shutting down cleanly...")
    
    -- Optional/Best Practice: Unregister event hook (HPR automatically disconnects on exit)
    if changeSubId then
        HPR.disconnect_E("WINDOW_CHANGED", changeSubId)
    end
end

You built it!

Look at you go! In just a few minutes, you've gone from an empty text file to writing fully event-driven, multi-threaded C++ integrations.

You now know the three core lifecycle hooks (init, onTick, and onExit) which are the absolute bread and butter of every HPR extension ever written. You understand how the Event Hub handles message passing under the hood, and crucially, you know how to avoid use-after-free segfaults like an absolute pro. From here, the only real limit is what you decide to build!

What's Next?

Full API Reference β†’

Every single function HPR exposes to Lua, documented in detail.

Building a Custom Window Backend β†’

Add tracking support for any compositor or desktop environment.

Active Media Monitoring & Spotify Tracker β†’

Track Spotify playback, build SQL schemas, push data to the Slint UI.

Event Hub & Burnout Detector β†’

Make extensions talk to each other and trigger OS notifications.

AW Parasite Extension Tutorial β†’

Use HPR's HTTP client and JSON parser to import live browser activity.

Function Override API β†’

Intercept and replace 26 core C++ engine functions directly from Lua. For advanced extension developers.

Common Mistakes

🚨 Refer to this section if you have no idea what the fuck is going on.

Here are three classic traps beginners fall into, along with how to solve them like a pro:

1. Forgetting to save subscription IDs (when early/dynamic disconnection is needed)

Why it matters: Previously, failing to manually call HPR.disconnect_E() upon exit was a severe issue that caused use-after-free crashes. Fortunately, HPR's C++ manager now features automatic RAII connection tracking, meaning all active listeners are automatically unregistered when your extension stops, completely preventing any shutdown segfaults.

However, keeping track of your subscription IDs is still highly recommended! If you need to stop listening to an event dynamically mid-execution (for example, after a specific state is reached, or to switch behaviors), you will still need to invoke HPR.disconnect_E() using the saved subscription ID.

❌ Outdated / Loose Code
-- Subscribed, but no ID saved to disconnect early
function init()
    HPR.connect_E("WINDOW_CHANGED", callback)
end
βœ… Best Practice Code
local subId = nil

function init()
    subId = HPR.connect_E("WINDOW_CHANGED", callback)
end

function onExit()
    -- Explicit cleanup is still a great practice!
    if subId then
        HPR.disconnect_E("WINDOW_CHANGED", subId)
    end
end

2. Treating CSV paths as relative to your subfolder

Why it happens: Because HPR is sandboxed for safety, all filesystem interaction methods (like `readCsv_E`, `writeCsv_E`, etc.) are resolved relative to the global extensions root directory (e.g. ~/.config/HPR/extensions/).

If your extension is organized inside a subfolder (like extensions/cool-addon/script.lua), writing a file to "settings.csv" won't save it inside your subfolder. Instead, it will create it right at the global root extensions directory. To resolve this perfectly, HPR exposes the HPR.getExtensionDir_E() function, which dynamically returns the relative path (from the extensions root) to your specific subdirectory so you can write bulletproof path code!

❌ Wrong Code
-- Saves at global root (extensions/settings.csv)
local data = HPR.readCsv_E("settings.csv")
βœ… Correct Code
-- Dynamically resolve paths relative to your folder
local myDir = HPR.getExtensionDir_E()
local data = HPR.readCsv_E(myDir .. "settings.csv")

3. Using "localhost" instead of "127.0.0.1"

Why it happens: Modern operating systems use Dual-Stack sockets and prioritize IPv6 by default. When you provide the hostname string "localhost", the DNS lookup layer resolves it to the IPv6 loopback address (::1).

However, most legacy applications and local helper APIs (including standard ActivityWatch server backends) bind strictly to the IPv4 address 127.0.0.1. Targeting "localhost" causes HPR's request to bounce off the IPv6 address and time out, resulting in empty responses or instant 502/refused connection errors!

❌ Wrong Code
-- Fails or times out on IPv6 loopback resolution
local body, status = HPR.httpGet_E(
    "localhost:5600", 
    "/api/0/buckets", 
    false
)
βœ… Correct Code
-- Bypasses DNS lookup and hits IPv4 directly
local body, status = HPR.httpGet_E(
    "127.0.0.1:5600", 
    "/api/0/buckets", 
    false
)

4. Blocking the extension thread

Why it happens: Each extension runs on its own dedicated C++ background thread. If your callback or onTick blocks for a long time β€” for example, a slow HTTP request without a timeout β€” that extension's tick schedule falls further and further behind. The core tracker and all other extensions keep running fine, but yours will lag.

Keep callbacks fast. For HTTP requests, always pass a sensible timeout via the timeout parameter of httpGet_E / httpPost_E so your extension doesn't hang indefinitely waiting on a dead server.