From ae1e3201763635c3df62d0ec23747d39cbac2c49 Mon Sep 17 00:00:00 2001 From: Demmie <2e3s19@gmail.com> Date: Sat, 25 Nov 2023 00:13:35 -0500 Subject: [PATCH] Add an ability to run external modules --- .gitignore | 1 + Cargo.lock | 50 ++++--- Cargo.toml | 10 +- README.md | 10 +- src/bundle.rs | 46 +----- src/bundle/menu.rs | 62 +++++--- src/bundle/modules.rs | 335 ++++++++++++++++++++++++++++++++++++++++++ watchers/Cargo.toml | 2 +- 8 files changed, 431 insertions(+), 85 deletions(-) create mode 100644 src/bundle/modules.rs diff --git a/.gitignore b/.gitignore index cac4db0..284aad8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target src/bundle/logo.argb32 +lcov.info diff --git a/Cargo.lock b/Cargo.lock index c1e3800..b218cb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,7 +239,7 @@ dependencies = [ "cfg-if 1.0.0", "event-listener 3.0.0", "futures-lite", - "rustix 0.38.15", + "rustix 0.38.25", "windows-sys", ] @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "awatcher" -version = "0.2.2-beta2" +version = "0.2.3" dependencies = [ "anyhow", "aw-datastore", @@ -459,6 +459,9 @@ dependencies = [ "ksni", "log", "open", + "rstest", + "serde", + "tempfile", "tokio", "toml 0.8.1", "watchers", @@ -1729,7 +1732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ "hermit-abi 0.3.3", - "rustix 0.38.15", + "rustix 0.38.25", "windows-sys", ] @@ -1839,9 +1842,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.148" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" [[package]] name = "libdbus-sys" @@ -1881,9 +1884,9 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" -version = "0.4.7" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" +checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" [[package]] name = "lock_api" @@ -2543,6 +2546,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_users" version = "0.4.3" @@ -2871,14 +2883,14 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.15" +version = "0.38.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531" +checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" dependencies = [ "bitflags 2.4.0", "errno", "libc", - "linux-raw-sys 0.4.7", + "linux-raw-sys 0.4.11", "windows-sys", ] @@ -2986,9 +2998,9 @@ checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0" [[package]] name = "serde" -version = "1.0.188" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] @@ -3007,9 +3019,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.188" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2 1.0.67", "quote 1.0.33", @@ -3250,14 +3262,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.8.0" +version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" +checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" dependencies = [ "cfg-if 1.0.0", "fastrand 2.0.1", - "redox_syscall 0.3.5", - "rustix 0.38.15", + "redox_syscall 0.4.1", + "rustix 0.38.25", "windows-sys", ] @@ -3809,7 +3821,7 @@ checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "watchers" -version = "0.2.2-beta2" +version = "0.2.3" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1eced3b..f178645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,12 +18,17 @@ image = { version = "0.24.6" } members = ["watchers"] [workspace.package] -version = "0.2.2-beta2" +version = "0.2.3" [workspace.dependencies] anyhow = "1.0.75" log = { version = "0.4.20", features = ["std"] } tokio = { version = "1.32.0" } +serde = "1.0.193" + +[dev-dependencies] +rstest = "0.18.2" +tempfile = "3.8.1" [dependencies] watchers = { path = "./watchers", default-features = false } @@ -39,12 +44,13 @@ ksni = {version = "0.2.1", optional = true} aw-server = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "448312d" } aw-datastore = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "448312d" } open = { version = "5.0.0", optional = true } +serde = { workspace = true, optional = true } [features] default = ["gnome", "kwin_window"] gnome = ["watchers/gnome"] kwin_window = ["watchers/kwin_window"] -bundle = ["ksni", "aw-server", "aw-datastore", "open"] +bundle = ["ksni", "aw-server", "aw-datastore", "open", "serde"] [package.metadata.deb] features = ["bundle"] diff --git a/README.md b/README.md index c49c47f..c314b93 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ The binaries for the bundle, bundled DEB and ActivityWatch watchers replacement ### Bundle with built-in ActivityWatch -This is a single binary to run **awatcher** with the server without changing system and ActivityWatch configuration, -when only tracking activity windows and idle state is needed. +This is a single binary to run **awatcher** with the server without changing system and ActivityWatch configuration. The bundle is **aw-server-rust** and **awatcher** as a single executable. -The data storage is compatible with ActivityWatch and **aw-server-rust** (**aw-server** has a different storage), -so this can later be run as a module for ActivityWatch. +The data storage is compatible with ActivityWatch and **aw-server-rust** (**aw-server** has a different storage), so this can later be run as a module for ActivityWatch. + +External modules are run like in the original ActivityWatch distribution +by looking at `$PATH` and running all binaries which start with `aw-`. +They are controled from the tray, no additional configuration is necessary. ## Supported environments diff --git a/src/bundle.rs b/src/bundle.rs index 6c17c3f..3523315 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -1,33 +1,11 @@ mod menu; +mod modules; mod server; pub use menu::Tray; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::PathBuf; use tokio::sync::mpsc::UnboundedSender; -fn get_config_watchers(config_path: &Path) -> Option> { - let mut config_path = config_path.parent()?.to_path_buf(); - config_path.push("bundle-config.toml"); - debug!("Reading bundle config at {}", config_path.display()); - - let config_content = std::fs::read_to_string(&config_path).ok()?; - let toml_content: toml::Value = toml::from_str(&config_content).ok()?; - - trace!("Bundle config: {toml_content:?}"); - - Some( - toml_content - .get("watchers")? - .get("autostart")? - .as_array()? - .iter() - .filter_map(|value| value.as_str()) - .map(std::string::ToString::to_string) - .collect(), - ) -} - pub async fn run( host: String, port: u32, @@ -35,22 +13,14 @@ pub async fn run( no_tray: bool, shutdown_sender: UnboundedSender<()>, ) { - let watchers: Vec = - get_config_watchers(config_file.parent().unwrap()).unwrap_or_default(); - - for watcher in &watchers { - debug!("Starting an external watcher {}", watcher); - let _ = Command::new(watcher).spawn(); - } + let manager = modules::Manager::new( + &std::env::var("PATH").unwrap_or_default(), + config_file.parent().unwrap(), + ); if !no_tray { - let service = ksni::TrayService::new(Tray::new( - host, - port, - config_file, - shutdown_sender, - watchers, - )); + let tray = Tray::new(host, port, config_file, shutdown_sender, manager); + let service = ksni::TrayService::new(tray); service.spawn(); } diff --git a/src/bundle/menu.rs b/src/bundle/menu.rs index b7c1c91..57dd5bf 100644 --- a/src/bundle/menu.rs +++ b/src/bundle/menu.rs @@ -1,14 +1,17 @@ +use std::collections::HashMap; use std::path::PathBuf; use tokio::sync::mpsc::UnboundedSender; -#[derive(Debug)] +use super::modules::Manager; + pub struct Tray { server_host: String, server_port: u32, config_file: PathBuf, shutdown_sender: UnboundedSender<()>, - watchers: Vec, + watchers_manager: Manager, + checks: HashMap, } impl Tray { @@ -17,14 +20,21 @@ impl Tray { server_port: u32, config_file: PathBuf, shutdown_sender: UnboundedSender<()>, - watchers: Vec, + watchers_manager: Manager, ) -> Self { + let checks = watchers_manager + .path_watchers + .iter() + .map(|watcher| (watcher.path().to_owned(), watcher.started())) + .collect(); + Self { server_host, server_port, config_file, shutdown_sender, - watchers, + watchers_manager, + checks, } } } @@ -38,9 +48,14 @@ impl ksni::Tray for Tray { }] } + fn id(&self) -> String { + "awatcher-bundle".into() + } + fn title(&self) -> String { "Awatcher".into() } + fn menu(&self) -> Vec> { let mut watchers_submenu: Vec> = vec![ ksni::menu::CheckmarkItem { @@ -59,12 +74,23 @@ impl ksni::Tray for Tray { } .into(), ]; - for watcher in &self.watchers { + for watcher in &self.watchers_manager.path_watchers { + let path = watcher.path().to_owned(); + watchers_submenu.push( ksni::menu::CheckmarkItem { - label: watcher.clone(), - enabled: false, - checked: true, + label: watcher.name(), + enabled: true, + checked: watcher.started(), + activate: Box::new(move |this: &mut Self| { + let current_checked = *this.checks.get(&path).unwrap_or(&false); + this.checks.insert(path.clone(), !current_checked); + if current_checked { + this.watchers_manager.stop_watcher(&path); + } else { + this.watchers_manager.start_watcher(&path); + } + }), ..Default::default() } .into(), @@ -76,13 +102,11 @@ impl ksni::Tray for Tray { label: "ActivityWatch".into(), // https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html icon_name: "document-properties".into(), - activate: { - let url = format!("http://{}:{}", self.server_host, self.server_port); + activate: Box::new(move |this: &mut Self| { + let url = format!("http://{}:{}", this.server_host, this.server_port); - Box::new(move |_| { - open::that(&url).unwrap(); - }) - }, + open::that(url).unwrap(); + }), ..Default::default() } .into(), @@ -108,13 +132,9 @@ impl ksni::Tray for Tray { ksni::menu::StandardItem { label: "Exit".into(), icon_name: "application-exit".into(), - activate: { - let shutdown_sender = self.shutdown_sender.clone(); - - Box::new(move |_| { - shutdown_sender.send(()).unwrap(); - }) - }, + activate: Box::new(move |this: &mut Self| { + this.shutdown_sender.send(()).unwrap(); + }), ..Default::default() } .into(), diff --git a/src/bundle/modules.rs b/src/bundle/modules.rs new file mode 100644 index 0000000..56e97b0 --- /dev/null +++ b/src/bundle/modules.rs @@ -0,0 +1,335 @@ +// This repeats the functionality of aw-qt from ActivityWatch. + +use serde::{Deserialize, Serialize}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +#[derive(Debug, Serialize, Deserialize, Default)] +struct Watchers { + #[serde(default)] + autostart: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +struct BundleConfig { + watchers: Watchers, +} + +pub struct ExternalWatcher { + path: PathBuf, + handle: Option, +} + +impl ExternalWatcher { + fn new(path: PathBuf) -> Option { + if !path.is_file() { + return None; + } + if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { + return None; + } + if !path.file_name()?.to_str()?.starts_with("aw-") { + return None; + } + + Some(Self { path, handle: None }) + } + + fn start(&mut self) -> bool { + if self.started() { + debug!("Watcher {} is already started", self.name()); + return true; + } + debug!("Starting an external watcher {}", self.name()); + + let command = Command::new(&self.path).stdout(Stdio::null()).spawn(); + + match command { + Ok(handle) => { + self.handle = Some(handle); + true + } + Err(e) => { + error!("Failed to start watcher {}: {e}", self.name()); + false + } + } + } + + fn stop(&mut self) { + self.handle = if let Some(mut handle) = self.handle.take() { + debug!("Stopping an external watcher {}", self.name()); + if let Err(e) = handle.kill() { + error!("Failed to kill watcher {}: {}", self.name(), e); + + Some(handle) + } else { + None + } + } else { + None + } + } + + pub fn started(&self) -> bool { + self.handle.is_some() + } + + pub fn name(&self) -> String { + self.path.file_name().unwrap().to_string_lossy().to_string() + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } +} + +pub struct Manager { + config_path: PathBuf, + config: BundleConfig, + pub path_watchers: Vec, +} + +impl Manager { + pub fn new(path_env: &str, config_path: &Path) -> Self { + let mut config_path = config_path.to_path_buf(); + config_path.push("bundle-config.toml"); + debug!("Processing bundle config at {}", config_path.display()); + + let config = Self::get_config(&config_path); + + let mut path_watchers = Self::get_watchers_from_path_env(path_env); + for watcher in &mut path_watchers { + debug!("Found external watcher {}", watcher.name()); + let file_name = watcher.path.file_name().unwrap(); + if config + .watchers + .autostart + .contains(&file_name.to_string_lossy().to_string()) + { + watcher.start(); + } else { + debug!( + "External watcher {} is not configured to autostart", + watcher.name() + ); + } + } + + Self { + config_path, + config, + path_watchers, + } + } + + pub fn start_watcher(&mut self, watcher_path: &Path) -> bool { + let watcher_name = if let Some(watcher) = self.get_watcher_by_path(watcher_path) { + if watcher.start() { + watcher.name().to_string() + } else { + return false; + } + } else { + return false; + }; + if !self.config.watchers.autostart.contains(&watcher_name) { + self.config.watchers.autostart.push(watcher_name.clone()); + + self.update_config_watchers(); + } + true + } + + pub fn stop_watcher(&mut self, watcher_path: &Path) { + let watcher_name = if let Some(watcher) = self.get_watcher_by_path(watcher_path) { + watcher.stop(); + Some(watcher.name().to_string()) + } else { + None + }; + if let Some(watcher_name) = watcher_name { + self.config + .watchers + .autostart + .retain(|check| check != &watcher_name); + + self.update_config_watchers(); + } + } + + fn update_config_watchers(&mut self) { + let toml_content = toml::to_string_pretty(&self.config).unwrap(); + std::fs::write(&self.config_path, toml_content).unwrap(); + } + + fn get_watcher_by_path(&mut self, watcher_path: &Path) -> Option<&mut ExternalWatcher> { + self.path_watchers + .iter_mut() + .find(|watcher| watcher.path() == watcher_path) + .or_else(|| { + error!("Watcher is not found {}", watcher_path.display()); + None + }) + } + + fn get_config(config_path: &Path) -> BundleConfig { + let config_content = std::fs::read_to_string(config_path).ok(); + + if let Some(content) = config_content { + toml::from_str(&content).unwrap_or_default() + } else { + debug!( + "No bundle config found at {}, creating new file", + config_path.display() + ); + let config = BundleConfig::default(); + + let toml_content = toml::to_string_pretty(&config).unwrap(); + std::fs::write(config_path, toml_content).unwrap(); + + config + } + } + + fn get_watchers_from_path_env(path_env: &str) -> Vec { + path_env + .split(':') + .map(Path::new) + .filter(|&path| path.is_dir()) + .filter_map(|path| path.read_dir().ok()) + .flat_map(Iterator::flatten) + .map(|entry| entry.path()) + .filter_map(ExternalWatcher::new) + .fold(Vec::new(), |mut acc, watcher| { + if acc.iter().any(|check| check.name() == watcher.name()) { + warn!( + "Duplicate watcher {} found in PATH, not running", + watcher.path.display() + ); + } else { + acc.push(watcher); + } + acc + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::{fixture, rstest}; + use std::fs::File; + use std::io::Write; + use tempfile::{tempdir, TempDir}; + + #[test] + fn test_get_watchers_from_path_env() { + let dir = tempdir().unwrap(); + + let path = dir.path().join("test"); + let test_file = File::create(path).unwrap(); + + let path = dir.path().join("aw-test"); + let aw_test_file = File::create(path).unwrap(); + + let watchers = Manager::get_watchers_from_path_env(dir.path().to_str().unwrap()); + assert_eq!(watchers.len(), 0); + + let mut permissions = test_file.metadata().unwrap().permissions(); + permissions.set_mode(0o111); + test_file.set_permissions(permissions).unwrap(); + + let mut permissions = aw_test_file.metadata().unwrap().permissions(); + permissions.set_mode(0o111); + aw_test_file.set_permissions(permissions).unwrap(); + + let watchers = Manager::get_watchers_from_path_env(dir.path().to_str().unwrap()); + assert_eq!(watchers.len(), 1); + assert_eq!(watchers[0].name(), "aw-test"); + } + + #[rstest] + fn test_manager(temp_dir: TempDir) { + std::fs::write( + temp_dir.path().join("bundle-config.toml").as_path(), + b"[watchers]\nautostart = [\"aw-test\", \"absent\"]\n", + ) + .unwrap(); + let mut manager = Manager::new(temp_dir.path().to_str().unwrap(), temp_dir.path()); + assert_eq!(manager.path_watchers.len(), 1); + assert_eq!(manager.path_watchers[0].name(), "aw-test"); + assert!(manager.path_watchers[0].handle.is_some()); + + assert!(!manager.start_watcher(&temp_dir.path().join("absent"))); + assert_autostart_content(&manager, &["aw-test", "absent"]); + + assert!(manager.start_watcher(&temp_dir.path().join("aw-test"))); // already started + assert!(manager.path_watchers[0].handle.is_some()); + assert_autostart_content(&manager, &["aw-test", "absent"]); + + manager.stop_watcher(&temp_dir.path().join("absent")); + assert_autostart_content(&manager, &["aw-test", "absent"]); + + manager.stop_watcher(&temp_dir.path().join("aw-test")); + assert!(manager.path_watchers[0].handle.is_none()); + assert_autostart_content(&manager, &["absent"]); + } + + #[rstest] + fn test_broken_file(temp_dir: TempDir) { + std::fs::write( + temp_dir.path().join("bundle-config.toml").as_path(), + b"[watchers]\n#autostart = [\"aw-test\"]\n", + ) + .unwrap(); + let mut manager = Manager::new(temp_dir.path().to_str().unwrap(), temp_dir.path()); + assert_eq!(manager.path_watchers.len(), 1); + assert_eq!(manager.path_watchers[0].name(), "aw-test"); + assert!(manager.path_watchers[0].handle.is_none()); // no starting in config + + assert!(manager.start_watcher(&temp_dir.path().join("aw-test"))); + assert!(manager.path_watchers[0].handle.is_some()); + assert_autostart_content(&manager, &["aw-test"]); + } + + fn assert_autostart_content(manager: &Manager, watchers: &[&str]) { + assert_eq!(manager.config.watchers.autostart, watchers); + assert_eq!( + std::fs::read_to_string(manager.config_path.as_path()).unwrap(), + format!( + "[watchers]\nautostart = [{}]\n", + watchers + .iter() + .map(|w| format!("\"{w}\"")) + .collect::>() + .join(", "), + ) + ); + } + + #[fixture] + fn temp_dir() -> TempDir { + let dir = tempdir().unwrap(); + + create_test_watcher(dir.path()); + + dir + } + + fn create_test_watcher(bin_dir: &Path) { + let exec_path = bin_dir.join("aw-test"); + let mut aw_test_file = File::create(exec_path).unwrap(); + // write a bash script with infinite loop and sleep into the file: + aw_test_file + .write_all(b"#!/bin/bash\nwhile true; do sleep 1; done") + .unwrap(); + // set execution permissions: + let mut permissions = aw_test_file.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + aw_test_file.set_permissions(permissions).unwrap(); + aw_test_file.flush().unwrap(); + aw_test_file.sync_all().unwrap(); + } +} diff --git a/watchers/Cargo.toml b/watchers/Cargo.toml index 2a8936e..ae3063a 100644 --- a/watchers/Cargo.toml +++ b/watchers/Cargo.toml @@ -23,7 +23,7 @@ zbus = {version = "3.14.1", optional = true} chrono = "0.4.31" toml = "0.8.1" dirs = "5.0.1" -serde = { version = "1.0.188", features = ["derive"] } +serde = { workspace = true, features = ["derive"] } serde_default = "0.1.0" serde_json = "1.0.107" regex = "1.9.5"