← Back to Docs

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!

The Parasite Concept

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 address 127.0.0.1. If a local server (like the ActivityWatch server or a local API script) binds strictly and exclusively to the IPv4 address 127.0.0.1, any request targeting localhost will 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 to true to use secure HTTPS encryption; set to false for 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. 200 for success, 404 for not found, or 500 for 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:

  • table or any: A structured Lua table containing the decoded JSON hierarchy, or the specific plucked value if a fieldPath was specified. If the JSON is invalid, it returns nil.

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.

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

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

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

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

HPR Database Guide

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!

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

HPR Event Hub Guide

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!

events
    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!

tick
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"; }
}
Summary

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.