Compare commits

...

44 Commits
v0.2.6 ... 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
Demmie
8a82b9f973
Add a note about Gnome extension 2024-07-06 23:58:13 -04:00
Demmie
ddfe972cff
Extract tracker service and fix reactive timings 2024-07-06 23:39:48 -04:00
Demmie
1b41533e1e
Invert client dependency for idle watchers with basic tests 2024-06-25 02:16:53 -04:00
Demmie
db3d14bf4a
Replace Duration by TimeDelta for Chrono
This simplifies the code.
2024-06-24 02:11:25 -04:00
Demmie
e1d5071869
Move to GHA to stable Rust 2024-06-24 01:16:40 -04:00
Demmie
123a0e45ff
Fix disappearing idle time after timeout
The active time was reported within idle time for idle_timeout. This caused disappearing the idle event.
The temporary solution sometimes sacrificies idle_timeout of active time.
2024-06-24 01:01:04 -04:00
Demmie
3e478e9054
Bump version to 0.3.0 2024-06-12 03:20:44 -04:00
Demmie
1441009b45
Fix non-idle timing 2024-06-12 03:10:08 -04:00
Demmie
d1926ebdc9
Avoid creation of different idle events
The events are identified by timestamp which becomes different after +1ms.
2024-06-12 02:20:50 -04:00
Demmie
1f7ed75a0b
Add 1 more second to reconnect
Also, code is cleaner.
2024-06-11 14:56:37 -04:00
Demmie
214ff1c06e
Adjust AW dependencies source 2024-06-11 13:07:37 -04:00
Demmie
a76f6e3e5b
Upgrade AW UI and server 2024-06-11 03:13:17 -04:00
2e3s
cfb3ad858d
Merge pull request #22 from wojnilowicz/bump-aw-server-rust 2024-06-11 03:06:11 -04:00
Łukasz Wojniłowicz
85972546f0 Bump aw-server-rust 2024-06-09 16:42:33 +02:00
Demmie
13cea8bed6
Add timeout to watcher iteration 2024-05-23 22:44:04 -04:00
29 changed files with 2058 additions and 1429 deletions

View File

@ -15,9 +15,7 @@ jobs:
AW_WEBUI_DIR: ${{ github.workspace }}/aw-webui/dist
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2023-11-01
- uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: |
sudo apt-get update
@ -26,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
@ -35,7 +33,7 @@ jobs:
with:
repository: ActivityWatch/aw-webui
path: aw-webui
ref: 839366e66f859faadd7f9128de3bea14b25ce4ae
ref: 291da6f2c5e7a6b896f23a4eec5ffed9874321ba
submodules: true
- name: Use Node.js
uses: actions/setup-node@v3
@ -90,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

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check --all
@ -24,13 +24,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y libdbus-1-dev
- uses: dtolnay/rust-toolchain@master
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly-2023-11-01
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --locked --all-targets --all-features --workspace -- -D warnings
- run: cargo clippy --locked --all-targets --workspace -- -D warnings
clippy:
runs-on: ubuntu-latest
env:
@ -38,9 +36,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y libdbus-1-dev
- uses: dtolnay/rust-toolchain@master
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --locked --all-targets --workspace -- -D warnings
@ -51,9 +48,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y libdbus-1-dev
- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2023-11-01
components: clippy
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features --workspace

