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)
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.
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 — Create and query tables in SQLite
To persist browser records across reloads, we initialize a custom SQLite table on startup.
This is HPR-specific code which is important to understand. 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!
local url_history = {} function init() -- Define the extension metadata identity in HPR HPR.authorName = "Plexescor" HPR.extensionName = "AW URL Parasite HPR Extension" -- 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!
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 time_since_last_event = 0 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 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
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.