Files
AWatch-rus/adk-rust/crates/worktime-ui-bridge/src/main.rs
T

998 lines
32 KiB
Rust

use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
thread,
time::Duration,
};
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
use clap::Parser;
use reqwest::header::{CONNECTION, HeaderMap, HeaderValue};
use reqwest::{Method, StatusCode, blocking::Client};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
const DEFAULT_STATE_PATH: &str = "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json";
const BRIDGE_SOURCE: &str = "aw-worktime-ui-bridge";
#[derive(Debug, Parser)]
#[command(about = "AW Worktime UI bridge (sessions -> afk/window)")]
struct Cli {
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
}
#[derive(Debug, Clone)]
struct Config {
aw_url: String,
host: String,
state_path: PathBuf,
timeout_seconds: f64,
watcher_fallback_enabled: bool,
watcher_fallback_stale_seconds: f64,
collector_health_max_age_seconds: f64,
collector_health_query_limit: usize,
foreground_context_cache_seconds: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct AwEvent {
timestamp: Option<String>,
#[serde(default)]
duration: Option<f64>,
#[serde(default)]
data: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct State {
last_ts: String,
#[serde(skip_serializing_if = "Option::is_none")]
last_foreground_context: Option<ForegroundContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct ForegroundContext {
app: String,
title: String,
timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct RunSummary {
ok: bool,
dry_run: bool,
host: String,
input_events: usize,
posted_afk: usize,
posted_win: usize,
watcher_afk_posted: usize,
watcher_win_posted: usize,
last_ts: Option<String>,
state_saved: bool,
}
struct AwClient {
base_url: String,
client: Client,
}
fn env(name: &str, fallback: &str) -> String {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
fn env_bool(name: &str, fallback: bool) -> bool {
match std::env::var(name)
.ok()
.map(|value| value.trim().to_ascii_lowercase())
{
Some(value) if !value.is_empty() => matches!(value.as_str(), "1" | "true" | "yes" | "on"),
_ => fallback,
}
}
fn env_f64(name: &str, fallback: f64) -> f64 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(fallback)
}
fn env_usize(name: &str, fallback: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(fallback)
}
fn load_config() -> Config {
Config {
aw_url: env("AW_SERVER_URL", DEFAULT_AW_URL)
.trim_end_matches('/')
.to_string(),
host: env("AW_WORKTIME_HOST", DEFAULT_HOST),
state_path: PathBuf::from(env("AW_WORKTIME_UI_BRIDGE_STATE", DEFAULT_STATE_PATH)),
timeout_seconds: env_f64("AW_WORKTIME_UI_BRIDGE_TIMEOUT", 60.0),
watcher_fallback_enabled: env_bool("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", true),
watcher_fallback_stale_seconds: env_f64(
"AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS",
600.0,
),
collector_health_max_age_seconds: env_f64(
"AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS",
300.0,
),
collector_health_query_limit: env_usize(
"AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT",
200,
),
foreground_context_cache_seconds: env_f64(
"AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS",
900.0,
),
}
}
impl Config {
fn sessions_bucket(&self) -> String {
format!("aw-worktime-sessions_{}", self.host)
}
fn afk_bucket(&self) -> String {
format!("aw-rdp-afk_{}", self.host)
}
fn window_bucket(&self) -> String {
format!("aw-rdp-window_{}", self.host)
}
fn watcher_afk_bucket(&self) -> String {
format!("aw-watcher-afk_{}", self.host)
}
fn watcher_window_bucket(&self) -> String {
format!("aw-watcher-window_{}", self.host)
}
fn web_category_bucket(&self) -> String {
format!("aw-detmir-web-category_{}", self.host)
}
}
impl AwClient {
fn new(config: &Config) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(CONNECTION, HeaderValue::from_static("close"));
let client = Client::builder()
.timeout(Duration::from_secs_f64(config.timeout_seconds.max(0.001)))
.no_proxy()
.pool_max_idle_per_host(0)
.default_headers(headers)
.build()
.context("build HTTP client")?;
Ok(Self {
base_url: config.aw_url.clone(),
client,
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn request_json(
&self,
method: Method,
path: &str,
payload: Option<Value>,
) -> Result<Option<Value>> {
let mut last_error = None;
let mut response = None;
for attempt in 1..=3 {
let mut request = self.client.request(method.clone(), self.url(path));
if let Some(payload) = payload.clone() {
request = request.json(&payload);
}
match request.send() {
Ok(ok_response) => {
response = Some(ok_response);
break;
}
Err(error) => {
last_error = Some(error);
if attempt < 3 {
eprintln!("warn request={path} retry_after_send_error");
thread::sleep(Duration::from_millis(750));
}
}
}
}
let response = match response {
Some(response) => response,
None => {
let error = last_error.expect("error exists after failed request");
return Err(error).with_context(|| format!("request ActivityWatch {path}"));
}
};
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(anyhow!("not found: {path}"));
}
if !status.is_success() {
return Err(anyhow!("ActivityWatch {path} returned HTTP {status}"));
}
let bytes = response.bytes().context("read ActivityWatch response")?;
if bytes.is_empty() {
Ok(None)
} else {
serde_json::from_slice(&bytes)
.map(Some)
.with_context(|| format!("decode ActivityWatch JSON from {path}"))
}
}
fn get_events(&self, bucket_id: &str, limit: usize) -> Result<Vec<AwEvent>> {
let path = format!("/api/0/buckets/{bucket_id}/events?limit={limit}");
match self.request_json(Method::GET, &path, None) {
Ok(Some(value)) => serde_json::from_value(value).context("decode AW events"),
Ok(None) => Ok(Vec::new()),
Err(error) if error.to_string().starts_with("not found:") => Ok(Vec::new()),
Err(error) => Err(error),
}
}
fn get_latest_bucket_event(&self, bucket_id: &str) -> Result<Option<AwEvent>> {
Ok(self.get_events(bucket_id, 1)?.into_iter().next())
}
fn ensure_bucket(
&self,
bucket_id: &str,
event_type: &str,
client_name: &str,
host: &str,
) -> Result<()> {
let payload = json!({
"client": client_name,
"type": event_type,
"hostname": host,
});
let path = format!("/api/0/buckets/{bucket_id}");
let response = match self.client.post(self.url(&path)).json(&payload).send() {
Ok(response) => response,
Err(error) => {
eprintln!("warn ensure_bucket={bucket_id} skipped: {error}");
return Ok(());
}
};
let status = response.status();
if status.is_success()
|| status == StatusCode::NOT_MODIFIED
|| status == StatusCode::CONFLICT
{
Ok(())
} else {
eprintln!("warn ensure_bucket={bucket_id} returned HTTP {status}; continuing");
Ok(())
}
}
fn post_events(&self, bucket_id: &str, events: &[AwEvent]) -> Result<()> {
let path = format!("/api/0/buckets/{bucket_id}/events");
self.request_json(Method::POST, &path, Some(json!(events)))
.map(|_| ())
}
}
fn load_state(path: &Path) -> State {
let fallback = State {
last_ts: "1970-01-01T00:00:00Z".to_string(),
last_foreground_context: None,
};
let Ok(raw) = fs::read_to_string(path) else {
return fallback;
};
let Ok(state) = serde_json::from_str::<State>(&raw) else {
return fallback;
};
if state.last_ts.trim().is_empty() {
fallback
} else {
state
}
}
fn save_state(path: &Path, state: &State) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = PathBuf::from(format!("{}.tmp", path.display()));
let raw = serde_json::to_vec(state).context("encode state")?;
fs::write(&tmp, raw).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
fn parse_iso_utc(ts: &str) -> Result<DateTime<Utc>> {
DateTime::parse_from_rfc3339(ts)
.map(|parsed| parsed.with_timezone(&Utc))
.with_context(|| format!("parse timestamp {ts}"))
}
fn to_iso_utc(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn normalize_event_ts(ts: &str) -> String {
parse_iso_utc(ts)
.map(|dt| to_iso_utc(dt - TimeDelta::nanoseconds(dt.timestamp_subsec_nanos().into())))
.unwrap_or_else(|_| ts.to_string())
}
fn value_string(data: &Value, key: &str) -> String {
match data.get(key) {
Some(Value::String(value)) => value.trim().to_string(),
Some(Value::Number(value)) => value.to_string(),
Some(Value::Bool(value)) => value.to_string(),
Some(Value::Null) | None => String::new(),
Some(value) => value.to_string().trim_matches('"').trim().to_string(),
}
}
fn value_i64(data: &Value, key: &str) -> Option<i64> {
match data.get(key) {
Some(Value::Number(value)) => value.as_i64(),
Some(Value::String(value)) => value.trim().parse().ok(),
_ => None,
}
}
fn is_session_active(data: &Value) -> bool {
if data.get("active").and_then(Value::as_bool).unwrap_or(false) {
return true;
}
let state = value_string(data, "state").to_lowercase();
if state == "active" || state == "активно" {
return true;
}
if state == "unknown" {
let sid = value_i64(data, "sessionId").unwrap_or(-1);
let user = value_string(data, "username").to_lowercase();
let session_name = value_string(data, "sessionName").to_lowercase();
return sid > 0
&& !user.is_empty()
&& !user.ends_with('$')
&& (session_name.starts_with("rdp-") || session_name == "console");
}
false
}
fn build_window_title(users: &[String], active_count: usize) -> String {
if users.is_empty() {
"RDP idle".to_string()
} else {
format!("RDP active ({active_count}): {}", users.join(", "))
}
}
fn get_latest_active_session_ids(events: &[AwEvent]) -> BTreeSet<i64> {
let mut grouped: BTreeMap<DateTime<Utc>, Vec<&AwEvent>> = BTreeMap::new();
for event in events {
let Some(ts) = event.timestamp.as_deref() else {
continue;
};
let Ok(parsed) = parse_iso_utc(ts) else {
continue;
};
grouped.entry(parsed).or_default().push(event);
}
let Some((_, latest_events)) = grouped.iter().next_back() else {
return BTreeSet::new();
};
latest_events
.iter()
.filter(|event| is_session_active(&event.data))
.filter_map(|event| value_i64(&event.data, "sessionId"))
.collect()
}
fn normalize_foreground_context(data: &Value) -> Option<ForegroundContext> {
let foreground_process = value_string(data, "foregroundProcess");
let foreground_title = value_string(data, "foregroundTitle");
if foreground_process.is_empty() && foreground_title.is_empty() {
return None;
}
let app = if foreground_process.ends_with(".exe") {
foreground_process.clone()
} else {
format!("{foreground_process}.exe")
};
Some(ForegroundContext {
app,
title: if foreground_title.is_empty() {
foreground_process
} else {
foreground_title
},
timestamp: None,
})
}
fn get_latest_foreground_context(
aw: &AwClient,
config: &Config,
now_utc: DateTime<Utc>,
active_session_ids: &BTreeSet<i64>,
state: &State,
) -> Result<Option<ForegroundContext>> {
let mut recent_candidates: Vec<(Option<i64>, DateTime<Utc>, ForegroundContext)> = Vec::new();
for event in aw
.get_events(
&config.web_category_bucket(),
config.collector_health_query_limit,
)?
.into_iter()
.rev()
{
let data = &event.data;
if value_string(data, "signalType").to_lowercase() != "collector_health" {
continue;
}
let Some(ts) = event.timestamp.as_deref() else {
continue;
};
let Ok(event_dt) = parse_iso_utc(ts) else {
continue;
};
if (now_utc - event_dt).num_milliseconds() as f64 / 1000.0
> config.collector_health_max_age_seconds
{
continue;
}
let Some(normalized) = normalize_foreground_context(data) else {
continue;
};
recent_candidates.push((value_i64(data, "sessionId"), event_dt, normalized));
}
for (session_id, event_dt, mut normalized) in recent_candidates.iter().cloned() {
if !active_session_ids.is_empty()
&& session_id.is_some_and(|sid| active_session_ids.contains(&sid))
{
normalized.timestamp = Some(to_iso_utc(event_dt));
return Ok(Some(normalized));
}
}
if let Some((_, event_dt, mut normalized)) = recent_candidates.into_iter().next() {
normalized.timestamp = Some(to_iso_utc(event_dt));
return Ok(Some(normalized));
}
if let Some(cached) = &state.last_foreground_context {
if let Some(cached_ts) = cached
.timestamp
.as_deref()
.filter(|value| !value.trim().is_empty())
{
if let Ok(cached_dt) = parse_iso_utc(cached_ts) {
let age = (now_utc - cached_dt).num_milliseconds() as f64 / 1000.0;
if age <= config.foreground_context_cache_seconds
&& (!cached.app.trim().is_empty() || !cached.title.trim().is_empty())
{
return Ok(Some(ForegroundContext {
app: if cached.app.trim().is_empty() {
"RDP".to_string()
} else {
cached.app.trim().to_string()
},
title: if cached.title.trim().is_empty() {
cached.app.trim().to_string()
} else {
cached.title.trim().to_string()
},
timestamp: Some(cached_ts.to_string()),
}));
}
}
}
}
Ok(None)
}
fn transform(
events: &[AwEvent],
foreground_context: Option<&ForegroundContext>,
) -> (Vec<AwEvent>, Vec<AwEvent>, Option<String>) {
let mut out_afk = Vec::new();
let mut out_win = Vec::new();
let mut last_ts = None;
let mut grouped: BTreeMap<String, Vec<&AwEvent>> = BTreeMap::new();
for event in events {
let Some(ts) = event.timestamp.as_deref() else {
continue;
};
grouped
.entry(normalize_event_ts(ts))
.or_default()
.push(event);
}
let ordered_ts: Vec<String> = grouped.keys().cloned().collect();
let parsed_ts: BTreeMap<String, Option<DateTime<Utc>>> = ordered_ts
.iter()
.map(|ts| (ts.clone(), parse_iso_utc(ts).ok()))
.collect();
for (idx, ts) in ordered_ts.iter().enumerate() {
let rows = &grouped[ts];
let src_duration = rows
.iter()
.map(|event| event.duration.unwrap_or(0.0))
.fold(0.0, f64::max);
let mut duration = src_duration;
let cur_dt = parsed_ts.get(ts).and_then(|dt| *dt);
let next_dt = ordered_ts
.get(idx + 1)
.and_then(|next_ts| parsed_ts.get(next_ts))
.and_then(|dt| *dt);
let next_gap = match (cur_dt, next_dt) {
(Some(cur_dt), Some(next_dt)) => {
Some(((next_dt - cur_dt).num_milliseconds() as f64 / 1000.0).max(0.0))
}
_ => None,
};
if duration <= 0.0 {
if let Some(next_gap) = next_gap {
duration = next_gap;
}
if duration <= 0.0 {
duration = 10.0;
}
} else if let Some(next_gap) = next_gap.filter(|value| *value > 0.0) {
duration = duration.min(next_gap);
}
duration = duration.min(30.0);
let mut active_users: BTreeSet<String> = BTreeSet::new();
for row in rows {
let user = value_string(&row.data, "username");
if !user.is_empty() && is_session_active(&row.data) {
active_users.insert(user);
}
}
let active_users: Vec<String> = active_users.into_iter().collect();
let active_count = active_users.len();
let is_active = active_count > 0;
out_afk.push(AwEvent {
timestamp: Some(ts.clone()),
duration: Some(duration),
data: json!({
"status": if is_active { "not-afk" } else { "afk" },
"source": BRIDGE_SOURCE,
}),
});
let win_data = if is_active {
if let Some(context) = foreground_context {
let mut title = context.title.trim().to_string();
if active_count > 1 {
let rdp_title = build_window_title(&active_users, active_count);
title = if title.is_empty() {
rdp_title
} else {
format!("{title} | {rdp_title}")
};
}
json!({
"app": if context.app.trim().is_empty() { "RDP" } else { context.app.trim() },
"title": if title.is_empty() { build_window_title(&active_users, active_count) } else { title },
"source": BRIDGE_SOURCE,
})
} else {
json!({
"app": "RDP",
"title": build_window_title(&active_users, active_count),
"source": BRIDGE_SOURCE,
})
}
} else {
json!({
"app": "RDP",
"title": build_window_title(&active_users, active_count),
"source": BRIDGE_SOURCE,
})
};
out_win.push(AwEvent {
timestamp: Some(ts.clone()),
duration: Some(duration),
data: win_data,
});
last_ts = Some(ts.clone());
}
(out_afk, out_win, last_ts)
}
fn normalize_watcher_window_events(win_events: &[AwEvent]) -> Vec<AwEvent> {
let mut normalized = Vec::new();
for event in win_events {
let app = value_string(&event.data, "app");
if app.is_empty() || app.to_uppercase() == "RDP" {
continue;
}
let mut data = event.data.clone();
let title = value_string(&data, "title");
if let Some((prefix, _)) = title.split_once(" | RDP active (") {
if let Some(map) = data.as_object_mut() {
map.insert(
"title".to_string(),
Value::String(prefix.trim().to_string()),
);
}
}
let mut cloned = event.clone();
cloned.data = data;
normalized.push(cloned);
}
normalized
}
fn get_latest_bucket_event_ts(aw: &AwClient, bucket_id: &str) -> Result<Option<DateTime<Utc>>> {
let Some(event) = aw.get_latest_bucket_event(bucket_id)? else {
return Ok(None);
};
let Some(ts) = event.timestamp.as_deref() else {
return Ok(None);
};
Ok(parse_iso_utc(ts).ok())
}
fn bucket_needs_fallback(
aw: &AwClient,
bucket_id: &str,
now_utc: DateTime<Utc>,
stale_after_seconds: f64,
) -> Result<bool> {
let Some(latest_dt) = get_latest_bucket_event_ts(aw, bucket_id)? else {
return Ok(true);
};
Ok((now_utc - latest_dt).num_milliseconds() as f64 / 1000.0 >= stale_after_seconds)
}
fn watcher_window_needs_bridge_sync(
aw: &AwClient,
config: &Config,
now_utc: DateTime<Utc>,
) -> Result<bool> {
let bucket_id = config.watcher_window_bucket();
let Some(latest_event) = aw.get_latest_bucket_event(&bucket_id)? else {
return Ok(true);
};
let Some(latest_dt) = get_latest_bucket_event_ts(aw, &bucket_id)? else {
return Ok(true);
};
if (now_utc - latest_dt).num_milliseconds() as f64 / 1000.0
>= config.watcher_fallback_stale_seconds
{
return Ok(true);
}
let source = value_string(&latest_event.data, "source").to_lowercase();
Ok(source == BRIDGE_SOURCE)
}
fn run(cli: &Cli) -> Result<RunSummary> {
let config = load_config();
let aw = AwClient::new(&config)?;
let state = load_state(&config.state_path);
let last_dt = parse_iso_utc(&state.last_ts)
.unwrap_or_else(|_| DateTime::from_timestamp(0, 0).expect("valid unix epoch"));
if !cli.dry_run {
aw.ensure_bucket(
&config.afk_bucket(),
"afkstatus",
BRIDGE_SOURCE,
&config.host,
)?;
aw.ensure_bucket(
&config.window_bucket(),
"currentwindow",
BRIDGE_SOURCE,
&config.host,
)?;
}
let now_utc = Utc::now();
let recent = aw.get_events(&config.sessions_bucket(), 5000)?;
if recent.is_empty() {
return Ok(RunSummary {
ok: true,
dry_run: cli.dry_run,
host: config.host,
input_events: 0,
posted_afk: 0,
posted_win: 0,
watcher_afk_posted: 0,
watcher_win_posted: 0,
last_ts: None,
state_saved: false,
});
}
let events: Vec<AwEvent> = recent
.into_iter()
.filter(|event| {
event
.timestamp
.as_deref()
.and_then(|ts| parse_iso_utc(ts).ok())
.is_some_and(|ts| ts > last_dt)
})
.collect();
if events.is_empty() {
return Ok(RunSummary {
ok: true,
dry_run: cli.dry_run,
host: config.host,
input_events: 0,
posted_afk: 0,
posted_win: 0,
watcher_afk_posted: 0,
watcher_win_posted: 0,
last_ts: None,
state_saved: false,
});
}
let active_session_ids = get_latest_active_session_ids(&events);
let foreground_context =
get_latest_foreground_context(&aw, &config, now_utc, &active_session_ids, &state)?;
let (afk_events, win_events, new_last_ts) = transform(&events, foreground_context.as_ref());
let Some(new_last_ts) = new_last_ts else {
return Ok(RunSummary {
ok: true,
dry_run: cli.dry_run,
host: config.host,
input_events: events.len(),
posted_afk: 0,
posted_win: 0,
watcher_afk_posted: 0,
watcher_win_posted: 0,
last_ts: None,
state_saved: false,
});
};
if afk_events.is_empty() || win_events.is_empty() {
return Ok(RunSummary {
ok: true,
dry_run: cli.dry_run,
host: config.host,
input_events: events.len(),
posted_afk: 0,
posted_win: 0,
watcher_afk_posted: 0,
watcher_win_posted: 0,
last_ts: Some(new_last_ts),
state_saved: false,
});
}
let watcher_win_events = normalize_watcher_window_events(&win_events);
let mut watcher_afk_posted = 0;
let mut watcher_win_posted = 0;
if !cli.dry_run {
aw.post_events(&config.afk_bucket(), &afk_events)?;
aw.post_events(&config.window_bucket(), &win_events)?;
}
if config.watcher_fallback_enabled {
if bucket_needs_fallback(
&aw,
&config.watcher_afk_bucket(),
now_utc,
config.watcher_fallback_stale_seconds,
)? {
watcher_afk_posted = afk_events.len();
if !cli.dry_run {
aw.ensure_bucket(
&config.watcher_afk_bucket(),
"afkstatus",
"aw-watcher-afk",
&config.host,
)?;
aw.post_events(&config.watcher_afk_bucket(), &afk_events)?;
}
}
if !watcher_win_events.is_empty()
&& watcher_window_needs_bridge_sync(&aw, &config, now_utc)?
{
watcher_win_posted = watcher_win_events.len();
if !cli.dry_run {
aw.ensure_bucket(
&config.watcher_window_bucket(),
"currentwindow",
"aw-watcher-window",
&config.host,
)?;
aw.post_events(&config.watcher_window_bucket(), &watcher_win_events)?;
}
}
}
let next_context = foreground_context.or(state.last_foreground_context);
let mut next_state = State {
last_ts: new_last_ts.clone(),
last_foreground_context: None,
};
if let Some(context) = next_context {
next_state.last_foreground_context = Some(ForegroundContext {
app: context.app,
title: context.title,
timestamp: context.timestamp.or_else(|| Some(to_iso_utc(now_utc))),
});
}
if !cli.dry_run {
save_state(&config.state_path, &next_state)?;
}
Ok(RunSummary {
ok: true,
dry_run: cli.dry_run,
host: config.host,
input_events: events.len(),
posted_afk: afk_events.len(),
posted_win: win_events.len(),
watcher_afk_posted,
watcher_win_posted,
last_ts: Some(new_last_ts),
state_saved: !cli.dry_run,
})
}
fn main() -> Result<()> {
let cli = Cli::parse();
let summary = run(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else if summary.posted_afk > 0 || summary.posted_win > 0 {
println!(
"posted_afk={} posted_win={} last_ts={}",
summary.posted_afk,
summary.posted_win,
summary.last_ts.as_deref().unwrap_or("")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn event(ts: &str, duration: f64, data: Value) -> AwEvent {
AwEvent {
timestamp: Some(ts.to_string()),
duration: Some(duration),
data,
}
}
#[test]
fn latest_active_session_ids_uses_latest_timestamp_group() {
let events = vec![
event(
"2026-05-27T07:59:25Z",
0.0,
json!({"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}),
),
event(
"2026-05-27T07:59:30Z",
0.0,
json!({"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}),
),
event(
"2026-05-27T07:59:30Z",
0.0,
json!({"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}),
),
event(
"2026-05-27T07:59:30Z",
0.0,
json!({"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""}),
),
];
assert_eq!(
get_latest_active_session_ids(&events),
BTreeSet::from([2, 3])
);
}
#[test]
fn transform_uses_foreground_context_for_active_sessions() {
let events = vec![event(
"2026-05-27T07:59:30Z",
0.0,
json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}),
)];
let ctx = ForegroundContext {
app: "totalcmd.exe".to_string(),
title: "Total Commander 6.01 - HARVEST".to_string(),
timestamp: None,
};
let (afk_events, win_events, last_ts) = transform(&events, Some(&ctx));
assert_eq!(value_string(&afk_events[0].data, "status"), "not-afk");
assert_eq!(value_string(&win_events[0].data, "app"), "totalcmd.exe");
assert_eq!(
value_string(&win_events[0].data, "title"),
"Total Commander 6.01 - HARVEST"
);
assert_eq!(last_ts.as_deref(), Some("2026-05-27T07:59:30Z"));
}
#[test]
fn transform_caps_duration_at_next_timestamp_gap() {
let events = vec![
event(
"2026-05-27T07:59:30Z",
5.0,
json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}),
),
event(
"2026-05-27T07:59:31Z",
5.0,
json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}),
),
];
let ctx = ForegroundContext {
app: "totalcmd.exe".to_string(),
title: "Total Commander 6.01 - HARVEST".to_string(),
timestamp: None,
};
let (_, win_events, _) = transform(&events, Some(&ctx));
assert_eq!(win_events[0].duration, Some(1.0));
assert_eq!(win_events[1].duration, Some(5.0));
}
#[test]
fn normalize_watcher_window_events_strips_rdp_suffix_for_real_apps() {
let events = vec![
event(
"2026-05-27T08:10:00Z",
5.0,
json!({"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор", "source": BRIDGE_SOURCE}),
),
event(
"2026-05-27T08:10:05Z",
5.0,
json!({"app": "RDP", "title": "RDP active (2): user5, администратор", "source": BRIDGE_SOURCE}),
),
];
let normalized = normalize_watcher_window_events(&events);
assert_eq!(
value_string(&normalized[0].data, "title"),
"Total Commander 6.01 - HARVEST"
);
assert_eq!(normalized.len(), 1);
}
#[test]
fn unknown_rdp_session_counts_as_active() {
let data = json!({"sessionId": 5, "state": "Unknown", "username": "user5", "sessionName": "rdp-tcp#0"});
assert!(is_session_active(&data));
}
#[test]
fn machine_unknown_session_is_not_active() {
let data = json!({"sessionId": 5, "state": "Unknown", "username": "HOST$", "sessionName": "rdp-tcp#0"});
assert!(!is_session_active(&data));
}
}