2330
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.2.6"
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.19.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 = "448312d" }
aw-datastore = { git = "https://github.com/ActivityWatch/aw-server-rust", optional = true, rev = "448312d" }
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
@ -49,6 +52,10 @@ as soon as the environment has the necessary interfaces.
| Wayland + KDE | :yellow_circle: [^3] | :green_circle: |
| Wayland + Gnome | :yellow_circle: [^4] | :green_circle: |
> [!IMPORTANT]
> Gnome watcher in Wayland requires [this extension](https://extensions.gnome.org/extension/5592/focused-window-d-bus/) to be installed.
> Also, if you have problems with tray icons in Gnome, you may try [this extension](https://extensions.gnome.org/extension/615/appindicator-support/) for the bundle (StatusNotifierItem specification).
[^1]: A few other DEs besides Sway may implement [wlr foreign toplevel protocol](https://wayland.app/protocols/wlr-foreign-toplevel-management-unstable-v1),
[^2]: [KWin idle](https://wayland.app/protocols/kde-idle) and [Idle notify](https://wayland.app/protocols/ext-idle-notify-v1) protocols are supported.
[^3]: KWin doesn't implement any toplevel protocol yet, KWin script is utilized instead (builtin, no actions required).
@ -95,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
@ -159,6 +167,4 @@ to build the "dist" folder,
This should be compiled on nightly. The complete bundled version is also built and released.
Gnome needs [the extension](https://extensions.gnome.org/extension/615/appindicator-support/) to support StatusNotifierItem specification.
The tray can be disabled with `--no-tray` option in the bundled version.

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

@ -8,7 +8,7 @@ use tokio::sync::mpsc::UnboundedSender;
pub async fn run(
host: String,
port: u32,
port: u16,
config_file: PathBuf,
no_tray: bool,
shutdown_sender: UnboundedSender<()>,
@ -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,13 +1,14 @@
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;
pub struct Tray {
server_host: String,
server_port: u32,
server_port: u16,
config_file: PathBuf,
shutdown_sender: UnboundedSender<()>,
watchers_manager: Manager,
@ -17,11 +18,22 @@ pub struct Tray {
impl Tray {
pub fn new(
server_host: String,
server_port: u32,
server_port: u16,
config_file: PathBuf,
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: u32) {
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: u32) {
.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 = u16::try_from(port).unwrap();
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

@ -11,7 +11,9 @@ use watchers::config::FileConfig;
pub struct RunnerConfig {
pub watchers_config: Config,
#[cfg(feature = "bundle")]
pub config_file: PathBuf,
#[cfg(feature = "bundle")]
pub no_tray: bool,
}
@ -50,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")
@ -100,11 +102,10 @@ pub fn from_cli() -> anyhow::Result<RunnerConfig> {
filters: config.client.filters,
no_server: *matches.get_one("no-server").unwrap(),
},
#[cfg(feature = "bundle")]
config_file: config.config_file,
#[cfg(feature = "bundle")]
no_tray: *matches.get_one("no-tray").unwrap(),
#[cfg(not(feature = "bundle"))]
no_tray: true,
})
}

View File

@ -8,6 +8,7 @@ extern crate log;
mod bundle;
mod config;
use std::error::Error;
use std::sync::Arc;
use tokio::signal::unix::{signal, SignalKind};
#[cfg(feature = "bundle")]
@ -15,7 +16,7 @@ use tokio::sync::mpsc;
use watchers::{run_first_supported, ReportClient, WatcherType};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
async fn main() -> anyhow::Result<(), Box<dyn Error>> {
let config = config::from_cli()?;
#[cfg(feature = "bundle")]
let no_tray = config.no_tray;
@ -24,18 +25,25 @@ async fn main() -> anyhow::Result<()> {
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", config.idle_timeout.as_secs());
info!(
"Idle timeout: {} seconds",
config.idle_timeout.num_seconds()
);
info!(
"Idle polling period: {} seconds",
config.poll_time_idle.as_secs()
config.poll_time_idle.num_seconds()
);
info!(
"Window polling period: {} seconds",
config.poll_time_window.as_secs()
config.poll_time_window.num_seconds()
);
#[cfg(feature = "bundle")]
let (shutdown_send, mut shutdown_recv) = mpsc::unbounded_channel();

View File

@ -10,29 +10,29 @@ crate-type = ["lib"]
path = "src/lib.rs"
[dev-dependencies]
rstest = "0.19.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 = "448312d" }
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"
tokio = { workspace = true, features = ["time", "sync"] }
async-trait = "0.1.83"
tokio = { workspace = true, features = ["time", "sync", "macros"] }
[features]
default = ["gnome", "kwin_window"]

View File

@ -2,12 +2,15 @@ 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;
use std::time::Duration;
pub use filters::FilterResult;
pub struct Config {
pub port: u32,
pub port: u16,
pub host: String,
pub idle_timeout: Duration,
pub poll_time_idle: Duration,
@ -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

@ -7,7 +7,7 @@ pub fn poll_time_idle_seconds() -> u32 {
pub fn poll_time_window_seconds() -> u32 {
1
}
pub fn port() -> u32 {
pub fn port() -> u16 {
5600
}
pub fn host() -> String {

View File

@ -1,7 +1,8 @@
use anyhow::{anyhow, Context};
use chrono::TimeDelta;
use serde::Deserialize;
use serde_default::DefaultFromSerde;
use std::{fs, io::ErrorKind, path::PathBuf, time::Duration};
use std::{fs, io::ErrorKind, path::PathBuf};
use crate::config::defaults;
@ -52,7 +53,7 @@ pub fn default_config() -> String {
#[derive(Deserialize, DefaultFromSerde)]
pub struct ServerConfig {
#[serde(default = "defaults::port")]
pub port: u32,
pub port: u16,
#[serde(default = "defaults::host")]
pub host: String,
}
@ -71,16 +72,16 @@ pub struct ClientConfig {
}
impl ClientConfig {
pub fn get_idle_timeout(&self) -> Duration {
Duration::from_secs(u64::from(self.idle_timeout_seconds))
pub fn get_idle_timeout(&self) -> TimeDelta {
TimeDelta::seconds(self.idle_timeout_seconds.into())
}
pub fn get_poll_time_idle(&self) -> Duration {
Duration::from_secs(u64::from(self.poll_time_idle_seconds))
pub fn get_poll_time_idle(&self) -> TimeDelta {
TimeDelta::seconds(self.poll_time_idle_seconds.into())
}
pub fn get_poll_time_window(&self) -> Duration {
Duration::from_secs(u64::from(self.poll_time_window_seconds))
pub fn get_poll_time_window(&self) -> TimeDelta {
TimeDelta::seconds(self.poll_time_window_seconds.into())
}
}
@ -146,6 +147,8 @@ impl FileConfig {
#[cfg(test)]
mod tests {
use crate::config::FilterResult;
use super::*;
use rstest::rstest;
use std::io::Write;
@ -191,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,8 +1,11 @@
use super::config::Config;
use super::config::{Config, FilterResult};
use crate::watchers::idle::Status;
use anyhow::Context;
use aw_client_rust::{AwClient, Event as AwEvent};
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::error::Error;
use std::future::Future;
pub struct ReportClient {
@ -13,8 +16,8 @@ pub struct ReportClient {
}
impl ReportClient {
pub async fn new(config: Config) -> anyhow::Result<Self> {
let client = AwClient::new(&config.host, &config.port.to_string(), "awatcher");
pub async fn new(config: Config) -> anyhow::Result<Self, Box<dyn Error>> {
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}");
@ -38,31 +41,29 @@ impl ReportClient {
Fut: Future<Output = Result<T, E>>,
E: std::error::Error + Send + Sync + 'static,
{
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
let mut attempts = 0;
loop {
for (attempt, &secs) in [1, 2, 4].iter().enumerate() {
match f().await {
Ok(val) => return Ok(val),
Err(e)
if attempts < 3
&& e.to_string()
.contains("tcp connect error: Connection refused") =>
if e.to_string()
.contains("tcp connect error: Connection refused") =>
{
warn!("Failed to connect, retrying: {}", e);
warn!("Failed to connect on attempt #{attempt}, retrying: {e}");
attempts += 1;
interval.tick().await;
tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
}
Err(e) => return Err(e),
}
}
f().await
}
pub async fn ping(
&self,
is_idle: bool,
timestamp: DateTime<Utc>,
duration: Duration,
duration: TimeDelta,
) -> anyhow::Result<()> {
let mut data = Map::new();
data.insert(
@ -81,11 +82,10 @@ impl ReportClient {
return Ok(());
}
let pulsetime = (self.config.idle_timeout + self.config.poll_time_idle).as_secs_f64();
let pulsetime = (self.config.idle_timeout + self.config.poll_time_idle).num_seconds();
let request = || {
self.client
.heartbeat(&self.idle_bucket_name, &event, pulsetime)
.heartbeat(&self.idle_bucket_name, &event, pulsetime as f64)
};
Self::run_with_retries(request)
@ -94,32 +94,37 @@ 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(),
duration: Duration::zero(),
duration: TimeDelta::zero(),
data,
};
@ -127,10 +132,13 @@ impl ReportClient {
return Ok(());
}
let interval_margin = self.config.poll_time_window.as_secs_f64() + 1.0;
let interval_margin = self.config.poll_time_window.num_seconds() + 1;
let request = || {
self.client
.heartbeat(&self.active_window_bucket_name, &event, interval_margin)
self.client.heartbeat(
&self.active_window_bucket_name,
&event,
interval_margin as f64,
)
};
Self::run_with_retries(request)
@ -138,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,
@ -149,4 +184,69 @@ impl ReportClient {
.await
.with_context(|| format!("Failed to create bucket {bucket_name}"))
}
pub async fn handle_idle_status(&self, status: Status) -> anyhow::Result<()> {
match status {
Status::Idle {
changed,
last_input_time,
duration,
} => self.idle(changed, last_input_time, duration).await,
Status::Active {
changed,
last_input_time,
} => self.non_idle(changed, last_input_time).await,
}
}
async fn idle(
&self,
changed: bool,
last_input_time: DateTime<Utc>,
duration: TimeDelta,
) -> anyhow::Result<()> {
if changed {
debug!(
"Reporting as changed to idle for {} seconds since {}",
duration.num_seconds(),
last_input_time.format("%Y-%m-%d %H:%M:%S"),
);
self.ping(false, last_input_time, TimeDelta::zero()).await?;
// ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
self.ping(true, last_input_time, duration + TimeDelta::milliseconds(1))
.await
} else {
trace!(
"Reporting as idle for {} seconds since {}",
duration.num_seconds(),
last_input_time.format("%Y-%m-%d %H:%M:%S"),
);
self.ping(true, last_input_time, duration).await
}
}
async fn non_idle(&self, changed: bool, last_input_time: DateTime<Utc>) -> anyhow::Result<()> {
if changed {
debug!(
"Reporting as no longer idle at {}",
last_input_time.format("%Y-%m-%d %H:%M:%S")
);
self.ping(
true,
last_input_time - TimeDelta::milliseconds(1),
TimeDelta::zero(),
)
.await?;
self.ping(false, last_input_time, TimeDelta::zero()).await
} else {
trace!(
"Reporting as not idle at {}",
last_input_time.format("%Y-%m-%d %H:%M:%S")
);
self.ping(false, last_input_time, TimeDelta::zero()).await
}
}
}

View File

@ -4,7 +4,7 @@ mod gnome_idle;
mod gnome_wayland;
#[cfg(feature = "gnome")]
mod gnome_window;
mod idle;
pub mod idle;
#[cfg(feature = "kwin_window")]
mod kwin_window;
mod wl_connection;
@ -18,7 +18,7 @@ mod x11_window;
use crate::{config::Config, report_client::ReportClient};
use async_trait::async_trait;
use std::{fmt::Display, sync::Arc};
use tokio::time;
use tokio::time::{sleep, timeout, Duration};
pub enum WatcherType {
Idle,
@ -26,10 +26,10 @@ pub enum WatcherType {
}
impl WatcherType {
fn sleep_time(&self, config: &Config) -> time::Duration {
fn sleep_time(&self, config: &Config) -> Duration {
match self {
WatcherType::Idle => config.poll_time_idle,
WatcherType::ActiveWindow => config.poll_time_window,
WatcherType::Idle => config.poll_time_idle.to_std().unwrap(),
WatcherType::ActiveWindow => config.poll_time_window.to_std().unwrap(),
}
}
}
@ -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"
));
}
};
@ -130,10 +130,19 @@ pub async fn run_first_supported(client: Arc<ReportClient>, watcher_type: &Watch
if let Some(mut watcher) = supported_watcher {
info!("Starting {watcher_type} watcher");
loop {
if let Err(e) = watcher.run_iteration(&client).await {
error!("Error on {watcher_type} iteration: {e}");
let sleep_time = watcher_type.sleep_time(&client.config);
match timeout(sleep_time, watcher.run_iteration(&client)).await {
Ok(Ok(())) => { /* Successfully completed. */ }
Ok(Err(e)) => {
error!("Error on {watcher_type} iteration: {e}");
}
Err(_) => {
error!("Timeout on {watcher_type} iteration after {sleep_time:?}");
}
}
time::sleep(watcher_type.sleep_time(&client.config)).await;
sleep(sleep_time).await;
}
}

View File

@ -2,13 +2,13 @@ use super::{gnome_wayland::load_watcher, idle, Watcher};
use crate::report_client::ReportClient;
use anyhow::Context;
use async_trait::async_trait;
use chrono::Duration;
use chrono::Utc;
use std::sync::Arc;
use zbus::Connection;
pub struct IdleWatcher {
dbus_connection: Connection,
idle_state: idle::State,
idle_state: idle::Tracker,
}
impl IdleWatcher {
@ -32,11 +32,11 @@ impl IdleWatcher {
#[async_trait]
impl Watcher for IdleWatcher {
async fn new(client: &Arc<ReportClient>) -> anyhow::Result<Self> {
let duration = Duration::from_std(client.config.idle_timeout).unwrap();
let duration = client.config.idle_timeout;
load_watcher(|| async move {
let mut watcher = Self {
dbus_connection: Connection::session().await?,
idle_state: idle::State::new(duration),
idle_state: idle::Tracker::new(Utc::now(), duration),
};
watcher.seconds_since_input().await?;
Ok(watcher)
@ -46,10 +46,8 @@ impl Watcher for IdleWatcher {
async fn run_iteration(&mut self, client: &Arc<ReportClient>) -> anyhow::Result<()> {
let seconds = self.seconds_since_input().await?;
self.idle_state
.send_with_last_input(seconds, client)
.await?;
Ok(())
client
.handle_idle_status(self.idle_state.get_with_last_input(Utc::now(), seconds)?)
.await
}
}

View File

@ -1,45 +1,64 @@
use crate::report_client::ReportClient;
use chrono::{DateTime, Duration, Utc};
use std::sync::Arc;
use chrono::{DateTime, TimeDelta, Utc};
use std::cmp::max;
pub struct State {
pub struct Tracker {
last_input_time: DateTime<Utc>,
is_idle: bool,
is_changed: bool,
idle_timeout: Duration,
idle_timeout: TimeDelta,
idle_end: Option<DateTime<Utc>>,
}
impl State {
pub fn new(idle_timeout: Duration) -> Self {
pub enum Status {
Idle {
changed: bool,
last_input_time: DateTime<Utc>,
duration: TimeDelta,
},
Active {
changed: bool,
last_input_time: DateTime<Utc>,
},
}
impl Tracker {
pub fn new(now: DateTime<Utc>, idle_timeout: TimeDelta) -> Self {
Self {
last_input_time: Utc::now(),
last_input_time: now,
is_idle: false,
is_changed: false,
idle_timeout,
idle_end: None,
}
}
pub fn mark_not_idle(&mut self) {
self.is_idle = false;
fn set_idle(&mut self, is_idle: bool) {
self.is_idle = is_idle;
self.is_changed = true;
self.last_input_time = Utc::now();
}
pub fn mark_idle(&mut self) {
self.is_idle = true;
self.is_changed = true;
self.last_input_time -= self.idle_timeout;
pub fn mark_not_idle(&mut self, now: DateTime<Utc>) {
debug!("No longer idle");
self.last_input_time = now;
self.set_idle(false);
self.idle_end = Some(now);
}
pub fn mark_idle(&mut self, _: DateTime<Utc>) {
debug!("Idle again");
self.set_idle(true);
}
// The logic is rewritten from the original Python code:
// https://github.com/ActivityWatch/aw-watcher-afk/blob/ef531605cd8238e00138bbb980e5457054e05248/aw_watcher_afk/afk.py#L73
pub async fn send_with_last_input(
pub fn get_with_last_input(
&mut self,
now: DateTime<Utc>,
seconds_since_input: u32,
client: &Arc<ReportClient>,
) -> anyhow::Result<()> {
let now = Utc::now();
let time_since_input = Duration::seconds(i64::from(seconds_since_input));
) -> anyhow::Result<Status> {
let time_since_input = TimeDelta::seconds(i64::from(seconds_since_input));
self.last_input_time = now - time_since_input;
@ -47,74 +66,339 @@ impl State {
&& u64::from(seconds_since_input) < self.idle_timeout.num_seconds().try_into().unwrap()
{
debug!("No longer idle");
self.is_idle = false;
self.is_changed = true;
self.set_idle(false);
} else if !self.is_idle
&& u64::from(seconds_since_input) >= self.idle_timeout.num_seconds().try_into().unwrap()
{
debug!("Idle again");
self.is_idle = true;
self.is_changed = true;
self.set_idle(true);
}
self.send_ping(now, client).await
Ok(self.get_status(now))
}
pub async fn send_reactive(&mut self, client: &Arc<ReportClient>) -> anyhow::Result<()> {
let now = Utc::now();
pub fn get_reactive(&mut self, now: DateTime<Utc>) -> anyhow::Result<Status> {
if !self.is_idle {
self.last_input_time = now;
self.last_input_time = max(self.last_input_time, now - self.idle_timeout);
if let Some(idle_end) = self.idle_end {
if self.last_input_time < idle_end {
self.last_input_time = idle_end;
}
}
}
self.send_ping(now, client).await
Ok(self.get_status(now))
}
async fn send_ping(
&mut self,
now: DateTime<Utc>,
client: &Arc<ReportClient>,
) -> anyhow::Result<()> {
if self.is_changed {
let result = if self.is_idle {
debug!("Reporting as changed to idle");
client
.ping(false, self.last_input_time, Duration::zero())
.await?;
// ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
client
.ping(
true,
self.last_input_time + Duration::milliseconds(1),
now - self.last_input_time,
)
.await
fn get_status(&mut self, now: DateTime<Utc>) -> Status {
let result = if self.is_changed {
if self.is_idle {
Status::Idle {
changed: self.is_changed,
last_input_time: self.last_input_time,
duration: now - self.last_input_time,
}
} else {
debug!("Reporting as no longer idle");
client
.ping(true, self.last_input_time, Duration::zero())
.await?;
client
.ping(
false,
self.last_input_time + Duration::milliseconds(1),
Duration::zero(),
)
.await
};
self.is_changed = false;
result
Status::Active {
changed: self.is_changed,
last_input_time: self.last_input_time,
}
}
} else if self.is_idle {
trace!("Reporting as idle");
client
.ping(true, self.last_input_time, now - self.last_input_time)
.await
Status::Idle {
changed: self.is_changed,
last_input_time: self.last_input_time,
duration: now - self.last_input_time,
}
} else {
trace!("Reporting as not idle");
client
.ping(false, self.last_input_time, Duration::zero())
.await
}
Status::Active {
changed: self.is_changed,
last_input_time: self.last_input_time,
}
};
self.is_changed = false;
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
use rstest::rstest;
#[rstest]
fn test_new() {
let current_time = Utc::now();
let state = Tracker::new(current_time, Duration::seconds(300));
assert!(!state.is_idle);
assert!(!state.is_changed);
}
#[rstest]
fn test_mark_not_idle() {
let current_time = Utc::now();
let mut state = Tracker::new(current_time, Duration::seconds(300));
state.mark_not_idle(current_time);
assert!(!state.is_idle);
assert!(state.is_changed);
}
#[rstest]
fn test_mark_idle() {
let current_time = Utc::now();
let mut state = Tracker::new(current_time, Duration::seconds(300));
state.mark_idle(current_time);
assert!(state.is_idle);
assert!(state.is_changed);
}
#[rstest]
fn test_send_with_last_input() {
struct Time {
now: DateTime<Utc>,
last_input_ago: u32,
}
impl Time {
fn new() -> Self {
Self {
now: Utc::now(),
last_input_ago: 0,
}
}
fn tick_inactive(&mut self) {
self.now += Duration::seconds(10);
self.last_input_ago += 10;
}
fn tick_active(&mut self) {
self.now += Duration::seconds(10);
self.last_input_ago = 0;
}
}
let mut time = Time::new();
let mut tracker = Tracker::new(time.now, Duration::seconds(30));
time.tick_inactive();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Active { changed: false, .. }));
time.tick_inactive();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Active { changed: false, .. }));
time.tick_inactive();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Idle { changed: true, .. }));
time.tick_inactive();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Idle { changed: false, .. }));
time.tick_active();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Active { changed: true, .. }));
time.tick_active();
let status = tracker
.get_with_last_input(time.now, time.last_input_ago)
.unwrap();
assert!(matches!(status, Status::Active { changed: false, .. }));
}
struct TimeReactive {
now: DateTime<Utc>,
}
impl TimeReactive {
fn new() -> Self {
Self {
now: Utc.with_ymd_and_hms(2021, 3, 1, 13, 30, 0).unwrap(),
}
}
fn tick(&mut self, seconds: i64) {
self.now += Duration::seconds(seconds);
}
fn diff_seconds(&self, other: DateTime<Utc>) -> i64 {
(self.now - other).num_seconds()
}
fn assert_active_status(
&self,
status: &Status,
expected_changed: bool,
expected_last_input_seconds_ago: i64,
message: &str,
) {
if let Status::Active {
changed,
last_input_time,
} = status
{
assert_eq!(expected_changed, *changed);
assert_eq!(
self.diff_seconds(*last_input_time),
expected_last_input_seconds_ago,
"{message}"
);
} else {
panic!("Expected active status");
}
}
fn assert_idle_status(
&self,
status: &Status,
expected_changed: bool,
last_input_ago: i64,
message: &str,
) {
if let Status::Idle {
changed,
last_input_time,
duration,
} = status
{
assert_eq!(expected_changed, *changed);
assert_eq!(
self.diff_seconds(*last_input_time),
last_input_ago,
"{message}"
);
assert_eq!(duration.num_seconds(), last_input_ago, "{message}");
} else {
panic!("Expected idle status");
}
}
}
#[rstest]
fn test_send_reactive() {
let mut time = TimeReactive::new();
let mut tracker = Tracker::new(time.now, Duration::seconds(30));
// 15 seconds of active time
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
assert!(matches!(status, Status::Active { changed: false, .. }));
time.tick(5);
// 30 seconds of idle time
tracker.mark_idle(time.now);
assert!(tracker.is_idle);
assert!(tracker.is_changed);
time.tick(5);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(
&status,
true,
20,
"Marked idle 5s ago, last guaranteed activity is on creation as less than 30s interval",
);
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(&status, false, 30, "");
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(&status, false, 40, "");
time.tick(5);
tracker.mark_not_idle(time.now);
assert!(!tracker.is_idle);
assert!(tracker.is_changed);
time.tick(5);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(
&status,
true,
5,
"Marked active 5s ago which is more recent than 30s interval ago.",
);
assert!(
matches!(status, Status::Active { last_input_time, .. } if last_input_time >= time.now - Duration::seconds(5))
);
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(&status, false, 15, "");
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(
&status,
false,
25,
"Marked active 25s ago which is more recent than 30s interval ago.",
);
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(
&status,
false,
30,
"Marked active 35s ago, it will be active since 30s ago.",
);
time.tick(10);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(
&status,
false,
30,
"Marked active 45s ago, it will be active since 30s ago.",
);
time.tick(5);
tracker.mark_idle(time.now);
time.tick(5);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(&status, true, 40, "Last guaranteed activity 5+5+30s ago");
time.tick(30);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(&status, false, 70, "");
// Short active time
time.tick(1);
tracker.mark_not_idle(time.now);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_active_status(&status, true, 0, "");
assert!(
matches!(status, Status::Active { last_input_time, .. } if last_input_time == time.now)
);
time.tick(5);
tracker.mark_idle(time.now);
time.tick(5);
let status = tracker.get_reactive(time.now).unwrap();
time.assert_idle_status(&status, true, 10, "");
assert!(
matches!(status, Status::Idle { last_input_time, .. } if last_input_time == time.now - Duration::seconds(10))
);
}
}

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

@ -4,7 +4,7 @@ use super::Watcher;
use crate::report_client::ReportClient;
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::Duration;
use chrono::{TimeDelta, Utc};
use std::sync::Arc;
use wayland_client::{
globals::GlobalListContents,
@ -17,7 +17,7 @@ use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtId
struct WatcherState {
idle_notification: ExtIdleNotificationV1,
idle_state: idle::State,
idle_state: idle::Tracker,
}
impl Drop for WatcherState {
@ -28,21 +28,19 @@ impl Drop for WatcherState {
}
impl WatcherState {
fn new(idle_notification: ExtIdleNotificationV1, idle_timeout: Duration) -> Self {
fn new(idle_notification: ExtIdleNotificationV1, idle_timeout: TimeDelta) -> Self {
Self {
idle_notification,
idle_state: idle::State::new(idle_timeout),
idle_state: idle::Tracker::new(Utc::now(), idle_timeout),
}
}
fn idle(&mut self) {
self.idle_state.mark_idle();
debug!("Idle");
self.idle_state.mark_idle(Utc::now());
}
fn resume(&mut self) {
self.idle_state.mark_not_idle();
debug!("Resumed");
self.idle_state.mark_not_idle(Utc::now());
}
}
@ -79,12 +77,12 @@ impl Watcher for IdleWatcher {
let mut connection: WlEventConnection<WatcherState> = WlEventConnection::connect()?;
connection.get_ext_idle()?;
let timeout = u32::try_from(client.config.idle_timeout.as_secs() * 1000);
let timeout = u32::try_from(client.config.idle_timeout.num_milliseconds());
let mut watcher_state = WatcherState::new(
connection
.get_ext_idle_notification(timeout.unwrap())
.unwrap(),
Duration::from_std(client.config.idle_timeout).unwrap(),
client.config.idle_timeout,
);
connection
.event_queue
@ -103,6 +101,8 @@ impl Watcher for IdleWatcher {
.roundtrip(&mut self.watcher_state)
.map_err(|e| anyhow!("Event queue is not processed: {e}"))?;
self.watcher_state.idle_state.send_reactive(client).await
client
.handle_idle_status(self.watcher_state.idle_state.get_reactive(Utc::now())?)
.await
}
}

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

@ -4,7 +4,7 @@ use super::Watcher;
use crate::report_client::ReportClient;
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::Duration;
use chrono::{TimeDelta, Utc};
use std::sync::Arc;
use wayland_client::{
globals::GlobalListContents,
@ -18,7 +18,7 @@ use wayland_protocols_plasma::idle::client::org_kde_kwin_idle_timeout::{
struct WatcherState {
kwin_idle_timeout: OrgKdeKwinIdleTimeout,
idle_state: idle::State,
idle_state: idle::Tracker,
}
impl Drop for WatcherState {
@ -29,21 +29,21 @@ impl Drop for WatcherState {
}
impl WatcherState {
fn new(kwin_idle_timeout: OrgKdeKwinIdleTimeout, idle_timeout: Duration) -> Self {
fn new(kwin_idle_timeout: OrgKdeKwinIdleTimeout, idle_timeout: TimeDelta) -> Self {
Self {
kwin_idle_timeout,
idle_state: idle::State::new(idle_timeout),
idle_state: idle::Tracker::new(Utc::now(), idle_timeout),
}
}
fn idle(&mut self) {
self.idle_state.mark_idle();
debug!("Idle");
let time = Utc::now();
self.idle_state.mark_idle(time);
}
fn resume(&mut self) {
self.idle_state.mark_not_idle();
debug!("Resumed");
let time = Utc::now();
self.idle_state.mark_not_idle(time);
}
}
@ -80,10 +80,10 @@ impl Watcher for IdleWatcher {
let mut connection: WlEventConnection<WatcherState> = WlEventConnection::connect()?;
connection.get_kwin_idle()?;
let timeout = u32::try_from(client.config.idle_timeout.as_secs() * 1000);
let timeout = u32::try_from(client.config.idle_timeout.num_milliseconds());
let mut watcher_state = WatcherState::new(
connection.get_kwin_idle_timeout(timeout.unwrap()).unwrap(),
Duration::from_std(client.config.idle_timeout).unwrap(),
client.config.idle_timeout,
);
connection
.event_queue
@ -102,6 +102,8 @@ impl Watcher for IdleWatcher {
.roundtrip(&mut self.watcher_state)
.map_err(|e| anyhow!("Event queue is not processed: {e}"))?;
self.watcher_state.idle_state.send_reactive(client).await
client
.handle_idle_status(self.watcher_state.idle_state.get_reactive(Utc::now())?)
.await
}
}

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

@ -1,5 +1,5 @@
use async_trait::async_trait;
use chrono::Duration;
use chrono::Utc;
use super::{idle, x11_connection::X11Client, Watcher};
use crate::report_client::ReportClient;
@ -7,7 +7,7 @@ use std::sync::Arc;
pub struct IdleWatcher {
client: X11Client,
idle_state: idle::State,
idle_state: idle::Tracker,
}
impl IdleWatcher {
@ -26,18 +26,15 @@ impl Watcher for IdleWatcher {
Ok(IdleWatcher {
client,
idle_state: idle::State::new(
Duration::from_std(report_client.config.idle_timeout).unwrap(),
),
idle_state: idle::Tracker::new(Utc::now(), report_client.config.idle_timeout),
})
}
async fn run_iteration(&mut self, client: &Arc<ReportClient>) -> anyhow::Result<()> {
let seconds = self.seconds_since_input().await?;
self.idle_state
.send_with_last_input(seconds, client)
.await?;
Ok(())
client
.handle_idle_status(self.idle_state.get_with_last_input(Utc::now(), seconds)?)
.await
}
}

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