mirror of
https://github.com/2e3s/awatcher.git
synced 2025-07-29 06:11:43 +00:00
Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
59c6dd1e7a | ||
![]() |
eafd5a7166 | ||
![]() |
8dde3b04ab | ||
![]() |
c85bbf0aaa | ||
![]() |
620d5865a9 | ||
![]() |
bea3a4ac58 | ||
![]() |
d1e3540669 | ||
![]() |
994b1cec6a | ||
![]() |
b07d3958bb | ||
![]() |
7ac76da921 | ||
![]() |
03e94e2f26 | ||
![]() |
ff16bd6221 | ||
![]() |
9f215c4d76 | ||
![]() |
cfcbdca306 | ||
![]() |
5b5739d756 | ||
![]() |
5b8c90a69c | ||
![]() |
e2ee19f494 | ||
![]() |
21acaeaa35 | ||
![]() |
7ac7c9db92 | ||
![]() |
8cfc14c501 | ||
![]() |
d719c54fdd | ||
![]() |
dd974db422 | ||
![]() |
d733008a45 | ||
![]() |
16942ea163 | ||
![]() |
b4cf791a5e | ||
![]() |
990843bb05 | ||
![]() |
a04578083f | ||
![]() |
810707cc46 | ||
![]() |
e6fc2e4f9c |
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@ -24,8 +24,8 @@ jobs:
|
||||
sudo apt-get install libdbus-1-dev -y
|
||||
sudo apt-get install libssl-dev -y
|
||||
sudo apt-get install pkg-config -y
|
||||
- name: install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
- run: cargo install cargo-deb
|
||||
- run: cargo install cargo-generate-rpm
|
||||
|
||||
# Build aw-webui
|
||||
- name: Checkout aw-webui
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
repository: ActivityWatch/aw-webui
|
||||
path: aw-webui
|
||||
ref: 2f3d1e8390c3d5314a69bfd1a8d388d90b74280f
|
||||
ref: 291da6f2c5e7a6b896f23a4eec5ffed9874321ba
|
||||
submodules: true
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
@ -88,3 +88,12 @@ jobs:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file_glob: true
|
||||
file: target/debian/aw-awatcher*.deb
|
||||
|
||||
- run: cargo generate-rpm --variant=bundle
|
||||
- run: cargo generate-rpm --variant=module
|
||||
- name: Upload bundle RPM to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file_glob: true
|
||||
file: target/generate-rpm/*.rpm
|
||||
|
2001
Cargo.lock
generated
2001
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
51
Cargo.toml
51
Cargo.toml
@ -4,7 +4,7 @@ description = "An activity and idle watcher based on ActivityWatch"
|
||||
version = { workspace = true }
|
||||
authors = ["Demmie <2e3s19@gmail.com>"]
|
||||
edition = "2021"
|
||||
license-file = "LICENSE"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://github.com/2e3s/awatcher"
|
||||
|
||||
[[bin]]
|
||||
@ -12,38 +12,38 @@ name = "awatcher"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
image = { version = "0.25.1" }
|
||||
image = { version = "0.25.6" }
|
||||
|
||||
[workspace]
|
||||
members = ["watchers"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.3.2-alpha.3"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.83"
|
||||
log = { version = "0.4.21", features = ["std"] }
|
||||
tokio = { version = "1.37.0" }
|
||||
serde = "1.0.202"
|
||||
anyhow = "1.0.98"
|
||||
log = { version = "0.4.27", features = ["std"] }
|
||||
tokio = { version = "1.47.0" }
|
||||
serde = "1.0.219"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.21.0"
|
||||
tempfile = "3.10.1"
|
||||
rstest = "0.26.1"
|
||||
tempfile = "3.20.0"
|
||||
|
||||
[dependencies]
|
||||
watchers = { path = "./watchers", default-features = false }
|
||||
chrono = "0.4.38"
|
||||
toml = "0.8.13"
|
||||
clap = { version = "4.5.4", features = ["string"] }
|
||||
fern = { version = "0.6.2", features = ["colored"] }
|
||||
chrono = "0.4.41"
|
||||
toml = "0.9.2"
|
||||
clap = { version = "4.5.41", features = ["string"] }
|
||||
fern = { version = "0.7.1", features = ["colored"] }
|
||||
log = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "signal"] }
|
||||
|
||||
ksni = {version = "0.2.2", optional = true}
|
||||
aw-server = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "bb787fd" }
|
||||
aw-datastore = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "bb787fd" }
|
||||
open = { version = "5.1.3", optional = true }
|
||||
aw-server = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "656f3c9" }
|
||||
aw-datastore = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "656f3c9" }
|
||||
open = { version = "5.3.2", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
@ -79,3 +79,22 @@ assets = [
|
||||
["README.md", "usr/share/doc/awatcher/README", "644"],
|
||||
]
|
||||
conflicts = "aw-awatcher"
|
||||
|
||||
[package.metadata.generate-rpm.variants.module]
|
||||
name = "aw-awatcher"
|
||||
assets = [
|
||||
{ source = "target/release/awatcher", dest = "/usr/bin/aw-awatcher", mode = "755" },
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm.variants.module.conflicts]
|
||||
awatcher = "*"
|
||||
|
||||
[package.metadata.generate-rpm.variants.bundle]
|
||||
assets = [
|
||||
{ source = "target/release/awatcher", dest = "/usr/bin/awatcher", mode = "755" },
|
||||
{ source = "src/bundle/awatcher.desktop", dest = "/usr/share/applications/", mode = "644" },
|
||||
{ source = "src/bundle/logo.png", dest = "/usr/share/awatcher/icons/awatcher.png", mode = "644" },
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm.variants.bundle.conflicts]
|
||||
aw-awatcher = "*"
|
||||
|
10
README.md
10
README.md
@ -10,16 +10,18 @@ The foundation is [ActivityWatch](https://github.com/ActivityWatch), which inclu
|
||||
The unbundled watcher is supposed to replace the original idle and active window watchers from the original distribution.
|
||||
The bundled executable can be used independently as it contains the server, UI and tray.
|
||||
|
||||
The binaries for the bundle, bundled DEB and ActivityWatch watchers replacement can be downloaded from
|
||||
The binaries for the bundle, bundled DEB/RPM and ActivityWatch watchers replacement can be downloaded from
|
||||
[releases](https://github.com/2e3s/awatcher/releases).
|
||||
At this moment, neither Flatpak nor AppImage support quering Wayland activity.
|
||||
|
||||
### Module for ActivityWatch
|
||||
|
||||
- Run `sudo unzip aw-awatcher.zip -d /usr/local/bin` in the console to allow ActivityWatch to detect its presence.
|
||||
- Or install the provided **aw-awatcher_\*.deb**.
|
||||
- Or install the provided **aw-awatcher_\*.deb** or **aw-awatcher_\*.rpm**.
|
||||
- Remove `aw-watcher-window` and `aw-watcher-afk` from autostart at `aw-qt/aw-qt.toml` in [config directory](https://docs.activitywatch.net/en/latest/directories.html#config),
|
||||
add `aw-awatcher`.
|
||||
- Restart ActivityWatch. In the Modules submenu there should be a new checked module **aw-awatcher**. Note that awatcher shows up in the Web UI under Timeline as `aw-watcher-window_$HOSTNAME`.
|
||||
- Optionally, you can use systemd instead of ActivityWatch runner. In this case, skip adding `aw-awatcher` to `aw-qt.toml` and install this service [configuration](https://github.com/2e3s/awatcher/blob/main/config/aw-awatcher.service). In this case, ActivityWatch server also must be managed by systemd (as `aw-server.service` in the config).
|
||||
|
||||
### Bundle with built-in ActivityWatch
|
||||
|
||||
@ -35,6 +37,7 @@ They are controled from the tray, no additional configuration is necessary.
|
||||
|
||||
It is recommended to use `~/.config/autostart` for the bundle. This folder is employed by "Autostart" in KDE settings and Gnome Tweaks.
|
||||
Systemd may require to sleep for a few seconds (`ExecStartPre=/bin/sleep 5`) in order to wait for the environment.
|
||||
See this service [configuration](https://github.com/2e3s/awatcher/blob/main/config/awatcher.service).
|
||||
|
||||
## Supported environments
|
||||
|
||||
@ -99,7 +102,8 @@ Copy the section as many times as needed for every given filter.
|
||||
- `replace-title` replaces the window title with the provided value.
|
||||
|
||||
The first matching filter stops the replacement.
|
||||
There should be at least 1 match field, and at least 1 replace field for a valid filter.
|
||||
There should be at least 1 match field for a filter to be valid.
|
||||
If the replacement is not specified, the data is not reported when matched.
|
||||
Matches are case sensitive regular expressions between implicit ^ and $:
|
||||
- `.` matches 1 any character
|
||||
- `.*` matches any number of any characters
|
||||
|
17
config/aw-awatcher.service
Normal file
17
config/aw-awatcher.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Service]
|
||||
Type=simple
|
||||
TimeoutStartSec=120
|
||||
ExecStartPre=/bin/sleep 5
|
||||
ExecStart=aw-awatcher
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartSteps=2
|
||||
RestartMaxDelaySec=15
|
||||
|
||||
[Unit]
|
||||
Description=AWatcher
|
||||
After=aw-server.service graphical-session.target
|
||||
Requires=aw-server.service
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
16
config/awatcher.service
Normal file
16
config/awatcher.service
Normal file
@ -0,0 +1,16 @@
|
||||
[Service]
|
||||
Type=simple
|
||||
TimeoutStartSec=120
|
||||
ExecStartPre=/bin/sleep 5
|
||||
ExecStart=awatcher
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartSteps=2
|
||||
RestartMaxDelaySec=15
|
||||
|
||||
[Unit]
|
||||
Description=AWatcher
|
||||
After=graphical-session.target
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
@ -19,10 +19,10 @@ pub async fn run(
|
||||
);
|
||||
|
||||
if !no_tray {
|
||||
let tray = Tray::new(host, port, config_file, shutdown_sender, manager);
|
||||
let tray = Tray::new(host.clone(), port, config_file, shutdown_sender, manager);
|
||||
let service = ksni::TrayService::new(tray);
|
||||
service.spawn();
|
||||
}
|
||||
|
||||
server::run(port).await;
|
||||
server::run(host.clone(), port).await;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::str::FromStr;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use super::modules::Manager;
|
||||
@ -22,6 +23,17 @@ impl Tray {
|
||||
shutdown_sender: UnboundedSender<()>,
|
||||
watchers_manager: Manager,
|
||||
) -> Self {
|
||||
let is_zero_first_octet = match Ipv4Addr::from_str(&server_host) {
|
||||
Ok(ip) => ip.octets()[0] == 0,
|
||||
Err(_) => false,
|
||||
};
|
||||
|
||||
let server_host = if is_zero_first_octet {
|
||||
"localhost".to_string()
|
||||
} else {
|
||||
server_host
|
||||
};
|
||||
|
||||
let checks = watchers_manager
|
||||
.path_watchers
|
||||
.iter()
|
||||
|
@ -289,9 +289,12 @@ mod tests {
|
||||
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")));
|
||||
let watcher_path = &temp_dir.path().join("aw-test");
|
||||
assert!(manager.start_watcher(watcher_path));
|
||||
assert!(manager.path_watchers[0].handle.is_some());
|
||||
assert_autostart_content(&manager, &["aw-test"]);
|
||||
|
||||
manager.stop_watcher(watcher_path);
|
||||
}
|
||||
|
||||
fn assert_autostart_content(manager: &Manager, watchers: &[&str]) {
|
||||
|
@ -1,8 +1,9 @@
|
||||
use anyhow::anyhow;
|
||||
use aw_server::endpoints::{build_rocket, AssetResolver, ServerState};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub async fn run(port: u16) {
|
||||
pub async fn run(host: String, port: u16) {
|
||||
let db_path = aw_server::dirs::db_path(false)
|
||||
.map_err(|()| anyhow!("DB path is not found"))
|
||||
.unwrap()
|
||||
@ -11,8 +12,13 @@ pub async fn run(port: u16) {
|
||||
.to_string();
|
||||
let device_id = aw_server::device_id::get_device_id();
|
||||
let mut config = aw_server::config::create_config(false);
|
||||
config.address = "127.0.0.1".to_string();
|
||||
config.port = port;
|
||||
|
||||
let mut addrs_iter = (host + ":" + &port.to_string()).to_socket_addrs().unwrap();
|
||||
let address = addrs_iter.next().unwrap();
|
||||
|
||||
info!("Starting server on {address}");
|
||||
config.address = address.ip().to_string();
|
||||
config.port = address.port();
|
||||
|
||||
let legacy_import = false;
|
||||
let server_state = ServerState {
|
||||
|
@ -52,7 +52,7 @@ pub fn from_cli() -> anyhow::Result<RunnerConfig> {
|
||||
.args([
|
||||
arg!(-c --config <FILE> "Custom config file").value_parser(value_parser!(PathBuf)),
|
||||
arg!(--port <PORT> "Custom server port")
|
||||
.value_parser(value_parser!(u32))
|
||||
.value_parser(value_parser!(u16))
|
||||
.default_value(defaults::port().to_string()),
|
||||
#[cfg(not(feature = "bundle"))]
|
||||
arg!(--host <HOST> "Custom server host")
|
||||
|
@ -25,9 +25,13 @@ async fn main() -> anyhow::Result<(), Box<dyn Error>> {
|
||||
let config = config.watchers_config;
|
||||
|
||||
if config.no_server {
|
||||
warn!("Not sending to server {}:{}", config.host, config.port);
|
||||
warn!(
|
||||
"Not sending to server {}:{}",
|
||||
config.client_host(),
|
||||
config.port
|
||||
);
|
||||
} else {
|
||||
info!("Sending to server {}:{}", config.host, config.port);
|
||||
info!("Sending to server {}:{}", config.client_host(), config.port);
|
||||
}
|
||||
info!(
|
||||
"Idle timeout: {} seconds",
|
||||
|
@ -10,28 +10,28 @@ crate-type = ["lib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.21.0"
|
||||
tempfile = "3.10.1"
|
||||
rstest = "0.26.1"
|
||||
tempfile = "3.13.0"
|
||||
|
||||
[dependencies]
|
||||
aw-client-rust = { git = "https://github.com/ActivityWatch/aw-server-rust", rev = "bb787fd" }
|
||||
wayland-client = "0.31.1"
|
||||
wayland-protocols = { version = "0.31.2", features = ["staging", "client" ]}
|
||||
wayland-protocols-plasma = { version = "0.2.0", features = ["client"] }
|
||||
wayland-protocols-wlr = { version = "0.2.0", features = ["client"] }
|
||||
aw-client-rust = { git = "https://github.com/ActivityWatch/aw-server-rust", rev = "656f3c9" }
|
||||
wayland-client = "0.31.7"
|
||||
wayland-protocols = { version = "0.32.5", features = ["staging", "client" ]}
|
||||
wayland-protocols-plasma = { version = "0.3.5", features = ["client"] }
|
||||
wayland-protocols-wlr = { version = "0.3.5", features = ["client"] }
|
||||
x11rb = { version = "0.13.1", features = ["screensaver"] }
|
||||
zbus = {version = "4.2.1", optional = true}
|
||||
zbus = {version = "5.1.0", optional = true}
|
||||
chrono = "0.4.38"
|
||||
toml = "0.8.13"
|
||||
dirs = "5.0.1"
|
||||
toml = "0.9.2"
|
||||
dirs = "6.0.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_default = "0.1.0"
|
||||
serde_json = "1.0.117"
|
||||
regex = "1.10.4"
|
||||
gethostname = "0.4.3"
|
||||
serde_default = "0.2.0"
|
||||
serde_json = "1.0.132"
|
||||
regex = "1.11.1"
|
||||
gethostname = "1.0.2"
|
||||
log = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = "0.1.80"
|
||||
async-trait = "0.1.83"
|
||||
tokio = { workspace = true, features = ["time", "sync", "macros"] }
|
||||
|
||||
[features]
|
||||
|
@ -2,9 +2,12 @@ pub mod defaults;
|
||||
mod file_config;
|
||||
mod filters;
|
||||
|
||||
use self::filters::{Filter, Replacement};
|
||||
use std::{net::Ipv4Addr, str::FromStr};
|
||||
|
||||
use self::filters::Filter;
|
||||
use chrono::Duration;
|
||||
pub use file_config::FileConfig;
|
||||
pub use filters::FilterResult;
|
||||
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
@ -16,14 +19,31 @@ pub struct Config {
|
||||
pub filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
fn normalize_server_host(server_host: &str) -> String {
|
||||
let is_zero_first_octet = match Ipv4Addr::from_str(server_host) {
|
||||
Ok(ip) => ip.octets()[0] == 0,
|
||||
Err(_) => false,
|
||||
};
|
||||
if is_zero_first_octet {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
server_host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn window_data_replacement(&self, app_id: &str, title: &str) -> Replacement {
|
||||
pub fn match_window_data(&self, app_id: &str, title: &str) -> FilterResult {
|
||||
for filter in &self.filters {
|
||||
if let Some(replacement) = filter.replacement(app_id, title) {
|
||||
return replacement;
|
||||
let result = filter.apply(app_id, title);
|
||||
if matches!(result, FilterResult::Match | FilterResult::Replace(_)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Replacement::default()
|
||||
FilterResult::Skip
|
||||
}
|
||||
|
||||
pub fn client_host(&self) -> String {
|
||||
normalize_server_host(&self.host)
|
||||
}
|
||||
}
|
||||
|
@ -147,6 +147,8 @@ impl FileConfig {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::FilterResult;
|
||||
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
use std::io::Write;
|
||||
@ -192,16 +194,37 @@ replace-title = "Title"
|
||||
assert_eq!(12, config.client.poll_time_window_seconds);
|
||||
|
||||
assert_eq!(2, config.client.filters.len());
|
||||
let replacement1 = config.client.filters[0]
|
||||
.replacement("firefox", "any")
|
||||
.unwrap();
|
||||
assert_eq!(None, replacement1.replace_app_id);
|
||||
assert_eq!(Some("Unknown".to_string()), replacement1.replace_title);
|
||||
let replacement2 = config.client.filters[1]
|
||||
.replacement("code", "title")
|
||||
.unwrap();
|
||||
assert_eq!(Some("VSCode".to_string()), replacement2.replace_app_id);
|
||||
assert_eq!(Some("Title".to_string()), replacement2.replace_title);
|
||||
|
||||
let replacement1 = config.client.filters[0].apply("firefox", "any");
|
||||
assert!(matches!(replacement1, FilterResult::Replace(ref r) if
|
||||
r.replace_app_id.is_none() &&
|
||||
r.replace_title == Some("Unknown".to_string())
|
||||
));
|
||||
|
||||
let replacement2 = config.client.filters[1].apply("code", "title");
|
||||
assert!(matches!(replacement2, FilterResult::Replace(ref r) if
|
||||
r.replace_app_id == Some("VSCode".to_string()) &&
|
||||
r.replace_title == Some("Title".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn match_filter() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
write!(
|
||||
file,
|
||||
r#"
|
||||
[[awatcher.filters]]
|
||||
match-app-id = "firefox"
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = FileConfig::new(Some(file.path().to_path_buf())).unwrap();
|
||||
|
||||
assert_eq!(1, config.client.filters.len());
|
||||
let replacement1 = config.client.filters[0].apply("firefox", "any");
|
||||
assert!(matches!(replacement1, FilterResult::Match));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
@ -31,6 +31,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum FilterResult {
|
||||
Replace(Replacement),
|
||||
Match,
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct Replacement {
|
||||
pub replace_app_id: Option<String>,
|
||||
@ -39,10 +46,7 @@ pub struct Replacement {
|
||||
|
||||
impl Filter {
|
||||
fn is_valid(&self) -> bool {
|
||||
let is_match_set = self.match_app_id.is_some() || self.match_title.is_some();
|
||||
let is_replacement_set = self.replace_app_id.is_some() || self.replace_title.is_some();
|
||||
|
||||
is_match_set && is_replacement_set
|
||||
self.match_app_id.is_some() || self.match_title.is_some()
|
||||
}
|
||||
|
||||
fn is_match(&self, app_id: &str, title: &str) -> bool {
|
||||
@ -70,9 +74,12 @@ impl Filter {
|
||||
replacement.to_owned()
|
||||
}
|
||||
|
||||
pub fn replacement(&self, app_id: &str, title: &str) -> Option<Replacement> {
|
||||
pub fn apply(&self, app_id: &str, title: &str) -> FilterResult {
|
||||
if !self.is_valid() || !self.is_match(app_id, title) {
|
||||
return None;
|
||||
return FilterResult::Skip;
|
||||
}
|
||||
if self.replace_app_id.is_none() && self.replace_title.is_none() {
|
||||
return FilterResult::Match;
|
||||
}
|
||||
|
||||
let mut replacement = Replacement::default();
|
||||
@ -83,7 +90,7 @@ impl Filter {
|
||||
if let Some(new_title) = &self.replace_title {
|
||||
replacement.replace_title = Some(Self::replace(&self.match_title, title, new_title));
|
||||
}
|
||||
Some(replacement)
|
||||
FilterResult::Replace(replacement)
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,6 +148,12 @@ mod tests {
|
||||
("org.kde.dolphin", "/home/user"),
|
||||
None
|
||||
)]
|
||||
#[case::match_only(
|
||||
(Some("org\\.kde\\.(.*)"), None),
|
||||
(None, None),
|
||||
("org.kde.dolphin", "/home/user"),
|
||||
Some((None, None))
|
||||
)]
|
||||
fn replacement(
|
||||
#[case] matches: (Option<&str>, Option<&str>),
|
||||
#[case] replaces: (Option<&str>, Option<&str>),
|
||||
@ -159,11 +172,15 @@ mod tests {
|
||||
replace_title: replace_title.map(option_string),
|
||||
};
|
||||
|
||||
let replacement = filter.replacement(app_id, title);
|
||||
let expect_replacement = expect_replacement.map(|r| Replacement {
|
||||
replace_app_id: r.0.map(option_string),
|
||||
replace_title: r.1.map(option_string),
|
||||
});
|
||||
let replacement = filter.apply(app_id, title);
|
||||
let expect_replacement = match expect_replacement {
|
||||
None => FilterResult::Skip,
|
||||
Some((None, None)) => FilterResult::Match,
|
||||
Some((replace_app_id, replace_title)) => FilterResult::Replace(Replacement {
|
||||
replace_app_id: replace_app_id.map(Into::into),
|
||||
replace_title: replace_title.map(Into::into),
|
||||
}),
|
||||
};
|
||||
assert_eq!(expect_replacement, replacement);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
use super::config::{Config, FilterResult};
|
||||
use crate::watchers::idle::Status;
|
||||
|
||||
use super::config::Config;
|
||||
use anyhow::Context;
|
||||
use aw_client_rust::{AwClient, Event as AwEvent};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::future::Future;
|
||||
|
||||
@ -17,7 +17,7 @@ pub struct ReportClient {
|
||||
|
||||
impl ReportClient {
|
||||
pub async fn new(config: Config) -> anyhow::Result<Self, Box<dyn Error>> {
|
||||
let client = AwClient::new(&config.host, config.port, "awatcher")?;
|
||||
let client = AwClient::new(&config.client_host(), config.port, "awatcher")?;
|
||||
|
||||
let hostname = gethostname::gethostname().into_string().unwrap();
|
||||
let idle_bucket_name = format!("aw-watcher-afk_{hostname}");
|
||||
@ -41,14 +41,14 @@ impl ReportClient {
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
for (attempt, &secs) in [1, 2].iter().enumerate() {
|
||||
for (attempt, &secs) in [1, 2, 4].iter().enumerate() {
|
||||
match f().await {
|
||||
Ok(val) => return Ok(val),
|
||||
Err(e)
|
||||
if e.to_string()
|
||||
.contains("tcp connect error: Connection refused") =>
|
||||
{
|
||||
warn!("Failed to connect on attempt #{attempt}, retrying: {}", e);
|
||||
warn!("Failed to connect on attempt #{attempt}, retrying: {e}");
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
|
||||
}
|
||||
@ -94,28 +94,33 @@ impl ReportClient {
|
||||
}
|
||||
|
||||
pub async fn send_active_window(&self, app_id: &str, title: &str) -> anyhow::Result<()> {
|
||||
self.send_active_window_with_extra(app_id, title, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_active_window_with_extra(
|
||||
&self,
|
||||
app_id: &str,
|
||||
title: &str,
|
||||
extra_data: Option<HashMap<String, String>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut data = Map::new();
|
||||
|
||||
let replacement = self.config.window_data_replacement(app_id, title);
|
||||
let inserted_app_id = if let Some(new_app_id) = replacement.replace_app_id {
|
||||
trace!("Replacing app_id by {new_app_id}");
|
||||
new_app_id
|
||||
if let Some((inserted_app_id, inserted_title)) = self.get_filtered_data(app_id, title) {
|
||||
trace!("Reporting app_id: {inserted_app_id}, title: {inserted_title}");
|
||||
|
||||
data.insert("app".to_string(), Value::String(inserted_app_id));
|
||||
data.insert("title".to_string(), Value::String(inserted_title));
|
||||
|
||||
if let Some(extra) = extra_data {
|
||||
for (key, value) in extra {
|
||||
data.insert(key, Value::String(value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app_id.to_string()
|
||||
};
|
||||
let inserted_title = if let Some(new_title) = replacement.replace_title {
|
||||
trace!("Replacing title of {inserted_app_id} by {new_title}");
|
||||
new_title
|
||||
} else {
|
||||
title.to_string()
|
||||
};
|
||||
trace!(
|
||||
"Reporting app_id: {}, title: {}",
|
||||
inserted_app_id,
|
||||
inserted_title
|
||||
);
|
||||
data.insert("app".to_string(), Value::String(inserted_app_id));
|
||||
data.insert("title".to_string(), Value::String(inserted_title));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let event = AwEvent {
|
||||
id: None,
|
||||
timestamp: Utc::now(),
|
||||
@ -141,6 +146,33 @@ impl ReportClient {
|
||||
.with_context(|| "Failed to send heartbeat for active window")
|
||||
}
|
||||
|
||||
fn get_filtered_data(&self, app_id: &str, title: &str) -> Option<(String, String)> {
|
||||
let filter_result = self.config.match_window_data(app_id, title);
|
||||
match filter_result {
|
||||
FilterResult::Replace(replacement) => {
|
||||
let app_id = if let Some(replace_app_id) = replacement.replace_app_id {
|
||||
trace!("Replacing app_id by {replace_app_id}");
|
||||
replace_app_id
|
||||
} else {
|
||||
app_id.to_string()
|
||||
};
|
||||
let title = if let Some(replace_title) = replacement.replace_title {
|
||||
trace!("Replacing title by {replace_title}");
|
||||
replace_title
|
||||
} else {
|
||||
title.to_string()
|
||||
};
|
||||
|
||||
Some((app_id, title))
|
||||
}
|
||||
FilterResult::Match => {
|
||||
trace!("Matched a filter, not reported");
|
||||
None
|
||||
}
|
||||
FilterResult::Skip => Some((app_id.to_string(), title.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_bucket(
|
||||
client: &AwClient,
|
||||
bucket_name: &str,
|
||||
|
@ -110,15 +110,15 @@ async fn filter_first_supported(
|
||||
client,
|
||||
"KWin window (script)"
|
||||
));
|
||||
watch!(create_watcher::<x11_window::WindowWatcher>(
|
||||
client,
|
||||
"X11 window"
|
||||
));
|
||||
#[cfg(feature = "gnome")]
|
||||
watch!(create_watcher::<gnome_window::WindowWatcher>(
|
||||
client,
|
||||
"Gnome window (extension)"
|
||||
));
|
||||
watch!(create_watcher::<x11_window::WindowWatcher>(
|
||||
client,
|
||||
"X11 window"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@ -138,7 +138,7 @@ pub async fn run_first_supported(client: Arc<ReportClient>, watcher_type: &Watch
|
||||
error!("Error on {watcher_type} iteration: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Timeout on {watcher_type} iteration after {:?}", sleep_time);
|
||||
error!("Timeout on {watcher_type} iteration after {sleep_time:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -258,8 +258,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
self.diff_seconds(*last_input_time),
|
||||
expected_last_input_seconds_ago,
|
||||
"{}",
|
||||
message
|
||||
"{message}"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected active status");
|
||||
@ -283,10 +282,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
self.diff_seconds(*last_input_time),
|
||||
last_input_ago,
|
||||
"{}",
|
||||
message
|
||||
"{message}"
|
||||
);
|
||||
assert_eq!(duration.num_seconds(), last_input_ago, "{}", message);
|
||||
assert_eq!(duration.num_seconds(), last_input_ago, "{message}");
|
||||
} else {
|
||||
panic!("Expected idle status");
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ use std::sync::{mpsc::channel, Arc};
|
||||
use std::thread;
|
||||
use tokio::sync::Mutex;
|
||||
use zbus::interface;
|
||||
use zbus::{Connection, ConnectionBuilder};
|
||||
use zbus::{conn::Builder as ConnectionBuilder, Connection};
|
||||
|
||||
const KWIN_SCRIPT_NAME: &str = "activity_watcher";
|
||||
const KWIN_SCRIPT: &str = include_str!("kwin_window.js");
|
||||
|
@ -92,7 +92,7 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for ToplevelState {
|
||||
window.app_id = app_id;
|
||||
}
|
||||
HandleEvent::State { state } => {
|
||||
trace!("State is changed for {id}: {:?}", state);
|
||||
trace!("State is changed for {id}: {state:?}");
|
||||
if state.contains(&(HandleState::Activated as u8)) {
|
||||
trace!("Window is activated: {id}");
|
||||
toplevel_state.current_window_id = Some(id);
|
||||
|
@ -9,6 +9,7 @@ use x11rb::rust_connection::RustConnection;
|
||||
pub struct WindowData {
|
||||
pub title: String,
|
||||
pub app_id: String,
|
||||
pub wm_instance: String,
|
||||
}
|
||||
|
||||
pub struct X11Client {
|
||||
@ -86,10 +87,12 @@ impl X11Client {
|
||||
)?;
|
||||
|
||||
let title = str::from_utf8(&name.value).with_context(|| "Invalid title UTF")?;
|
||||
let (instance, class) = parse_wm_class(&class)?;
|
||||
|
||||
Ok(WindowData {
|
||||
title: title.to_string(),
|
||||
app_id: parse_wm_class(&class)?.to_string(),
|
||||
app_id: class,
|
||||
wm_instance: instance,
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -149,21 +152,26 @@ impl X11Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_wm_class(property: &GetPropertyReply) -> anyhow::Result<&str> {
|
||||
fn parse_wm_class(property: &GetPropertyReply) -> anyhow::Result<(String, String)> {
|
||||
if property.format != 8 {
|
||||
bail!("Malformed property: wrong format");
|
||||
}
|
||||
let value = &property.value;
|
||||
// The property should contain two null-terminated strings. Find them.
|
||||
if let Some(middle) = value.iter().position(|&b| b == 0) {
|
||||
let (_, class) = value.split_at(middle);
|
||||
// Skip the null byte at the beginning
|
||||
let (instance, class) = value.split_at(middle);
|
||||
// Remove the null byte at the end of the instance
|
||||
let instance = &instance[..instance.len()];
|
||||
// Skip the null byte at the beginning of the class
|
||||
let mut class = &class[1..];
|
||||
// Remove the last null byte from the class, if it is there.
|
||||
if class.last() == Some(&0) {
|
||||
class = &class[..class.len() - 1];
|
||||
}
|
||||
Ok(std::str::from_utf8(class)?)
|
||||
Ok((
|
||||
std::str::from_utf8(instance)?.to_string(),
|
||||
std::str::from_utf8(class)?.to_string(),
|
||||
))
|
||||
} else {
|
||||
bail!("Missing null byte")
|
||||
}
|
||||
|
@ -2,31 +2,55 @@ use super::{x11_connection::X11Client, Watcher};
|
||||
use crate::report_client::ReportClient;
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct WindowWatcher {
|
||||
client: X11Client,
|
||||
last_title: String,
|
||||
last_app_id: String,
|
||||
last_title: String,
|
||||
last_wm_instance: String,
|
||||
}
|
||||
|
||||
impl WindowWatcher {
|
||||
pub async fn send_active_window_with_instance(
|
||||
&self,
|
||||
client: &ReportClient,
|
||||
app_id: &str,
|
||||
title: &str,
|
||||
wm_instance: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut extra_data = HashMap::new();
|
||||
extra_data.insert("wm_instance".to_string(), wm_instance.to_string());
|
||||
client
|
||||
.send_active_window_with_extra(app_id, title, Some(extra_data))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_active_window(&mut self, client: &ReportClient) -> anyhow::Result<()> {
|
||||
let data = self.client.active_window_data()?;
|
||||
|
||||
if data.app_id != self.last_app_id || data.title != self.last_title {
|
||||
if data.app_id != self.last_app_id
|
||||
|| data.title != self.last_title
|
||||
|| data.wm_instance != self.last_wm_instance
|
||||
{
|
||||
debug!(
|
||||
r#"Changed window app_id="{}", title="{}""#,
|
||||
data.app_id, data.title
|
||||
r#"Changed window app_id="{}", title="{}", wm_instance="{}""#,
|
||||
data.app_id, data.title, data.wm_instance
|
||||
);
|
||||
self.last_app_id = data.app_id;
|
||||
self.last_title = data.title;
|
||||
self.last_app_id = data.app_id.clone();
|
||||
self.last_title = data.title.clone();
|
||||
self.last_wm_instance = data.wm_instance.clone();
|
||||
}
|
||||
|
||||
client
|
||||
.send_active_window(&self.last_app_id, &self.last_title)
|
||||
.await
|
||||
.with_context(|| "Failed to send heartbeat for active window")
|
||||
self.send_active_window_with_instance(
|
||||
client,
|
||||
&self.last_app_id,
|
||||
&self.last_title,
|
||||
&self.last_wm_instance,
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Failed to send heartbeat for active window")
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +64,7 @@ impl Watcher for WindowWatcher {
|
||||
client,
|
||||
last_title: String::new(),
|
||||
last_app_id: String::new(),
|
||||
last_wm_instance: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user