snapshot of https://github.com/ActivityWatch/aw-server-rust.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / ubuntu-latest (push) Failing after 5s
Build / Android (push) Failing after 2s
Build / Code coverage (push) Failing after 2s
Lint / format (push) Failing after 3s
Lint / clippy (push) Failing after 3s
Build / macOS-latest (push) Has been cancelled
Build / windows-latest (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:19:17 +05:00
commit da934b693c
120 changed files with 26659 additions and 0 deletions

10
aw-client-rust/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

27
aw-client-rust/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "aw-client-rust"
version = "0.1.0"
edition = "2021"
authors = ["Johan Bjäreholt <johan@bjareho.lt>"]
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls-native-roots"] }
gethostname = "0.4"
serde = { version = "1.0", features = ["derive"] }
phf = { version = "0.11", features = ["macros"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
aw-models = { path = "../aw-models" }
tokio = { version = "1.28.2", features = ["rt", "net", "time"] }
log = "0.4"
libc = "0.2"
thiserror = "1.0"
dirs = "6.0"
fs4 = { version = "0.13", features = ["sync"] }
[dev-dependencies]
aw-datastore = { path = "../aw-datastore" }
aw-server = { path = "../aw-server", default-features = false, features=[] }
rocket = "0.5.0-rc.1"
tokio-test = "*"
toml = "0.8"

10
aw-client-rust/README.md Normal file
View File

@@ -0,0 +1,10 @@
aw-client-rust
==============
WIP: aw-client implementation in Rust
`AwClient::wait_for_start()` is asynchronous: call
`client.wait_for_start().await?` inside a Tokio runtime with networking and timers
enabled. It waits for TCP connectivity, with a ten-second total deadline covering
DNS resolution, connection attempts, and retry delays. It does not check HTTP readiness.
`blocking::AwClient::wait_for_start()?` remains synchronous.

View File

@@ -0,0 +1,109 @@
use std::future::Future;
use std::{collections::HashMap, error::Error};
use chrono::{DateTime, Utc};
use aw_models::{Bucket, Event};
use super::AwClient as AsyncAwClient;
pub struct AwClient {
client: AsyncAwClient,
pub baseurl: reqwest::Url,
pub name: String,
pub hostname: String,
}
impl std::fmt::Debug for AwClient {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "AwClient(baseurl={:?})", self.client.baseurl)
}
}
fn block_on<F: Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build shell runtime")
.block_on(f)
}
macro_rules! proxy_method
{
($name:tt, $ret:ty, $($v:ident: $t:ty),*) => {
pub fn $name(&self, $($v: $t),*) -> Result<$ret, reqwest::Error>
{ block_on(self.client.$name($($v),*)) }
};
}
impl AwClient {
pub fn new(host: &str, port: u16, name: &str) -> Result<AwClient, Box<dyn Error>> {
Self::new_with_api_key(host, port, name, None)
}
pub fn new_with_api_key(
host: &str,
port: u16,
name: &str,
api_key: Option<String>,
) -> Result<AwClient, Box<dyn Error>> {
let async_client = AsyncAwClient::new_with_api_key(host, port, name, api_key)?;
Ok(AwClient {
baseurl: async_client.baseurl.clone(),
name: async_client.name.clone(),
hostname: async_client.hostname.clone(),
client: async_client,
})
}
proxy_method!(get_bucket, Bucket, bucketname: &str);
proxy_method!(get_buckets, HashMap<String, Bucket>,);
proxy_method!(create_bucket, (), bucket: &Bucket);
proxy_method!(create_bucket_simple, (), bucketname: &str, buckettype: &str);
proxy_method!(delete_bucket, (), bucketname: &str);
proxy_method!(
get_events,
Vec<Event>,
bucketname: &str,
start: Option<DateTime<Utc>>,
stop: Option<DateTime<Utc>>,
limit: Option<u64>
);
proxy_method!(
query,
Vec<serde_json::Value>,
query: &str,
timeperiods: Vec<(DateTime<Utc>, DateTime<Utc>)>
);
proxy_method!(insert_event, (), bucketname: &str, event: &Event);
proxy_method!(insert_events, (), bucketname: &str, events: Vec<Event>);
proxy_method!(
heartbeat,
(),
bucketname: &str,
event: &Event,
pulsetime: f64
);
proxy_method!(delete_event, (), bucketname: &str, event_id: i64);
proxy_method!(get_event_count, i64, bucketname: &str);
proxy_method!(get_info, aw_models::Info,);
proxy_method!(get_setting, serde_json::Value, setting: &str);
proxy_method!(get_settings, aw_models::Settings,);
pub fn wait_for_start(&self) -> Result<(), Box<dyn Error>> {
block_on(self.client.wait_for_start())
}
}
#[test]
fn test_wait_for_start_blocking_wrapper() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = AwClient::new(
"127.0.0.1",
listener.local_addr().unwrap().port(),
"test-wait-for-start-blocking",
)
.unwrap();
client.wait_for_start().unwrap();
}

View File

