How to write a new tracking module
A "watcher" is what TrackFlow calls each little program that tracks one thing — the active window, your browser, VS Code, and so on. This guide walks through building a brand new built-in one, compiled as part of TrackFlow itself.
Most people don't need this guide. If you just want to track your own thing without touching TrackFlow's source code or rebuilding the app, use custom watchers instead — same underlying idea (a small program that prints data), created and managed entirely from inside the app. This guide is for adding a watcher that ships as part of TrackFlow itself, for everyone who installs it.
Before you start
- You'll need the Rust toolchain installed — if
cargo --versionin a terminal prints something, you're set. Otherwise get it from rustup.rs (a few clicks, no configuration needed). - You should be comfortable opening a terminal and running a command someone gives you — nothing more advanced than that.
- You don't need to know Tauri, or how TrackFlow's server works internally. That's the whole point of this guide.
The big idea, in one paragraph
A watcher is just a program that prints text. It doesn't connect to the internet, doesn't call an API, doesn't know anything about databases. Every second or so, it figures out "what's happening right now" and prints one line of JSON describing it. TrackFlow's main app reads whatever the watcher prints and takes care of the rest. That's the entire contract — if you can print a line of text from a loop, you can write a watcher.
Worked example: a watcher that reports the time of day
To make this concrete, here's a complete, real, working watcher — aw-watcher-clock — that just reports whether it's currently "morning", "afternoon", "evening" or "night". It's intentionally trivial so every piece is visible; a real watcher just replaces the "figure out what's happening" part with something more useful.
Step 1 — create the folder and the crate. In the project's root folder, create aw-watcher-clock-rust/, and inside it a file called Cargo.toml:
[package]
name = "aw-watcher-clock-rust"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "aw-watcher-clock"
path = "src/main.rs"
[dependencies]
chrono = "0.4"
serde_json = "1"
gethostname = "0.4"
Step 2 — write the watcher itself. Create aw-watcher-clock-rust/src/main.rs with this content:
use std::io::Write;
use std::{thread, time::Duration};
use serde_json::json;
fn time_of_day() -> &'static str {
let hour = chrono::Local::now().format("%H").to_string().parse::<u32>().unwrap();
match hour {
5..=11 => "morning",
12..=17 => "afternoon",
18..=21 => "evening",
_ => "night",
}
}
fn main() {
let hostname = gethostname::gethostname().to_string_lossy().to_string();
loop {
let envelope = json!({
"bucket_id": format!("aw-watcher-clock_{hostname}"),
"bucket_type": "clock.timeofday",
"client": "aw-watcher-clock",
"op": "heartbeat",
"pulsetime": 60,
"event": {
"timestamp": chrono::Utc::now().to_rfc3339(),
"duration": 0,
"data": { "period": time_of_day() }
}
});
// println! alone is not enough: Rust buffers stdout, so without
// an explicit flush the line can sit in memory instead of
// actually reaching TrackFlow. This one line is the single most
// common reason a new watcher "does nothing".
println!("{envelope}");
std::io::stdout().flush().unwrap();
thread::sleep(Duration::from_secs(30));
}
}
That's a complete, real watcher. Every 30 seconds it prints one line like this to its own terminal output:
{"bucket_id":"aw-watcher-clock_MY-PC","bucket_type":"clock.timeofday","client":"aw-watcher-clock","op":"heartbeat","pulsetime":60,"event":{"timestamp":"2026-08-15T20:00:00Z","duration":0,"data":{"period":"evening"}}}
The two things worth noticing about that line:
"op": "heartbeat"means "this is a continuation of whatever I was already reporting, if it's the same as last time" — perfect for something that stays the same for a while (like the time of day, or the active window). Use"event"instead for something that happens once and is done, like a screenshot being taken.- Everything specific to your watcher goes inside
"data"— TrackFlow doesn't care what's in there, it just stores it.
Making TrackFlow actually run it
The watcher above compiles and runs on its own, but TrackFlow won't launch it automatically yet, and it won't be in the installer. Three separate things need to happen — miss one, and the watcher either won't start or won't ship. Go through them in order.
1. Tell the app it exists (src-tauri/src/lib.rs)
- Find the
WATCHERSlist and add"aw-watcher-clock"to it — this is the list TrackFlow actually loops over to start watchers when it launches. - Find
ALL_MODULESand add("aw-watcher-clock", "Time of day")— the second part is the label shown in the tray's "Modules" menu, where anyone can turn your watcher on or off.
You don't need to write any other Rust code for a simple watcher like this one — TrackFlow already knows how to launch a generic watcher, feed it standard input if needed, and read its output.
2. Make it part of a release build
A new watcher name has to be typed in four separate files. This is the step people forget — if you skip one, everything still compiles and works on your own machine, but the watcher silently won't be in the installer other people download.
| File | What to add |
|---|---|
src-tauri/tauri.conf.json | Add "binaries/aw-watcher-clock" to the bundle.externalBin list |
.github/workflows/release.yml | Add clock to the WATCHERS line near the top (space-separated list of names) |
release.yml and cache-warmup.yml | In both files, add a line aw-watcher-clock-rust -> target to the cache workspaces: list — do it in both, or the two files stop agreeing and the build cache stops working |
cache-warmup.yml | Add clock to its own WATCHERS line too, same as in release.yml |
3. (Optional) Show it on the first-run screen
Skip this if you're happy with people finding your watcher later in the tray menu. If you want it offered as a toggle the very first time someone installs TrackFlow:
- In
src/i18n/locales/en.tsandit.ts, add a line like'aw-watcher-clock': 'Time of day'underfirstRunSetup.modules. - In
src/components/FirstRunWatcherSetup.vue, add"aw-watcher-clock"to theWATCHER_NAMESlist, and decide if it should be checked by default.
Mistakes that are easy to make (and how to spot them)
- "My watcher compiles but nothing happens." Almost always a missing
.flush()afterprintln!— Rust holds output in a buffer, and without flushing, TrackFlow never actually sees it. - "It works on my machine but isn't in the download." One of the four release/CI places above was missed — go through that table again, line by line.
- "Two watchers seem to be fighting over the same data." Every watcher needs its own unique
bucket_id(the hostname suffix in the example above handles this automatically). Two watchers writing to the samebucket_idwill overwrite each other. - "The cache stopped speeding up my builds after I added a watcher." The
workspaces:list inrelease.ymlandcache-warmup.ymlhas to be identical in both files — a mismatch here silently breaks the cache without any error.
Checklist
- ☐ Crate created, compiles, prints valid JSON with
.flush()after every line - ☐ Added to
WATCHERSandALL_MODULESinlib.rs - ☐ Added to
tauri.conf.json'sexternalBin - ☐ Added to
WATCHERSin bothrelease.ymlandcache-warmup.yml - ☐ Added to the cache
workspaces:list in both workflow files - ☐ (Optional) Added to the first-run screen
None of this requires Rust specifically, by the way — the contract is just "print a line of JSON to stdout." A watcher written in Python, Go, or anything else that can do that would work exactly the same way; it just wouldn't currently fit into this repository's existing Cargo-based build pipeline without some adjustment.