AW Parasite Extension Tutorial
This tutorial teaches you how to write extensions that pull data from other local applications. By the end of this guide, you will know how to use HPR's native HTTP and JSON libraries to fetch external information, store it in SQLite, and render it in a custom Slint dashboard.
IMPORTANT: API Focus Notice
This tutorial is strictly for learning the HPR Extension API. You do not need to understand ActivityWatch's architecture, server models, or data buckets. ActivityWatch serves purely as a sample external data source. Throughout this guide, whenever ActivityWatch-specific code appears, you do not need to learn or understand it — simply copy and paste the code as-is to get the data stream working, and focus your attention entirely on how HPR handles the data!
Writing browser extensions is complex. Instead, we leverage the pre-existing ActivityWatch browser plugin. The plugin tracks your active browser tab and sends it to a local server on port 5600. Our HPR script queries that local endpoint, updates a database, and displays the active URL in our HPR interface.
Part 1 — Setting Up the API Helpers
To interface with external applications, your extension needs to fetch network resources and decode standard
serialized data. HPR provides high-performance, native C++ utilities directly inside the global
HPR table. Let's examine these core functions in deep detail before we begin writing the script:
HPR.httpGet_E(host, path, secure, headers?)
Performs a synchronous HTTP or HTTPS GET request using HPR's native, multithreaded network engine. This function allows your script to communicate directly with local services, devices, or remote web services.
Function Parameters:
host(string): The host address and port number. For example, local services use"127.0.0.1:5600". Caution: Always prefer using literal IPv4 addresses like"127.0.0.1"rather than"localhost". On modern operating systems, passing"localhost"triggers the system's standard name resolution (getaddrinfo), which prioritizes the IPv6 loopback address[::1]over the IPv4 address127.0.0.1. If a local server (like the ActivityWatch server or a local API script) binds strictly and exclusively to the IPv4 address127.0.0.1, any request targetinglocalhostwill fail because the OS tries to connect via[::1]first, leading to immediate connection refusals or timeouts.path(string): The resource path or API endpoint. For example,"/api/0/buckets/".secure(boolean): Set totrueto use secure HTTPS encryption; set tofalsefor plain HTTP requests.headers(table, optional): A Lua dictionary mapping header name strings to header value strings. Useful for custom headers or authorization.
Return Values:
body(string): The raw response text string returned from the server.status(integer): The HTTP status code of the response (e.g.200for success,404for not found, or500for server errors).
Usage in this Tutorial:
We will use this function twice: first, in Step 3 to pull the list of registered tracking buckets from ActivityWatch at startup, and second, in Step 4 within the tick timer to fetch the single most recent webpage tracking event.
HPR.parseJSON_E(jsonStr, [fieldPath])
A fast, high-performance JSON deserialization utility compiled directly in native C++. It avoids the high CPU overhead and garbage collection latency associated with running pure-Lua parsing libraries.
Function Parameters:
jsonStr(string): The raw, serialized JSON text string to parse.fieldPath(string, optional): A dot-separated string path (e.g."data.url"or"events[1].title"). If provided, HPR will pluck only that specific value out of the payload without allocating a full Lua table for the remaining JSON, providing significant performance optimization.
Return Values:
tableorany: A structured Lua table containing the decoded JSON hierarchy, or the specific plucked value if afieldPathwas specified. If the JSON is invalid, it returnsnil.
Usage in this Tutorial:
We will use this function to process the text returned by HPR.httpGet_E. In Step
3, we translate the raw bucket list JSON into a searchable table to identify our browser
bucket. In Step 4, we parse the active page event JSON array, enabling us to read
properties like url and title from the first event element.
HPR.toJSON_E(luaTable)
A C++ native serialization function that transforms a structured Lua table into a minified, standardized JSON string. Highly useful for preparing request bodies before sending them out.
Function Parameters:
luaTable(table): The Lua array or dictionary structure you wish to serialize.
Return Values:
jsonStr(string): A standard, minified JSON text representation of the input table.
Interactive Flow Example
Below is a practical test snippet demonstrating how these functions collaborate. It fetches an endpoint and parses a single nested property directly:
-- 1. Fetch raw JSON payload synchronously local body, status = HPR.httpGet_E("127.0.0.1:5600", "/api/0/buckets", false) if status == 200 then -- 2. Decode the raw response text directly into a Lua table local data = HPR.parseJSON_E(body) -- 3. Alternatively, extract a single specific value instantly local webBucketName = HPR.parseJSON_E(body, "aw-watcher-web-chrome.id") print("Found bucket: " .. tostring(webBucketName)) end
Part 2 — Writing the Extension
Let's create the extension file main.lua inside HPR's extensions folder and build out the
tracking step-by-step.
Step 1 — Create your extension file
Create a new .lua file inside HPR's extensions directory. HPR will automatically scan and load
this script on startup.
~/.config/HPR/extensions/main.lua
%APPDATA%\HPR\HPR_Config\extensions\main.lua
Step 2 — Declare configuration and script state
We declare local variables at the top of the file to manage our server address and keep track of active webpage timers.
Notice: The port address, bucket variables, and stale threshold defined below are specific to the ActivityWatch tracker. You do not need to learn or understand how these variables function. Simply copy and paste this config block directly into your file!
local AW_HOST = "127.0.0.1:5600" local AW_BUCKET = nil local STALE_THRESHOLD_MS = 25000 local time_since_last_flush = 0 local time_since_last_event = 0
Step 3 — Scan and locate the browser bucket
ActivityWatch registers tracking sources inside hostname-specific directories called buckets. We query the
local bucket directory using HPR's httpGet_E utility and identify the browser watcher key.
Notice: You do not need to understand or learn how ActivityWatch directories or dynamic bucket registries function. Simply copy and paste the discoverBucket function as-is to make the data source connection work!
How the API Helpers are used here: In the function below, we call HPR.httpGet_E
to fetch the list of buckets from our local address. We then immediately pass that response body to
HPR.parseJSON_E to convert the raw JSON text into a Lua table of bucket keys so we can search
them.
local function discoverBucket() local body, status = HPR.httpGet_E(AW_HOST, "/api/0/buckets/", false) if status ~= 200 then print("[aw_parasite] cannot reach local host server") return nil end local buckets = HPR.parseJSON_E(body) if not buckets then return nil end for id, _ in pairs(buckets) do if string.find(id, "aw-watcher-web", 1, true) then return id end end return nil end
Step 4 — Extract active page data
Now we pull the single most recent webpage tracking event from our bucket. Since Lua arrays start at index 1,
we read events[1] and extract the webpage URL and title.
Notice: You do not need to understand or learn ActivityWatch's JSON array layouts, events structures, or data parameters. This code is purely to extract a webpage name. Simply copy and paste the fetchLatestUrl function directly!
How the API Helpers are used here: We use HPR.httpGet_E to fetch the latest
event from the resolved bucket path. We then invoke HPR.parseJSON_E to parse the returned JSON
string into a structured Lua table, enabling us to read the active webpage's URL and title.
local function fetchLatestUrl() if not AW_BUCKET then AW_BUCKET = discoverBucket() if not AW_BUCKET then return nil, nil end end local body, status = HPR.httpGet_E(AW_HOST, "/api/0/buckets/" .. AW_BUCKET .. "/events?limit=1", false) if status ~= 200 then return nil, nil end local events = HPR.parseJSON_E(body) if not events or #events == 0 then return nil, nil end local latest = events[1] local url = latest.data and latest.data.url local title = latest.data and latest.data.title if not url or url == "" then return nil, nil end return url, title or url end
Step 5 — Declare script identity and initialize SQLite tables
To register our extension with HPR's Loaded Extensions manager UI, we define our metadata at the very beginning of the script.
Best Practice Callout: Declare metadata!
It is a best practice to set HPR.authorName = "Your Name" and
HPR.extensionName = "Your Extension" at the top level of your script or inside init(). HPR queries these fields immediately after loading the Lua file and after running the init() hook. If you omit them entirely, your extension will still load and execute successfully, but it will fall back to being displayed in HPR's Loaded Extensions manager UI under a randomly generated name. Declaring these is not required but EXTREMELY HIGHLY RECOMMENDED.
To persist browser records across reloads, we also initialize a custom SQLite table on startup. HPR provides
simple SQL utilities: HPR.dbExecute_E() for writing to the database, and
HPR.dbQuery_E() for reading rows back. Inside init(), we ensure our table exists and
preload any existing tracking durations back into our memory cache.
If you have not read or understood how HPR integrates and manages SQLite databases, check out the Active Media Monitoring & Spotify Tracker Tutorial for a complete, in-depth guide on SQL transactions!
-- Define the extension metadata identity (at top-level or inside init()) HPR.authorName = "Plexescor" HPR.extensionName = "AW URL Parasite HPR Extension" local AW_HOST = "127.0.0.1:5600" local AW_BUCKET = nil local STALE_THRESHOLD_MS = 25000 local time_since_last_flush = 0 local time_since_last_event = 0 local url_history = {} local past_url_history = {} local is_showing_historical = false local midnightId = 0 local pastDbId = 0 local liveDbId = 0 local function extractDomain(url) local domain = url:match("^%w+://([^/]+)") or url return domain end local function flushToDatabase() for u, data in pairs(url_history) do HPR.dbExecute_E([[ insert into aw_parasite_url_usage (url, title, duration_ms) values (?, ?, ?) on conflict(url) do update set title = excluded.title, duration_ms = excluded.duration_ms; ]], { u, data.title, data.duration_ms }) end end function init() -- Ensure the database table exists HPR.dbExecute_E([[ create table if not exists aw_parasite_url_usage ( url text unique, title text, duration_ms int ); ]]) -- Preload today's records back into memory local rows = HPR.dbQuery_E("select url, title, duration_ms from aw_parasite_url_usage;") for _, row in ipairs(rows) do url_history[row.url] = { title = row.title, duration_ms = tonumber(row.duration_ms) or 0 } end -- Hook Step 6 events here... return 1000 end
Step 6 — Connect to HPR timeline events
HPR uses the Event Hub to coordinate calendar changes. This is HPR-specific code which is important to understand.
We call HPR.connect_E() to bind Lua callbacks to system events. We reset our memory cache at
midnight, and load historical database files when a user switches dates. We use pcall to safely
execute database queries and catch potential exceptions without stopping the extension.
If you have not read or understood how HPR's Event Hub and event connections work, check out the Event Hub & Burnout Detector Tutorial for a detailed guide on building event-driven extensions!
Teardown Warning: Strict 200ms Exit grace period!
Whenever you connect to Event Hub events via HPR.connect_E, HPR's C++ manager automatically tracks these subscriptions and safely cleans them up upon exit to prevent crashes, segfaults, or memory leaks. Manual disconnection in onExit() is no longer mandatory for stability, but you must still keep your onExit() handler fast! HPR gives every extension thread a strict 200 milliseconds grace period to complete its teardown. If your code takes longer than 200ms, HPR will forcefully detach the extension thread (calling ext->thread.detach()) and force close the entire application with _exit(0) to prevent lingering background hangs. Keep your exit teardown highly optimized!
midnightId = HPR.connect_E("MIDNIGHT_ROLLOVER", function() url_history = {} end) pastDbId = HPR.connect_E("LOAD_DATABASE_SINGULAR", function() local ok, histRows = pcall(HPR.dbQueryHistorical_E, "select url, title, duration_ms from aw_parasite_url_usage;") if ok and histRows and #histRows > 0 then past_url_history = {} for _, row in ipairs(histRows) do past_url_history[row.url] = { title = row.title, duration_ms = tonumber(row.duration_ms) or 0 } end is_showing_historical = true else past_url_history = {} is_showing_historical = false end end) liveDbId = HPR.connect_E("LOAD_LIVE_DATA", function() is_showing_historical = false end)
Step 7 — Sort and push active durations to HPR
Inside our tick loop onTick(delta), HPR passes us the elapsed milliseconds. We poll the browser,
accumulate active times, sort the list by playtime, and update HPR's UI.
Notice: The fetchLatestUrl routine and stale check variables used below are ActivityWatch-specific. You do not need to understand how the URL is extracted or resolved — simply copy and paste this block to update the time calculations and push values to HPR!
function onTick(delta) local url, title = fetchLatestUrl() if url then if time_since_last_event < STALE_THRESHOLD_MS then if not url_history[url] then url_history[url] = { title = title, duration_ms = 0 } end url_history[url].duration_ms = url_history[url].duration_ms + delta end time_since_last_event = 0 else time_since_last_event = time_since_last_event + delta end local source = is_showing_historical and past_url_history or url_history local formatted = {} local total_ms = 0 for u, data in pairs(source) do total_ms = total_ms + data.duration_ms table.insert(formatted, { url = extractDomain(u), title = data.title, duration = HPR.formatTime_HHMMSS_E(math.floor(data.duration_ms)), duration_i = math.floor(data.duration_ms) }) end -- Sort descending table.sort(formatted, function(a, b) return a.duration_i > b.duration_i end) -- Push values directly to UI properties HPR.setUiProperty_E("awUrls_S", formatted) HPR.setUiProperty_E("trackedTime_Aw_S", math.floor(total_ms)) time_since_last_flush = time_since_last_flush + delta if time_since_last_flush >= 10000 then pcall(flushToDatabase) time_since_last_flush = 0 end end
Step 8 — Complete Consolidated Script
Putting it all together, here is the complete main.lua for your parasitic URL tracking extension. Copy and paste this script directly into HPR's extensions folder:
HPR.authorName = "Plexescor"
HPR.extensionName = "AW URL Parasite HPR Extension"
local AW_HOST = "127.0.0.1:5600"
local AW_BUCKET = nil
local STALE_THRESHOLD_MS = 25000
local time_since_last_flush = 0
local time_since_last_event = 0
local url_history = {}
local past_url_history = {}
local is_showing_historical = false
local midnightId = 0
local pastDbId = 0
local liveDbId = 0
local function extractDomain(url)
local domain = url:match("^%w+://([^/]+)") or url
return domain
end
local function discoverBucket()
local body, status = HPR.httpGet_E(AW_HOST, "/api/0/buckets/", false)
if status ~= 200 then
print("[aw_parasite] cannot reach local host server")
return nil
end
local buckets = HPR.parseJSON_E(body)
if not buckets then return nil end
for id, _ in pairs(buckets) do
if string.find(id, "aw-watcher-web", 1, true) then
return id
end
end
return nil
end
local function fetchLatestUrl()
if not AW_BUCKET then
AW_BUCKET = discoverBucket()
if not AW_BUCKET then return nil, nil end
end
local body, status = HPR.httpGet_E(AW_HOST, "/api/0/buckets/" .. AW_BUCKET .. "/events?limit=1", false)
if status ~= 200 then return nil, nil end
local events = HPR.parseJSON_E(body)
if not events or #events == 0 then return nil, nil end
local latest = events[1]
local url = latest.data and latest.data.url
local title = latest.data and latest.data.title
if not url or url == "" then return nil, nil end
return url, title or url
end
local function flushToDatabase()
for u, data in pairs(url_history) do
HPR.dbExecute_E([[
insert into aw_parasite_url_usage (url, title, duration_ms)
values (?, ?, ?)
on conflict(url) do update set
title = excluded.title,
duration_ms = excluded.duration_ms;
]], { u, data.title, data.duration_ms })
end
end
function init()
-- Ensure the database table exists
HPR.dbExecute_E([[
create table if not exists aw_parasite_url_usage (
url text unique,
title text,
duration_ms int
);
]])
-- Preload today's records back into memory
local rows = HPR.dbQuery_E("select url, title, duration_ms from aw_parasite_url_usage;")
for _, row in ipairs(rows) do
url_history[row.url] = {
title = row.title,
duration_ms = tonumber(row.duration_ms) or 0
}
end
midnightId = HPR.connect_E("MIDNIGHT_ROLLOVER", function()
url_history = {}
end)
pastDbId = HPR.connect_E("LOAD_DATABASE_SINGULAR", function()
local ok, histRows = pcall(HPR.dbQueryHistorical_E,
"select url, title, duration_ms from aw_parasite_url_usage;")
if ok and histRows and #histRows > 0 then
past_url_history = {}
for _, row in ipairs(histRows) do
past_url_history[row.url] = {
title = row.title,
duration_ms = tonumber(row.duration_ms) or 0
}
end
is_showing_historical = true
else
past_url_history = {}
is_showing_historical = false
end
end)
liveDbId = HPR.connect_E("LOAD_LIVE_DATA", function()
is_showing_historical = false
end)
return 1000
end
function onTick(delta)
local url, title = fetchLatestUrl()
if url then
if time_since_last_event < STALE_THRESHOLD_MS then
if not url_history[url] then
url_history[url] = { title = title, duration_ms = 0 }
end
url_history[url].duration_ms = url_history[url].duration_ms + delta
end
time_since_last_event = 0
else
time_since_last_event = time_since_last_event + delta
end
local source = is_showing_historical and past_url_history or url_history
local formatted = {}
local total_ms = 0
for u, data in pairs(source) do
total_ms = total_ms + data.duration_ms
table.insert(formatted, {
url = extractDomain(u),
title = data.title,
duration = HPR.formatTime_HHMMSS_E(math.floor(data.duration_ms)),
duration_i = math.floor(data.duration_ms)
})
end
-- Sort descending
table.sort(formatted, function(a, b) return a.duration_i > b.duration_i end)
-- Push values directly to UI properties
HPR.setUiProperty_E("awUrls_S", formatted)
HPR.setUiProperty_E("trackedTime_Aw_S", math.floor(total_ms))
time_since_last_flush = time_since_last_flush + delta
if time_since_last_flush >= 10000 then
pcall(flushToDatabase)
time_since_last_flush = 0
end
end
function onExit()
pcall(flushToDatabase)
end
Part 3 — Wiring the Slint Dashboard
To display browser metrics inside HPR's visual panel, we modify HPR's config UI files to declare structs and render webpage rows.
1. Define the struct schema (ui/types.slint)
Open types.slint and add our struct definition at the end of the file:
export struct AwUrl { url: string, title: string, duration: string, duration_i: int, }
2. Create the layout component
(ui/aw-parasite-view.slint)
Create a new file called aw-parasite-view.slint in HPR's UI directory to draw the visual layout:
import { Card } from "./card.slint"; import { SectionHeader } from "./section-header.slint"; import { AwUrl } from "./types.slint"; component AwUrlRow inherits Rectangle { in property <string> url; in property <float> ratio; in property <string> duration; in property <color> bar-color; background: ta.has-hover ? #ffffff0e : transparent; HorizontalLayout { padding-left: 16px; spacing: 12px; Text { text: url; color: ta.has-hover ? #e2e8f0 : #94a3b8; font-size: 12px; vertical-alignment: center; } Rectangle { height: 4px; background: #ffffff08; Rectangle { x: 0; width: parent.width * ratio; background: bar-color; } } } ta := TouchArea {} } export component AwParasiteView { in property <[AwUrl]> awUrls_S; in property <int> trackedTime_Aw_S; VerticalLayout { padding: 12px; spacing: 10px; SectionHeader { label: "BROWSER ACTIVITY"; } Card { Flickable { viewport-height: awContent.preferred-height; awContent := VerticalLayout { for entry[i] in awUrls_S: AwUrlRow { url: entry.url; ratio: trackedTime_Aw_S > 0 ? (entry.duration_i * 1.0) / (trackedTime_Aw_S * 1.0) : 0.0; duration: entry.duration + " (" + Math.round(self.ratio * 100) + "%)"; bar-color: Colors.hsv(mod(entry.url.character-count * 61, 360), 0.70, 0.88); } } } } } }
3. Register the layout in HPR
(ui/app-window.slint)
Open app-window.slint and wire the view in:
a) Import our visual component at the top of the file:
import { AwParasiteView } from "./aw-parasite-view.slint"; import { AwUrl } from "./types.slint";
b) Declare the incoming
properties in the MainWindow block:
in property <[AwUrl]> awUrls_S; in property <int> trackedTime_Aw_S;
c) Insert the conditional view alongside HPR's original layout sections:
if active-view == "AW_BROWSER" : AwParasiteView { vertical-stretch: 1; awUrls_S: root.awUrls_S; trackedTime_Aw_S: root.trackedTime_Aw_S; }
d) Add a navigation button inside the sidebar drawer:
SidebarButton {
icon: @image-url("./images/archlinux.svg");
tip: "View Browser Activity";
clicked => { root.active-view = "AW_BROWSER"; }
}
You have successfully set up a parasitic URL tracking extension that fetches and serializes data on standard HPR event hooks, making your tracking environment highly extensible and feature-rich.