@@ -0,0 +1,284 @@
//! Default classes
//!
//! Taken from default classes in aw-webui
use serde::{Deserialize, Serialize};
use serde_json;
pub type CategoryId = Vec<String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategorySpec {
#[serde(rename = "type")]
pub spec_type: String,
#[serde(default)]
pub regex: String,
#[serde(default)]
pub ignore_case: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassSetting {
#[serde(default)]
pub id: Option<i32>,
pub name: Vec<String>,
pub rule: CategorySpec,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
/// Parse categorization classes from a settings JSON value.
///
/// Accepts the payload the webui writes to `settings.classes`: a JSON array of
/// `{name, rule, ...}` objects. `id` is optional (the webui assigns it in
/// memory). `null`, empty arrays, missing fields, or unparseable JSON fall
/// back to [`default_classes`].
///
/// A JSON *string* is treated as double-encoded payload (the localStorage →
/// server settings migration bug, ActivityWatch/activitywatch#1067) and decoded
/// once more before parsing.
pub fn classes_from_settings_json(value: &serde_json::Value) -> Vec<(CategoryId, CategorySpec)> {
let value = decode_maybe_double_encoded(value);
match value {
serde_json::Value::Null => default_classes(),
serde_json::Value::Array(ref arr) if arr.is_empty() => default_classes(),
other => match serde_json::from_value::<Vec<ClassSetting>>(other) {
Ok(classes) if classes.is_empty() => default_classes(),
Ok(classes) => classes.into_iter().map(|c| (c.name, c.rule)).collect(),
Err(e) => {
log::warn!("Failed to parse settings.classes, using defaults: {:?}", e);
default_classes()
}
},
}
}
/// Parse `settings.classes` from the raw datastore/HTTP body string.
pub fn classes_from_settings_str(raw: &str) -> Vec<(CategoryId, CategorySpec)> {
match serde_json::from_str::<serde_json::Value>(raw.trim()) {
Ok(v) => classes_from_settings_json(&v),
Err(e) => {
log::warn!(
"settings.classes is not valid JSON, using defaults: {:?}",
e
);
default_classes()
}
}
}
fn decode_maybe_double_encoded(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::from_str(s).unwrap_or_else(|_| value.clone()),
_ => value.clone(),
}
}
/// Returns the default categorization classes
pub fn default_classes() -> Vec<(CategoryId, CategorySpec)> {
vec![
(
vec!["Work".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Google Docs|libreoffice|ReText".to_string(),
ignore_case: false,
},
),
(
vec!["Work".to_string(), "Programming".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyder|kate|Ghidra|Scite"
.to_string(),
ignore_case: false,
},
),
(
vec![
"Work".to_string(),
"Programming".to_string(),
"ActivityWatch".to_string(),
],
CategorySpec {
spec_type: "regex".to_string(),
regex: "ActivityWatch|aw-".to_string(),
ignore_case: true,
},
),
(
vec!["Work".to_string(), "Image".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Gimp|Inkscape".to_string(),
ignore_case: false,
},
),
(
vec!["Work".to_string(), "Video".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Kdenlive".to_string(),
ignore_case: false,
},
),
(
vec!["Work".to_string(), "Audio".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Audacity".to_string(),
ignore_case: false,
},
),
(
vec!["Work".to_string(), "3D".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Blender".to_string(),
ignore_case: false,
},
),
(
vec!["Media".to_string(), "Games".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Minecraft|RimWorld".to_string(),
ignore_case: false,
},
),
(
vec!["Media".to_string(), "Video".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "YouTube|Plex|VLC".to_string(),
ignore_case: false,
},
),
(
vec!["Media".to_string(), "Social Media".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "reddit|Facebook|Twitter|Instagram|devRant".to_string(),
ignore_case: true,
},
),
(
vec!["Media".to_string(), "Music".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Spotify|Deezer".to_string(),
ignore_case: true,
},
),
(
vec!["Comms".to_string(), "IM".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Discord|Nheko"
.to_string(),
ignore_case: false,
},
),
(
vec!["Comms".to_string(), "Email".to_string()],
CategorySpec {
spec_type: "regex".to_string(),
regex: "Gmail|Thunderbird|mutt|alpine".to_string(),
ignore_case: false,
},
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn work_regex(classes: &[(CategoryId, CategorySpec)]) -> Option<&str> {
classes
.iter()
.find(|(name, _)| name == &vec!["Work".to_string()])
.map(|(_, spec)| spec.regex.as_str())
}
#[test]
fn custom_classes_without_id_are_used() {
let value = json!([
{
"name": ["Work"],
"rule": {"type": "regex", "regex": "Slack|Notion", "ignore_case": true}
},
{
"name": ["Uncategorized", "Games"],
"rule": {"type": "regex", "regex": "Steam"}
}
]);
let classes = classes_from_settings_json(&value);
assert_eq!(classes.len(), 2);
assert_eq!(work_regex(&classes), Some("Slack|Notion"));
assert!(classes[0].1.ignore_case);
assert_eq!(
classes[1].0,
vec!["Uncategorized".to_string(), "Games".to_string()]
);
assert_eq!(classes[1].1.regex, "Steam");
}
#[test]
fn null_and_empty_fall_back_to_defaults() {
let defaults = default_classes();
assert_eq!(
work_regex(&classes_from_settings_json(&json!(null))),
work_regex(&defaults)
);
assert_eq!(
work_regex(&classes_from_settings_json(&json!([]))),
work_regex(&defaults)
);
assert_eq!(
work_regex(&classes_from_settings_str("null")),
work_regex(&defaults)
);
}
#[test]
fn invalid_json_falls_back_to_defaults() {
let defaults = default_classes();
assert_eq!(
work_regex(&classes_from_settings_str("not-json")),
work_regex(&defaults)
);
assert_eq!(
work_regex(&classes_from_settings_json(&json!({"name": "Work"}))),
work_regex(&defaults)
);
}
#[test]
fn double_encoded_string_is_decoded() {
// ActivityWatch/activitywatch#1067: classes stored as a JSON string of JSON.
let inner = json!([
{"name": ["Work"], "rule": {"type": "regex", "regex": "CustomApp"}}
]);
let wrapped = serde_json::Value::String(inner.to_string());
let classes = classes_from_settings_json(&wrapped);
assert_eq!(work_regex(&classes), Some("CustomApp"));
}
#[test]
fn webui_shaped_payload_with_id_and_data_parses() {
let value = json!([
{
"id": 0,
"name": ["Media", "Video"],
"rule": {"type": "regex", "regex": "YouTube|Plex", "ignore_case": false},
"data": {"color": "#F33", "score": 0}
}
]);
let classes = classes_from_settings_json(&value);
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].0, vec!["Media".to_string(), "Video".to_string()]);
assert_eq!(classes[0].1.regex, "YouTube|Plex");
}
}

417
aw-client-rust/src/lib.rs Normal file
View File

@@ -0,0 +1,417 @@
extern crate aw_models;
extern crate chrono;
extern crate gethostname;
extern crate reqwest;
extern crate serde_json;
extern crate tokio;
pub mod blocking;
pub mod classes;
pub mod queries;
pub mod single_instance;
use std::{collections::HashMap, error::Error};
use chrono::{DateTime, Utc};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde_json::{json, Map};
use single_instance::SingleInstance;
use std::time::Duration;
use tokio::net::TcpStream;
pub use aw_models::{Bucket, BucketMetadata, Event};
pub struct AwClient {
client: reqwest::Client,
#[allow(dead_code)]
single_instance: SingleInstance,
pub baseurl: reqwest::Url,
pub name: String,
pub hostname: String,
}
impl std::fmt::Debug for AwClient {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "AwClient(baseurl={:?})", self.baseurl)
}
}
fn get_hostname() -> String {
gethostname::gethostname().to_string_lossy().to_string()
}
fn build_client(api_key: Option<String>) -> Result<reqwest::Client, Box<dyn Error>> {
let mut headers = HeaderMap::new();
if let Some(api_key) = api_key {
let mut header_value = HeaderValue::from_str(&format!("Bearer {api_key}"))?;
header_value.set_sensitive(true);
headers.insert(AUTHORIZATION, header_value);
}
Ok(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.default_headers(headers)
.build()?)
}
/// Build the name used for the single-instance lock.
///
/// `localhost` is normalized to `127.0.0.1` so that a client given either spelling
/// takes the same lock, and so the name matches aw-client-python, which defaults
/// its server hostname to `127.0.0.1`.
fn single_instance_name(name: &str, host: &str, port: u16) -> String {
let host = if host.eq_ignore_ascii_case("localhost") {
"127.0.0.1"
} else {
host
};
format!("{}-at-{}-on-{}", name, host, port)
}
impl AwClient {
async fn send_success(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
request.send().await?.error_for_status()
}
pub fn new(host: &str, port: u16, name: &str) -> Result<AwClient, Box<dyn Error>> {
Self::new_with_api_key(host, port, name, None)
}
pub fn new_with_api_key(
host: &str,
port: u16,
name: &str,
api_key: Option<String>,
) -> Result<AwClient, Box<dyn Error>> {
let baseurl = reqwest::Url::parse(&format!("http://{}:{}", host, port))?;
let hostname = get_hostname();
let client = build_client(api_key)?;
let instance_name = single_instance_name(name, host, port);
let single_instance = single_instance::SingleInstance::new(instance_name.as_str())?;
Ok(AwClient {
client,
single_instance,
baseurl,
name: name.to_string(),
hostname,
})
}
pub async fn get_bucket(&self, bucketname: &str) -> Result<Bucket, reqwest::Error> {
let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname);
let bucket = Self::send_success(self.client.get(url))
.await?
.json()
.await?;
Ok(bucket)
}
pub async fn get_buckets(&self) -> Result<HashMap<String, Bucket>, reqwest::Error> {
let url = format!("{}api/0/buckets/", self.baseurl);
Self::send_success(self.client.get(url)).await?.json().await
}
pub async fn create_bucket(&self, bucket: &Bucket) -> Result<(), reqwest::Error> {
let url = format!("{}api/0/buckets/{}", self.baseurl, bucket.id);
Self::send_success(self.client.post(url).json(bucket)).await?;
Ok(())
}
pub async fn create_bucket_simple(
&self,
bucketname: &str,
buckettype: &str,
) -> Result<(), reqwest::Error> {
let bucket = Bucket {
bid: None,
id: bucketname.to_string(),
client: self.name.clone(),
_type: buckettype.to_string(),
hostname: self.hostname.clone(),
data: Map::default(),
metadata: BucketMetadata::default(),
events: None,
created: None,
last_updated: None,
};
self.create_bucket(&bucket).await
}
pub async fn delete_bucket(&self, bucketname: &str) -> Result<(), reqwest::Error> {
let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname);
Self::send_success(self.client.delete(url)).await?;
Ok(())
}
pub async fn query(
&self,
query: &str,
timeperiods: Vec<(DateTime<Utc>, DateTime<Utc>)>,
) -> Result<Vec<serde_json::Value>, reqwest::Error> {
let url = reqwest::Url::parse(format!("{}api/0/query", self.baseurl).as_str()).unwrap();
// Format timeperiods as ISO8601 strings, separated by /
let timeperiods_str: Vec<String> = timeperiods
.iter()
.map(|(start, stop)| (start.to_rfc3339(), stop.to_rfc3339()))
.map(|(start, stop)| format!("{}/{}", start, stop))
.collect();
// Result is a sequence, one element per timeperiod
Self::send_success(self.client.post(url).json(&json!({
"query": query.split('\n').collect::<Vec<&str>>(),
"timeperiods": timeperiods_str,
})))
.await?
.json()
.await
}
pub async fn get_events(
&self,
bucketname: &str,
start: Option<DateTime<Utc>>,
stop: Option<DateTime<Utc>>,
limit: Option<u64>,
) -> Result<Vec<Event>, reqwest::Error> {
let mut url = reqwest::Url::parse(
format!("{}api/0/buckets/{}/events", self.baseurl, bucketname).as_str(),
)
.unwrap();
// Must be a better way to build URLs
if let Some(s) = start {
url.query_pairs_mut()
.append_pair("start", s.to_rfc3339().as_str());
};
if let Some(s) = stop {
url.query_pairs_mut()
.append_pair("end", s.to_rfc3339().as_str());
};
if let Some(s) = limit {
url.query_pairs_mut()
.append_pair("limit", s.to_string().as_str());
};
Self::send_success(self.client.get(url)).await?.json().await
}
pub async fn insert_event(
&self,
bucketname: &str,
event: &Event,
) -> Result<(), reqwest::Error> {
let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname);
let eventlist = vec![event.clone()];
Self::send_success(self.client.post(url).json(&eventlist)).await?;
Ok(())
}
pub async fn insert_events(
&self,
bucketname: &str,
events: Vec<Event>,
) -> Result<(), reqwest::Error> {
let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname);
Self::send_success(self.client.post(url).json(&events)).await?;
Ok(())
}
pub async fn heartbeat(
&self,
bucketname: &str,
event: &Event,
pulsetime: f64,
) -> Result<(), reqwest::Error> {
let url = format!(
"{}api/0/buckets/{}/heartbeat?pulsetime={}",
self.baseurl, bucketname, pulsetime
);
Self::send_success(self.client.post(url).json(&event)).await?;
Ok(())
}
pub async fn delete_event(
&self,
bucketname: &str,
event_id: i64,
) -> Result<(), reqwest::Error> {
let url = format!(
"{}api/0/buckets/{}/events/{}",
self.baseurl, bucketname, event_id
);
Self::send_success(self.client.delete(url)).await?;
Ok(())
}
pub async fn get_event_count(&self, bucketname: &str) -> Result<i64, reqwest::Error> {
let url = format!("{}api/0/buckets/{}/events/count", self.baseurl, bucketname);
let res = Self::send_success(self.client.get(url))
.await?
.text()
.await?;
let count: i64 = match res.trim().parse() {
Ok(count) => count,
Err(err) => panic!("could not parse get_event_count response: {err:?}"),
};
Ok(count)
}
pub async fn get_info(&self) -> Result<aw_models::Info, reqwest::Error> {
let url = format!("{}api/0/info", self.baseurl);
Self::send_success(self.client.get(url)).await?.json().await
}
pub async fn get_setting(&self, setting: &str) -> Result<serde_json::Value, reqwest::Error> {
let url = format!("{}api/0/settings/{}", self.baseurl, setting);
Self::send_success(self.client.get(url)).await?.json().await
}
pub async fn get_settings(&self) -> Result<aw_models::Settings, reqwest::Error> {
let url = format!("{}api/0/settings", self.baseurl);
Self::send_success(self.client.get(url)).await?.json().await
}
/// Wait up to ten seconds for a TCP connection, including DNS and retries.
/// Requires a Tokio runtime with networking and time enabled.
pub async fn wait_for_start(&self) -> Result<(), Box<dyn Error>> {
wait_for_server(&self.baseurl, Duration::from_secs(10)).await
}
}
async fn wait_for_server(url: &reqwest::Url, max_wait: Duration) -> Result<(), Box<dyn Error>> {
// URL hosts include brackets around IPv6 literals; DNS lookup takes the bare address.
let host = url
.host_str()
.ok_or("Missing server hostname")?
.trim_start_matches('[')
.trim_end_matches(']');
let port = url.port_or_known_default().ok_or("Missing server port")?;
let attempts = async {
let addresses: Vec<_> = tokio::net::lookup_host((host, port)).await?.collect();
if addresses.is_empty() {
return Err("Unable to resolve baseurl into socket address".into());
}
let mut retry_delay = Duration::from_millis(100);
loop {
for address in &addresses {
if matches!(
tokio::time::timeout(retry_delay, TcpStream::connect(address)).await,
Ok(Ok(_))
) {
return Ok(());
}
}
tokio::time::sleep(retry_delay).await;
retry_delay = (retry_delay * 2).min(Duration::from_secs(1));
}
};
tokio::time::timeout(max_wait, attempts)
.await
.map_err(|_| -> Box<dyn Error> {
format!(
"Local server {host}:{port} not running after {} seconds of retrying",
max_wait.as_secs_f64()
)
.into()
})?
}
#[cfg(test)]
mod tests {
use super::single_instance_name;
#[test]
fn test_wait_for_start_success() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = reqwest::Url::parse(&format!("http://{}/", listener.local_addr().unwrap()))
.unwrap();
super::wait_for_server(&url, std::time::Duration::from_secs(1))
.await
.unwrap();
});
}
#[test]
fn test_wait_for_start_retries_without_blocking_executor() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
// Reserve a port without listening, then start the server on this same runtime.
let socket = tokio::net::TcpSocket::new_v4().unwrap();
socket.bind("127.0.0.1:0".parse().unwrap()).unwrap();
let url =
reqwest::Url::parse(&format!("http://{}/", socket.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
let listener = socket.listen(8).unwrap();
listener.accept().await.unwrap();
});
super::wait_for_server(&url, std::time::Duration::from_secs(2))
.await
.unwrap();
server.await.unwrap();
});
}
#[test]
fn test_wait_for_start_deadline() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let socket = tokio::net::TcpSocket::new_v4().unwrap();
socket.bind("127.0.0.1:0".parse().unwrap()).unwrap();
let url =
reqwest::Url::parse(&format!("http://{}/", socket.local_addr().unwrap())).unwrap();
let started = std::time::Instant::now();
let budget = std::time::Duration::from_millis(150);
let error = super::wait_for_server(&url, budget).await.unwrap_err();
assert!(error.to_string().contains("not running after 0.15 seconds"));
assert!(started.elapsed() >= budget);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
});
}
#[test]
fn test_single_instance_name_normalizes_localhost() {
// Both spellings must take the same lock, otherwise two instances of the
// same watcher can run against the same server.
assert_eq!(
single_instance_name("aw-watcher-afk", "localhost", 5600),
single_instance_name("aw-watcher-afk", "127.0.0.1", 5600),
);
assert_eq!(
single_instance_name("aw-watcher-afk", "localhost", 5600),
"aw-watcher-afk-at-127.0.0.1-on-5600",
);
// Hostnames are case-insensitive, so case variants must take the same
// lock rather than each getting one of their own.
for variant in ["LOCALHOST", "LocalHost", "localHost"] {
assert_eq!(
single_instance_name("aw-watcher-afk", variant, 5600),
"aw-watcher-afk-at-127.0.0.1-on-5600",
"host spelling {variant:?} should normalize"
);
}
}
#[test]
fn test_single_instance_name_keeps_other_hosts() {
assert_eq!(
single_instance_name("aw-watcher-afk", "192.168.1.2", 5600),
"aw-watcher-afk-at-192.168.1.2-on-5600",
);
}
}

