Add an ability to run external modules

This commit is contained in:
Demmie 2023-11-25 00:13:35 -05:00
parent 864d1dc364
commit ae1e320176
No known key found for this signature in database
GPG Key ID: B06DAA3D432C6E9A
8 changed files with 431 additions and 85 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target
src/bundle/logo.argb32
lcov.info

50
Cargo.lock generated
View File

@ -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",

View File

@ -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"]

View File

@ -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

View File

@ -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<Vec<String>> {
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<String> =
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();
}

View File

@ -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<String>,
watchers_manager: Manager,
checks: HashMap<PathBuf, bool>,
}
impl Tray {
@ -17,14 +20,21 @@ impl Tray {
server_port: u32,
config_file: PathBuf,
shutdown_sender: UnboundedSender<()>,
watchers: Vec<String>,
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<ksni::MenuItem<Self>> {
let mut watchers_submenu: Vec<ksni::MenuItem<Self>> = 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(),

335
src/bundle/modules.rs Normal file
View File

@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct BundleConfig {
watchers: Watchers,
}
pub struct ExternalWatcher {
path: PathBuf,
handle: Option<Child>,
}
impl ExternalWatcher {
fn new(path: PathBuf) -> Option<Self> {
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<ExternalWatcher>,
}
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<ExternalWatcher> {
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::<Vec<String>>()
.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();
}
}

View File

@ -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"