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
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:
10
aw-client-rust/.gitignore
vendored
Normal file
10
aw-client-rust/.gitignore
vendored
Normal 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
27
aw-client-rust/Cargo.toml
Normal 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
10
aw-client-rust/README.md
Normal 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.
|
||||
109
aw-client-rust/src/blocking.rs
Normal file
109
aw-client-rust/src/blocking.rs
Normal 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();
|
||||
}
|
||||
284
aw-client-rust/src/classes.rs
Normal file
284
aw-client-rust/src/classes.rs
Normal 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
417
aw-client-rust/src/lib.rs
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
484
aw-client-rust/src/queries.rs
Normal file
484
aw-client-rust/src/queries.rs
Normal 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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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 ¶ms.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(¶ms);
|
||||
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"));
|
||||
}
|
||||
}
|
||||
90
aw-client-rust/src/single_instance.rs
Normal file
90
aw-client-rust/src/single_instance.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
aw-client-rust/tests/status_errors.rs
Normal file
139
aw-client-rust/tests/status_errors.rs
Normal 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");
|
||||
}
|
||||
303
aw-client-rust/tests/test.rs
Normal file
303
aw-client-rust/tests/test.rs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user