View File

@@ -0,0 +1,484 @@
//! Query building utilities for ActivityWatch
//!
//! This module provides functionality to build canonical queries that are compatible
//! with the ActivityWatch query language. It includes support for:
//!
//! - Desktop and Android query parameters
//! - Server-side categorization classes fetching
//! - Browser events integration
//! - AFK filtering
//!
//! ## Server-side Classes
//!
//! The queries can automatically fetch categorization classes from the ActivityWatch server:
//!
//! ```rust
//! use aw_client_rust::queries::{DesktopQueryParams, QueryParams, QueryParamsBase};
//!
//! let params = DesktopQueryParams {
//! base: QueryParamsBase {
//! bid_browsers: vec![],
//! classes: vec![], // Empty - will fetch from server
//! filter_classes: vec![],
//! filter_afk: true,
//! include_audible: true,
//! },
//! bid_window: "aw-watcher-window_example".to_string(),
//! bid_afk: "aw-watcher-afk_example".to_string(),
//! always_active_pattern: None,
//! };
//!
//! // Automatically fetches classes from localhost:5600
//! let query = QueryParams::Desktop(params.clone()).canonical_events();
//!
//! ```
use crate::classes::{CategoryId, CategorySpec};
use serde::{Deserialize, Serialize};
/// Browser application names mapped by browser type
pub static BROWSER_APPNAMES: phf::Map<&'static str, &'static [&'static str]> = phf::phf_map! {
"chrome" => &[
// Chrome
"Google Chrome",
"Google-chrome",
"chrome.exe",
"google-chrome-stable",
// Chromium
"Chromium",
"Chromium-browser",
"Chromium-browser-chromium",
"chromium.exe",
// Pre-releases
"Google-chrome-beta",
"Google-chrome-unstable",
// Brave (should this be merged with the brave entry?)
"Brave-browser",
],
"firefox" => &[
"Firefox",
"Firefox.exe",
"firefox",
"firefox.exe",
"Firefox Developer Edition",
"firefoxdeveloperedition",
"Firefox-esr",
"Firefox Beta",
"Nightly",
"org.mozilla.firefox",
],
"opera" => &["opera.exe", "Opera"],
"brave" => &["brave.exe"],
"edge" => &[
"msedge.exe", // Windows
"Microsoft Edge", // macOS
],
"vivaldi" => &["Vivaldi-stable", "Vivaldi-snapshot", "vivaldi.exe"],
};
pub const DEFAULT_LIMIT: u32 = 100;
/// Type alias for categorization classes
pub type ClassRule = (CategoryId, CategorySpec);
/// Base query parameters
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryParamsBase {
#[serde(default)]
pub bid_browsers: Vec<String>,
#[serde(default)]
pub classes: Vec<ClassRule>,
#[serde(default)]
pub filter_classes: Vec<Vec<String>>,
#[serde(default = "default_true")]
pub filter_afk: bool,
#[serde(default = "default_true")]
pub include_audible: bool,
}
fn default_true() -> bool {
true
}
/// Query parameters specific to desktop
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopQueryParams {
#[serde(flatten)]
pub base: QueryParamsBase,
pub bid_window: String,
pub bid_afk: String,
#[serde(default)]
pub always_active_pattern: Option<String>,
}
/// Query parameters specific to Android
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AndroidQueryParams {
#[serde(flatten)]
pub base: QueryParamsBase,
pub bid_android: String,
}
/// Enum to represent different types of query parameters
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum QueryParams {
Desktop(DesktopQueryParams),
Android(AndroidQueryParams),
}
impl QueryParams {
/// Build canonical events query string
pub fn canonical_events(&self) -> String {
match self {
QueryParams::Desktop(params) => build_desktop_canonical_events(params),
QueryParams::Android(params) => build_android_canonical_events(params),
}
}
}
/// Helper function to serialize classes in the format expected by the categorize function
/// This version builds the query string directly without JSON serialization to avoid double-escaping
fn serialize_classes(classes: &[ClassRule]) -> String {
let mut parts = Vec::new();
for (category_id, category_spec) in classes {
// Build category array string manually: ["Work", "Programming"]
let category_str = format!(
"[{}]",
category_id
.iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(", ")
);
// Build spec object manually to avoid JSON escaping regex patterns
let mut spec_parts = Vec::new();
spec_parts.push(format!("\"type\": \"{}\"", category_spec.spec_type));
// Only include regex for non-"none" types, and use raw pattern without escaping
if category_spec.spec_type != "none" {
spec_parts.push(format!("\"regex\": \"{}\"", category_spec.regex));
}
// Always include ignore_case field
spec_parts.push(format!("\"ignore_case\": {}", category_spec.ignore_case));
let spec_str = format!("{{{}}}", spec_parts.join(", "));
// Build the tuple [category, spec]
parts.push(format!("[{}, {}]", category_str, spec_str));
}
format!("[{}]", parts.join(", "))
}
pub fn build_desktop_canonical_events(params: &DesktopQueryParams) -> String {
let mut query = Vec::new();
// Fetch window events
query.push(format!(
"events = flood(query_bucket(find_bucket(\"{}\")))",
escape_doublequote(&params.bid_window)
));
// Fetch not-afk events
if params.base.filter_afk {
let mut not_afk_query = format!(
"not_afk = flood(query_bucket(find_bucket(\"{}\")));
not_afk = filter_keyvals(not_afk, \"status\", [\"not-afk\"])",
escape_doublequote(&params.bid_afk)
);
// Add treat_as_active functionality if pattern is provided
if let Some(ref pattern) = params.always_active_pattern {
not_afk_query.push_str(&format!(
";
not_treat_as_afk = filter_keyvals_regex(events, \"app\", \"{}\");
not_afk = period_union(not_afk, not_treat_as_afk);
not_treat_as_afk = filter_keyvals_regex(events, \"title\", \"{}\");
not_afk = period_union(not_afk, not_treat_as_afk)",
escape_doublequote(pattern),
escape_doublequote(pattern)
));
}
query.push(not_afk_query);
}
// Add browser events if any browser buckets specified
if !params.base.bid_browsers.is_empty() {
query.push(build_browser_events(params));
if params.base.include_audible {
query.push(
"audible_events = filter_keyvals(browser_events, \"audible\", [true]);
not_afk = period_union(not_afk, audible_events)"
.to_string(),
);
}
}
// Filter out window events when user was AFK
if params.base.filter_afk {
query.push("events = filter_period_intersect(events, not_afk)".to_string());
}
// Add categorization if classes specified
if !params.base.classes.is_empty() {
query.push(format!(
"events = categorize(events, {});",
serialize_classes(&params.base.classes)
));
}
// Filter categories if specified
if !params.base.filter_classes.is_empty() {
query.push(format!(
"events = filter_keyvals(events, \"$category\", {})",
serde_json::to_string(&params.base.filter_classes).unwrap()
));
}
query.join(";\n")
}
pub fn build_android_canonical_events(params: &AndroidQueryParams) -> String {
let mut query = Vec::new();
// Fetch app events
query.push(format!(
"events = flood(query_bucket(find_bucket(\"{}\")))",
escape_doublequote(&params.bid_android)
));
// Merge events by app
query.push("events = merge_events_by_keys(events, [\"app\"])".to_string());
// Add categorization if classes specified
if !params.base.classes.is_empty() {
query.push(format!(
"events = categorize(events, {});",
serialize_classes(&params.base.classes)
));
}
// Filter categories if specified
if !params.base.filter_classes.is_empty() {
query.push(format!(
"events = filter_keyvals(events, \"$category\", {})",
serde_json::to_string(&params.base.filter_classes).unwrap()
));
}
query.join(";\n")
}
pub fn build_browser_events(params: &DesktopQueryParams) -> String {
let mut query = String::from("browser_events = [];");
for browser_bucket in &params.base.bid_browsers {
for (browser_name, app_names) in BROWSER_APPNAMES.entries() {
if browser_bucket.contains(browser_name) {
query.push_str(&format!(
"
events_{0} = flood(query_bucket(\"{1}\"));
window_{0} = filter_keyvals(events, \"app\", {2});
events_{0} = filter_period_intersect(events_{0}, window_{0});
events_{0} = split_url_events(events_{0});
browser_events = concat(browser_events, events_{0});
browser_events = sort_by_timestamp(browser_events)",
browser_name,
escape_doublequote(browser_bucket),
serde_json::to_string(app_names).unwrap()
));
}
}
}
query
}
/// Build a full desktop query using default localhost:5600 configuration
pub fn full_desktop_query(params: &DesktopQueryParams) -> String {
let mut query = QueryParams::Desktop(params.clone()).canonical_events();
// Add basic event aggregations
query.push_str(&format!(
"
title_events = sort_by_duration(merge_events_by_keys(events, [\"app\", \"title\"]));
app_events = sort_by_duration(merge_events_by_keys(title_events, [\"app\"]));
cat_events = sort_by_duration(merge_events_by_keys(events, [\"$category\"]));
app_events = limit_events(app_events, {});
title_events = limit_events(title_events, {});
duration = sum_durations(events);
",
DEFAULT_LIMIT, DEFAULT_LIMIT
));
// Add browser-specific query parts if browser buckets exist
if !params.base.bid_browsers.is_empty() {
query.push_str(&format!(
"
browser_events = split_url_events(browser_events);
browser_urls = merge_events_by_keys(browser_events, [\"url\"]);
browser_urls = sort_by_duration(browser_urls);
browser_urls = limit_events(browser_urls, {});
browser_domains = merge_events_by_keys(browser_events, [\"$domain\"]);
browser_domains = sort_by_duration(browser_domains);
browser_domains = limit_events(browser_domains, {});
browser_duration = sum_durations(browser_events);
",
DEFAULT_LIMIT, DEFAULT_LIMIT
));
} else {
query.push_str(
"
browser_events = [];
browser_urls = [];
browser_domains = [];
browser_duration = 0;
",
);
}
// Add return statement
query.push_str(
"
RETURN = {
\"events\": events,
\"window\": {
\"app_events\": app_events,
\"title_events\": title_events,
\"cat_events\": cat_events,
\"active_events\": not_afk,
\"duration\": duration
},
\"browser\": {
\"domains\": browser_domains,
\"urls\": browser_urls,
\"duration\": browser_duration
}
};
",
);
query
}
fn escape_doublequote(s: &str) -> String {
s.replace('\"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_desktop_query_generation() {
let params = DesktopQueryParams {
base: QueryParamsBase {
bid_browsers: vec![],
classes: vec![],
filter_classes: vec![],
filter_afk: true,
include_audible: true,
},
bid_window: "aw-watcher-window_".to_string(),
bid_afk: "aw-watcher-afk_".to_string(),
always_active_pattern: None,
};
let query = full_desktop_query(&params);
assert!(!query.is_empty());
assert!(query.contains("events = flood"));
}
#[test]
fn test_classes_serialization() {
let classes = vec![
(
vec!["Work".to_string()],
crate::classes::CategorySpec {
spec_type: "regex".to_string(),
regex: "Google Docs".to_string(),
ignore_case: false,
},
),
(
vec!["Work".to_string(), "Programming".to_string()],
crate::classes::CategorySpec {
spec_type: "regex".to_string(),
regex: "GitHub|vim".to_string(),
ignore_case: true,
},
),
];
let serialized = serialize_classes(&classes);
// Should contain the category names and rules in the correct format
assert!(serialized.contains("Work"));
assert!(serialized.contains("Programming"));
assert!(serialized.contains("Google Docs"));
assert!(serialized.contains("GitHub|vim"));
assert!(serialized.contains("\"type\": \"regex\""));
assert!(serialized.contains("\"ignore_case\": false"));
assert!(serialized.contains("\"ignore_case\": true"));
}
#[test]
fn test_canonical_events_with_empty_classes() {
let params = DesktopQueryParams {
base: QueryParamsBase {
bid_browsers: vec![],
classes: vec![],
filter_classes: vec![],
filter_afk: true,
include_audible: true,
},
bid_window: "test-window".to_string(),
bid_afk: "test-afk".to_string(),
always_active_pattern: None,
};
let query_params = QueryParams::Desktop(params);
let query = query_params.canonical_events();
// Should contain basic query structure
assert!(query.contains("events = flood"));
assert!(query.contains("test-window"));
}
#[test]
fn test_canonical_events_with_existing_classes() {
let classes = vec![(
vec!["Test".to_string()],
crate::classes::CategorySpec {
spec_type: "regex".to_string(),
regex: "test".to_string(),
ignore_case: false,
},
)];
let params = DesktopQueryParams {
base: QueryParamsBase {
bid_browsers: vec![],
classes,
filter_classes: vec![],
filter_afk: true,
include_audible: true,
},
bid_window: "test-window".to_string(),
bid_afk: "test-afk".to_string(),
always_active_pattern: None,
};
let query_params = QueryParams::Desktop(params);
let query = query_params.canonical_events();
// Should contain categorization
assert!(query.contains("events = categorize"));
assert!(query.contains("test"));
}
}

View File

@@ -0,0 +1,90 @@
use dirs::cache_dir;
use fs4::fs_std::FileExt;
use log::debug;
use std::fs::{File, OpenOptions};
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Debug)]
pub struct SingleInstance {
file: Option<File>,
locked: Arc<AtomicBool>,
}
#[derive(Debug, thiserror::Error)]
pub enum SingleInstanceError {
#[error("Another instance is already running")]
AlreadyRunning,
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Failed to create lock directory")]
LockDirCreation,
}
impl SingleInstance {
pub fn new(client_name: &str) -> Result<SingleInstance, SingleInstanceError> {
let cache_dir = cache_dir().ok_or(SingleInstanceError::LockDirCreation)?;
let lock_dir = cache_dir.join("activitywatch").join("client_locks");
std::fs::create_dir_all(&lock_dir).map_err(|_| SingleInstanceError::LockDirCreation)?;
let lockfile = lock_dir.join(client_name);
debug!("SingleInstance lockfile: {:?}", lockfile);
#[cfg(windows)]
{
// On Windows, try to create an exclusive file
// Remove existing file if it exists (in case of previous crash)
let _ = std::fs::remove_file(&lockfile);
match OpenOptions::new()
.write(true)
.create(true)
.create_new(true)
.open(&lockfile)
{
Ok(file) => Ok(SingleInstance {
file: Some(file),
locked: Arc::new(AtomicBool::new(true)),
}),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
// Qualified so the unix build doesn't carry an unused `error` import
log::error!("Another instance is already running");
Err(SingleInstanceError::AlreadyRunning)
}
Err(e) => Err(SingleInstanceError::Io(e)),
}
}
#[cfg(unix)]
{
// On Unix-like systems, use flock
match OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&lockfile)
{
Ok(file) => match file.try_lock_exclusive() {
Ok(true) => Ok(SingleInstance {
file: Some(file),
locked: Arc::new(AtomicBool::new(true)),
}),
Ok(false) => Err(SingleInstanceError::AlreadyRunning),
Err(e) => Err(SingleInstanceError::Io(e)),
},
Err(e) => Err(SingleInstanceError::Io(e)),
}
}
}
}
impl Drop for SingleInstance {
fn drop(&mut self) {
if self.locked.load(Ordering::SeqCst) {
//drop the file handle and lock on Unix and Windows
self.file.take();
self.locked.store(false, Ordering::SeqCst);
}
}
}

View File

@@ -0,0 +1,139 @@
use std::future::Future;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::thread;
use aw_client_rust::blocking;
use aw_client_rust::AwClient;
fn block_on<F: Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build test runtime")
.block_on(future)
}
struct MockResponse {
status_line: &'static str,
content_type: &'static str,
body: &'static str,
}
/// Drain the HTTP request fully before responding.
///
/// Parses Content-Length from headers so POST body data (which may arrive
/// in a separate TCP segment) is consumed before the mock writes its
/// response. Without this, reqwest may see a broken pipe on loopback if
/// the response arrives before the body finishes sending.
fn drain_request(stream: &mut impl Read) {
let mut reader = BufReader::new(stream);
let mut content_length = 0_usize;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).expect("read request line");
if n == 0 {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
// End of headers; body follows if Content-Length > 0
break;
}
if let Some(val) = trimmed.strip_prefix("Content-Length:") {
content_length = val.trim().parse().unwrap_or(0);
}
}
if content_length > 0 {
let mut body_buf = vec![0_u8; content_length];
reader
.read_exact(&mut body_buf)
.expect("drain request body");
}
}
fn spawn_mock_server(responses: Vec<MockResponse>) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind mock server");
let port = listener.local_addr().expect("mock server addr").port();
let handle = thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
drain_request(&mut stream);
let body = response.body.as_bytes();
write!(
stream,
"HTTP/1.1 {}\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n{}",
response.status_line,
body.len(),
response.content_type,
response.body
)
.expect("write response");
stream.flush().expect("flush response");
}
});
(port, handle)
}
#[test]
fn async_client_rejects_non_success_statuses() {
let (port, handle) = spawn_mock_server(vec![
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json",
body: "{}",
},
MockResponse {
status_line: "409 Conflict",
content_type: "text/plain",
body: "",
},
]);
let client = AwClient::new("127.0.0.1", port, "aw-client-rust-test").expect("create client");
let err = block_on(client.get_buckets()).expect_err("500 response must fail");
assert_eq!(
err.status(),
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let err = block_on(client.create_bucket_simple("bucket", "type"))
.expect_err("409 response must fail");
assert_eq!(err.status(), Some(reqwest::StatusCode::CONFLICT));
handle.join().expect("join mock server");
}
#[test]
fn blocking_client_rejects_non_success_statuses() {
let (port, handle) = spawn_mock_server(vec![
MockResponse {
status_line: "500 Internal Server Error",
content_type: "application/json",
body: "{}",
},
MockResponse {
status_line: "409 Conflict",
content_type: "text/plain",
body: "",
},
]);
let client =
blocking::AwClient::new("127.0.0.1", port, "aw-client-rust-test").expect("create client");
let err = client
.get_buckets()
.expect_err("500 response must fail for blocking client");
assert_eq!(
err.status(),
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let err = client
.create_bucket_simple("bucket", "type")
.expect_err("409 response must fail for blocking client");
assert_eq!(err.status(), Some(reqwest::StatusCode::CONFLICT));
handle.join().expect("join mock server");
}

View File

@@ -0,0 +1,303 @@
extern crate aw_client_rust;
extern crate aw_datastore;
extern crate aw_server;
extern crate chrono;
extern crate rocket;
extern crate serde_json;
extern crate tokio_test;
#[cfg(test)]
mod test {
use aw_client_rust::blocking::AwClient;
use aw_client_rust::Event;
use chrono::{DateTime, Duration, Utc};
use serde_json::Map;
use std::cell::RefCell;
use std::fs;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio_test::block_on;
// Config-reading helpers — only needed in tests; production code passes API keys explicitly.
#[derive(serde::Deserialize, Default)]
struct LocalAuthConfig {
#[serde(default)]
api_key: Option<String>,
}
#[derive(serde::Deserialize, Default)]
struct LocalServerConfig {
#[serde(default)]
port: Option<u16>,
#[serde(default)]
auth: LocalAuthConfig,
}
#[derive(Clone, Copy)]
struct ConfigCandidate {
filename: &'static str,
default_port: u16,
}
fn get_server_config_dir() -> Option<PathBuf> {
Some(
dirs::config_dir()?
.join("activitywatch")
.join("aw-server-rust"),
)
}
fn load_local_api_key(host: &str, port: u16) -> Option<String> {
if host != "127.0.0.1" && host != "localhost" {
return None;
}
let config_dir = get_server_config_dir()?;
let candidates = [
ConfigCandidate {
filename: "config.toml",
default_port: 5600,
},
ConfigCandidate {
filename: "config-testing.toml",
default_port: 5666,
},
];
for candidate in candidates {
let path = config_dir.join(candidate.filename);
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(_) => continue,
};
let config: LocalServerConfig = match toml::from_str(&content) {
Ok(config) => config,
Err(_) => continue,
};
let configured_port = config.port.unwrap_or(candidate.default_port);
if configured_port == port {
return config.auth.api_key.filter(|k| !k.is_empty());
}
}
None
}
// A random port, but still not guaranteed to not be bound
// FIXME: Bind to a port that is free for certain and use that for the client instead
static PORT: u16 = 41293;
static ENV_LOCK: Mutex<()> = Mutex::new(());
// Keep the listener alive until the server binds — prevents TOCTOU race in reserve_port
thread_local! {
static RESERVED_PORT: RefCell<Option<TcpListener>> = const { RefCell::new(None) };
}
fn wait_for_server(timeout_s: u32, client: &AwClient) {
for i in 0.. {
match client.get_info() {
Ok(_) => break,
Err(err) => {
if i == timeout_s - 1 {
panic!("Timed out starting aw-server after {timeout_s}s: {err:?}");
}
}
}
use std::time;
let duration = time::Duration::from_secs(1);
thread::sleep(duration);
}
}
fn setup_testserver(port: u16, api_key: Option<&str>) -> rocket::Shutdown {
use aw_server::endpoints::AssetResolver;
use aw_server::endpoints::ServerState;
let state = ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let aw_config = aw_server::config::AWConfig {
port,
testing: true,
auth: aw_server::config::AWAuthConfig {
api_key: api_key.map(str::to_owned),
},
..Default::default()
};
let server = aw_server::endpoints::build_rocket(state, aw_config);
let server = block_on(server.ignite()).unwrap();
let shutdown_handler = server.shutdown();
thread::spawn(move || {
let _ = block_on(server.launch()).unwrap();
});
shutdown_handler
}
fn reserve_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
// Keep the listener alive until the server binds — prevents TOCTOU race
RESERVED_PORT.with(|cell| *cell.borrow_mut() = Some(listener));
port
}
fn write_server_config(port: u16, api_key: Option<&str>) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let config_home = std::env::temp_dir().join(format!(
"aw-client-rust-config-{}-{}",
std::process::id(),
unique
));
let config_dir = config_home.join("activitywatch").join("aw-server-rust");
fs::create_dir_all(&config_dir).unwrap();
let mut content = format!("port = {port}\n");
if let Some(api_key) = api_key {
content.push_str("\n[auth]\n");
content.push_str(&format!("api_key = \"{api_key}\"\n"));
}
fs::write(config_dir.join("config.toml"), content).unwrap();
config_home
}
fn with_config_home<T>(config_home: &Path, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let old_value = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", config_home);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
if let Some(old_value) = old_value {
std::env::set_var("XDG_CONFIG_HOME", old_value);
} else {
std::env::remove_var("XDG_CONFIG_HOME");
}
let _ = fs::remove_dir_all(config_home);
result.unwrap()
}
#[test]
fn test_full() {
let clientname = "aw-client-rust-test";
// Hold ENV_LOCK during client creation to prevent parallel-test interference
// with test_reads_api_key_from_matching_server_config (which holds the lock
// via with_config_home for the entire client+server lifetime).
let client: AwClient = {
let _guard = ENV_LOCK.lock().unwrap();
AwClient::new("127.0.0.1", PORT, clientname).expect("Client creation failed")
};
let shutdown_handler = setup_testserver(PORT, None);
wait_for_server(20, &client);
let info = client.get_info().unwrap();
assert!(info.testing);
let bucketname = format!("aw-client-rust-test_{}", client.hostname);
let buckettype = "test-type";
client
.create_bucket_simple(&bucketname, buckettype)
.unwrap();
let bucket = client.get_bucket(&bucketname).unwrap();
assert!(bucket.id == bucketname);
println!("{}", bucket.id);
let buckets = client.get_buckets().unwrap();
println!("Buckets: {buckets:?}");
let mut event = Event {
id: None,
timestamp: DateTime::from_utc(
DateTime::parse_from_rfc3339("2017-12-30T01:00:00+00:00")
.unwrap()
.naive_utc(),
Utc,
),
duration: Duration::seconds(0),
data: Map::new(),
};
println!("{event:?}");
client.insert_event(&bucketname, &event).unwrap();
// Ugly way to create a UTC from timestamp, see https://github.com/chronotope/chrono/issues/263
event.timestamp = DateTime::from_utc(
DateTime::parse_from_rfc3339("2017-12-30T01:00:01+00:00")
.unwrap()
.naive_utc(),
Utc,
);
client.heartbeat(&bucketname, &event, 10.0).unwrap();
let events = client.get_events(&bucketname, None, None, None).unwrap();
println!("Events: {events:?}");
assert!(events[0].duration == Duration::seconds(1));
// Query
let query = format!(
"events = query_bucket(\"{}\");
RETURN = events;",
bucket.id
);
let start: DateTime<Utc> = DateTime::parse_from_rfc3339("1996-12-19T00:00:00-08:00")
.unwrap()
.into();
let end: DateTime<Utc> = DateTime::parse_from_rfc3339("2020-12-19T00:00:00-08:00")
.unwrap()
.into();
let timeperiods = (start, end);
let query_result = client.query(&query, vec![timeperiods]).unwrap();
println!("Query result: {query_result:?}");
client
.delete_event(&bucketname, events[0].id.unwrap())
.unwrap();
let count = client.get_event_count(&bucketname).unwrap();
assert_eq!(count, 0);
client.delete_bucket(&bucketname).unwrap();
shutdown_handler.notify();
}
// XDG_CONFIG_HOME is only respected by dirs::config_dir() on Linux.
// On macOS it returns $HOME/Library/Application Support (ignoring XDG_CONFIG_HOME),
// so this test would fail — gate it on Linux only.
#[test]
#[cfg(target_os = "linux")]
fn test_reads_api_key_from_matching_server_config() {
let clientname = "aw-client-rust-auth-test";
let port = reserve_port();
let config_home = write_server_config(port, Some("secret123"));
with_config_home(&config_home, || {
let api_key = load_local_api_key("127.0.0.1", port);
let client: AwClient =
AwClient::new_with_api_key("127.0.0.1", port, clientname, api_key)
.expect("Client creation failed");
// Drop the reserved listener before Rocket tries to bind the same port.
RESERVED_PORT.with(|cell| *cell.borrow_mut() = None);
let shutdown_handler = setup_testserver(port, Some("secret123"));
wait_for_server(20, &client);
let bucketname = format!("aw-client-rust-auth-test_{}", client.hostname);
client
.create_bucket_simple(&bucketname, "test-type")
.unwrap();
let bucket = client.get_bucket(&bucketname).unwrap();
assert_eq!(bucket.id, bucketname);
shutdown_handler.notify();
});
}
}