Compare commits

...

29 Commits
v0.3.0 ... main

Author SHA1 Message Date
Demmie
59c6dd1e7a
Update Cargo dependencies 2025-07-27 23:11:03 -04:00
Demmie
eafd5a7166
Fix new warnings 2025-07-27 23:00:12 -04:00
Demmie
8dde3b04ab
Update dependencies 2025-05-06 12:53:29 -04:00
2e3s
c85bbf0aaa
Update version to 0.3.2-alpha.3 2025-05-06 11:10:39 -04:00
2e3s
620d5865a9
Merge pull request #48 from vdonich/patch-1
Update watchers priorities
2025-03-18 01:01:13 -04:00
vdonich
bea3a4ac58
Update watchers priorities
Make extension watcher to take precedence over X11 window watcher when available.
This would make Gnome extension be activated correctly, when using XWayland.
2025-03-18 00:18:17 +00:00
2e3s
d1e3540669
Merge pull request #45 from wojnilowicz/fix-spdx
Use SPDX short identifier
2025-02-17 16:24:34 -05:00
Łukasz Wojniłowicz
994b1cec6a Use SPDX short identifier
According to https://doc.rust-lang.org/cargo/reference/manifest.html#the-license-and-license-file-fields
the license field should be an SPDX license expression.
2025-02-17 18:12:39 +01:00
Demmie
b07d3958bb
Add another connection attempt 2025-01-22 02:22:41 -05:00
Demmie
7ac76da921
Fix client host 2025-01-19 03:43:06 -05:00
Demmie
03e94e2f26
Make bundle server host configurable 2025-01-15 14:46:02 -05:00
Demmie
ff16bd6221
Mention supported formats and RPM 2024-12-24 22:16:49 -05:00
Demmie
9f215c4d76
Fix missing package 2024-12-24 01:43:31 -05:00
Demmie
cfcbdca306
Upgrade dependencies 2024-12-24 00:43:32 -05:00
Demmie
5b5739d756
Support RPM packages 2024-12-24 00:36:46 -05:00
Demmie
5b8c90a69c
Upgrade AW to 0.13.2 2024-11-07 10:22:56 -05:00
Demmie
e2ee19f494
Fix port type in config 2024-11-07 10:06:45 -05:00
Demmie
21acaeaa35
Upgrade wayland libs 2024-11-07 09:57:43 -05:00
Demmie
7ac7c9db92
Upgrade dependencies 2024-10-20 17:59:56 -04:00
2e3s
8cfc14c501
Merge pull request #28 from powellnorma/aw-instance
x11: include wm_instance
2024-10-16 18:06:47 -04:00
powellnorma
d719c54fdd cargo fmt 2024-10-15 19:02:02 +02:00
powellnorma
dd974db422 Merge branch 'main' into aw-instance 2024-10-11 12:23:06 +02:00
powellnorma
d733008a45 ReportClient: send_active_window_with_instance -> send_active_window_with_extra 2024-10-11 12:12:41 +02:00
Demmie
16942ea163
Allow not reporting data by filter 2024-10-10 23:07:35 -04:00
Demmie
b4cf791a5e
Update dependencies 2024-10-09 11:09:29 -04:00
powellnorma
990843bb05 x11: include wm_instance 2024-10-06 12:12:43 +02:00
Demmie
a04578083f
Add systemd sample configs and docs 2024-07-19 00:48:58 -04:00
2e3s
810707cc46
Merge pull request #23 from wojnilowicz/add-service 2024-07-19 00:31:21 -04:00
Łukasz Wojniłowicz
e6fc2e4f9c Add systemd service 2024-07-14 21:14:57 +02:00
23 changed files with 1426 additions and 1038 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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]) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
}

View File

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

View File

@ -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:?}");
}
}

View File

@ -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");
}

View File

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

View File

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

View File

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

View File

@ -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(),
})
}