Skip to content

Commit bb36d7c

Browse files
committed
fix(linux): fall back to Python watchers when aw-awatcher is missing
Only default to aw-awatcher on first run when it is actually installed (discovery paths or PATH). Packages without it, such as the standalone aw-tauri release, keep the previous defaults: aw-awatcher on Wayland, aw-watcher-afk + aw-watcher-window on X11. Split install-path resolution into pure functions taking the exe path and APPDIR, and test the deb/rpm, AppImage and macOS bundle layouts.
1 parent 931e56d commit bb36d7c

3 files changed

Lines changed: 238 additions & 60 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ opt in.
100100
- `autostart.modules` — Modules to start automatically. Each entry can be a string (`"aw-watcher-afk"`) or an object with args (`{ name = "aw-sync", args = "daemon" }`). `aw-sync` is opt-in, not included by default
101101
- `module_args` — Default args by module name, used when a module is launched from the tray menu or restarted after a crash. Lets you set args for a module *without* adding it to `autostart.modules` (e.g. `aw-watcher-vscode` above is launched manually from the tray, but still gets its args). If a module is listed in both places, the inline args on its `autostart.modules` entry take precedence.
102102

103-
**Note:** On Linux, the default watcher is `aw-awatcher` (covers window + AFK on both X11 and Wayland) instead of the separate Python `aw-watcher-afk` / `aw-watcher-window` pair. That keeps self-contained deb/rpm/AppImage packages small and pure-Rust. You can still drop the Python watchers into `~/aw-modules` and point `autostart.modules` at them.
103+
**Note:** On Linux, the default watcher is `aw-awatcher` (covers window + AFK on both X11 and Wayland) when it is installed, e.g. bundled in a self-contained deb/rpm/AppImage. If it isn't found on first run, X11 falls back to the Python `aw-watcher-afk` + `aw-watcher-window` pair, and Wayland keeps `aw-awatcher` (the Python window watcher can't see Wayland windows).
104104

105105
## Logging
106106

‎src-tauri/src/dirs.rs‎

Lines changed: 158 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! Module segments (`aw-tauri`, …) do not change.
99
1010
use std::fs;
11-
use std::path::PathBuf;
11+
use std::path::{Path, PathBuf};
1212

1313
use crate::profile::{current_profile, DEFAULT_PROFILE, TESTING_PROFILE};
1414

@@ -170,64 +170,83 @@ pub fn get_runtime_dir() -> PathBuf {
170170
/// - Linux AppImage: `$APPDIR/usr/lib/aw-tauri/modules/`
171171
/// - macOS: `Contents/Resources/modules/` (and legacy `Contents/Resources/`)
172172
pub fn get_install_discovery_paths() -> Vec<PathBuf> {
173-
let mut paths = Vec::new();
174-
175173
#[cfg(target_os = "linux")]
176174
{
177-
if let Ok(exe_path) = std::env::current_exe() {
178-
if let Some(exe_dir) = exe_path.parent() {
179-
// externalBin / same-directory layout
180-
paths.push(exe_dir.to_path_buf());
181-
182-
// Tauri resources: ../lib/<productName>/ relative to the binary
183-
if let Some(prefix) = exe_dir.parent() {
184-
let resource = prefix.join("lib").join("aw-tauri");
185-
if resource.exists() {
186-
paths.push(resource.join("modules"));
187-
paths.push(resource);
188-
}
189-
}
190-
}
191-
}
175+
linux_install_discovery_paths(
176+
std::env::current_exe().ok().as_deref(),
177+
std::env::var_os("APPDIR").map(PathBuf::from).as_deref(),
178+
)
179+
}
180+
181+
#[cfg(target_os = "macos")]
182+
{
183+
macos_install_discovery_paths(std::env::current_exe().ok().as_deref())
184+
}
185+
186+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
187+
{
188+
Vec::new()
189+
}
190+
}
191+
192+
/// Linux layout, split out from `get_install_discovery_paths` so it can be
193+
/// tested against a fake install tree.
194+
#[cfg(any(target_os = "linux", test))]
195+
fn linux_install_discovery_paths(exe_path: Option<&Path>, appdir: Option<&Path>) -> Vec<PathBuf> {
196+
let mut paths = Vec::new();
197+
198+
if let Some(exe_dir) = exe_path.and_then(Path::parent) {
199+
// externalBin / same-directory layout
200+
paths.push(exe_dir.to_path_buf());
192201

193-
// AppImage runtime sets APPDIR to the mounted squashfs root
194-
if let Ok(appdir) = std::env::var("APPDIR") {
195-
let resource = PathBuf::from(appdir)
196-
.join("usr")
197-
.join("lib")
198-
.join("aw-tauri");
202+
// Tauri resources: ../lib/<productName>/ relative to the binary
203+
if let Some(prefix) = exe_dir.parent() {
204+
let resource = prefix.join("lib").join("aw-tauri");
199205
if resource.exists() {
200-
let modules = resource.join("modules");
201-
if !paths.contains(&modules) {
202-
paths.push(modules);
203-
}
204-
if !paths.contains(&resource) {
205-
paths.push(resource);
206-
}
206+
paths.push(resource.join("modules"));
207+
paths.push(resource);
207208
}
208209
}
209210
}
210211

211-
#[cfg(target_os = "macos")]
212-
{
213-
// Structure: Contents/MacOS/aw-tauri -> go up two levels -> Contents/Resources
214-
if let Ok(exe_path) = std::env::current_exe() {
215-
if let Some(contents_dir) = exe_path.parent().and_then(|p| p.parent()) {
216-
let resources_dir = contents_dir.join("Resources");
217-
if resources_dir.exists() {
218-
// Modules bundled via tauri.conf.json `bundle.resources` land in Resources/modules/.
219-
paths.push(resources_dir.join("modules"));
220-
// Also include Resources/ directly for compatibility with modules placed
221-
// at the root (e.g. legacy build_app_tauri.sh layout).
222-
paths.push(resources_dir);
223-
}
212+
// AppImage runtime sets APPDIR to the mounted squashfs root
213+
if let Some(appdir) = appdir {
214+
let resource = appdir.join("usr").join("lib").join("aw-tauri");
215+
if resource.exists() {
216+
let modules = resource.join("modules");
217+
if !paths.contains(&modules) {
218+
paths.push(modules);
219+
}
220+
if !paths.contains(&resource) {
221+
paths.push(resource);
224222
}
225223
}
226224
}
227225

228226
paths
229227
}
230228

229+
/// macOS layout, split out from `get_install_discovery_paths` so it can be
230+
/// tested against a fake app bundle.
231+
#[cfg(any(target_os = "macos", test))]
232+
fn macos_install_discovery_paths(exe_path: Option<&Path>) -> Vec<PathBuf> {
233+
let mut paths = Vec::new();
234+
235+
// Structure: Contents/MacOS/aw-tauri -> go up two levels -> Contents/Resources
236+
if let Some(contents_dir) = exe_path.and_then(Path::parent).and_then(Path::parent) {
237+
let resources_dir = contents_dir.join("Resources");
238+
if resources_dir.exists() {
239+
// Modules bundled via tauri.conf.json `bundle.resources` land in Resources/modules/.
240+
paths.push(resources_dir.join("modules"));
241+
// Also include Resources/ directly for compatibility with modules placed
242+
// at the root (e.g. legacy build_app_tauri.sh layout).
243+
paths.push(resources_dir);
244+
}
245+
}
246+
247+
paths
248+
}
249+
231250
pub fn get_discovery_paths() -> Vec<PathBuf> {
232251
let mut discovery_paths = Vec::new();
233252

@@ -353,10 +372,102 @@ mod tests {
353372
assert_eq!(appname_for("my-profile"), "activitywatch-my-profile");
354373
}
355374

375+
/// Fresh, empty scratch dir for building fake install trees.
376+
fn scratch_dir(name: &str) -> PathBuf {
377+
let dir =
378+
std::env::temp_dir().join(format!("aw-tauri-test-{}-{}", name, std::process::id()));
379+
let _ = fs::remove_dir_all(&dir);
380+
fs::create_dir_all(&dir).unwrap();
381+
dir
382+
}
383+
384+
#[test]
385+
fn test_linux_install_paths_deb_rpm_layout() {
386+
// /usr/bin/aw-tauri with resources in /usr/lib/aw-tauri/
387+
let root = scratch_dir("deb");
388+
let usr = root.join("usr");
389+
fs::create_dir_all(usr.join("bin")).unwrap();
390+
fs::create_dir_all(usr.join("lib").join("aw-tauri").join("modules")).unwrap();
391+
392+
let paths = linux_install_discovery_paths(Some(&usr.join("bin").join("aw-tauri")), None);
393+
assert_eq!(
394+
paths,
395+
vec![
396+
usr.join("bin"),
397+
usr.join("lib").join("aw-tauri").join("modules"),
398+
usr.join("lib").join("aw-tauri"),
399+
]
400+
);
401+
402+
let _ = fs::remove_dir_all(&root);
403+
}
404+
405+
#[test]
406+
fn test_linux_install_paths_without_resources() {
407+
// No ../lib/aw-tauri next to the binary: only the binary's own dir.
408+
let root = scratch_dir("bare");
409+
fs::create_dir_all(root.join("bin")).unwrap();
410+
411+
let paths = linux_install_discovery_paths(Some(&root.join("bin").join("aw-tauri")), None);
412+
assert_eq!(paths, vec![root.join("bin")]);
413+
414+
let _ = fs::remove_dir_all(&root);
415+
}
416+
417+
#[test]
418+
fn test_linux_install_paths_appimage_layout() {
419+
// AppImage: binary runs from a different dir, resources under $APPDIR.
420+
let root = scratch_dir("appimage");
421+
let appdir = root.join("squashfs-root");
422+
let resource = appdir.join("usr").join("lib").join("aw-tauri");
423+
fs::create_dir_all(resource.join("modules")).unwrap();
424+
let exe_dir = root.join("elsewhere").join("bin");
425+
fs::create_dir_all(&exe_dir).unwrap();
426+
427+
let paths = linux_install_discovery_paths(Some(&exe_dir.join("aw-tauri")), Some(&appdir));
428+
assert_eq!(
429+
paths,
430+
vec![exe_dir.clone(), resource.join("modules"), resource.clone()]
431+
);
432+
433+
// When the binary itself lives in $APPDIR/usr/bin, paths aren't duplicated.
434+
let exe = appdir.join("usr").join("bin").join("aw-tauri");
435+
let paths = linux_install_discovery_paths(Some(&exe), Some(&appdir));
436+
assert_eq!(
437+
paths,
438+
vec![
439+
appdir.join("usr").join("bin"),
440+
resource.join("modules"),
441+
resource,
442+
]
443+
);
444+
445+
let _ = fs::remove_dir_all(&root);
446+
}
447+
448+
#[test]
449+
fn test_macos_install_paths_bundle_layout() {
450+
let root = scratch_dir("macos");
451+
let contents = root.join("ActivityWatch.app").join("Contents");
452+
fs::create_dir_all(contents.join("MacOS")).unwrap();
453+
fs::create_dir_all(contents.join("Resources").join("modules")).unwrap();
454+
455+
let paths = macos_install_discovery_paths(Some(&contents.join("MacOS").join("aw-tauri")));
456+
assert_eq!(
457+
paths,
458+
vec![
459+
contents.join("Resources").join("modules"),
460+
contents.join("Resources"),
461+
]
462+
);
463+
464+
let _ = fs::remove_dir_all(&root);
465+
}
466+
356467
#[test]
357-
fn test_install_discovery_paths_is_callable() {
358-
// Does not require a real install layout; just ensures the helper runs.
359-
let _ = get_install_discovery_paths();
468+
fn test_install_paths_without_exe() {
469+
assert!(linux_install_discovery_paths(None, None).is_empty());
470+
assert!(macos_install_discovery_paths(None).is_empty());
360471
}
361472

362473
#[cfg(target_os = "linux")]

‎src-tauri/src/lib.rs‎

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -479,19 +479,24 @@ impl Default for UserConfig {
479479
fn default() -> Self {
480480
let discovery_paths = dirs::get_discovery_paths();
481481

482-
// Build default modules list based on platform.
483-
// Linux uses aw-awatcher (Rust, covers window+AFK on both X11 and Wayland)
484-
// so self-contained deb/rpm/AppImage packages work without the Python watchers.
485-
// See ActivityWatch/aw-tauri#232.
486-
let mut modules = Vec::new();
487-
488-
if cfg!(target_os = "linux") {
489-
modules.push(ModuleEntry::Simple("aw-awatcher".to_string()));
482+
// Build default modules list based on platform
483+
let modules = if cfg!(target_os = "linux") {
484+
let is_wayland = env::var("XDG_SESSION_TYPE")
485+
.map(|s| s == "wayland")
486+
.unwrap_or(false)
487+
|| env::var("WAYLAND_DISPLAY").is_ok();
488+
default_linux_modules(
489+
module_available("aw-awatcher", &discovery_paths),
490+
is_wayland,
491+
)
490492
} else {
491493
// On non-Linux platforms, use traditional watchers
492-
modules.push(ModuleEntry::Simple("aw-watcher-afk".to_string()));
493-
modules.push(ModuleEntry::Simple("aw-watcher-window".to_string()));
494-
}
494+
vec!["aw-watcher-afk", "aw-watcher-window"]
495+
};
496+
let modules = modules
497+
.into_iter()
498+
.map(|name| ModuleEntry::Simple(name.to_string()))
499+
.collect();
495500

496501
UserConfig {
497502
port: 5600,
@@ -509,6 +514,30 @@ impl Default for UserConfig {
509514
}
510515
}
511516

517+
/// First-run watchers on Linux. aw-awatcher (Rust, covers window + AFK on X11
518+
/// and Wayland) is preferred when it's installed, e.g. bundled in a
519+
/// self-contained deb/rpm/AppImage. Without it, keep the previous defaults so
520+
/// installs that only have the Python watchers still record activity: the
521+
/// Python window watcher can't see Wayland windows, so Wayland still gets
522+
/// aw-awatcher.
523+
fn default_linux_modules(awatcher_available: bool, is_wayland: bool) -> Vec<&'static str> {
524+
if awatcher_available || is_wayland {
525+
vec!["aw-awatcher"]
526+
} else {
527+
vec!["aw-watcher-afk", "aw-watcher-window"]
528+
}
529+
}
530+
531+
/// Whether a file called `name` exists in `discovery_paths` or `$PATH`.
532+
fn module_available(name: &str, discovery_paths: &[PathBuf]) -> bool {
533+
let path_var = env::var_os("PATH").unwrap_or_default();
534+
discovery_paths
535+
.iter()
536+
.cloned()
537+
.chain(env::split_paths(&path_var))
538+
.any(|dir| dir.join(name).is_file())
539+
}
540+
512541
fn get_config_path() -> PathBuf {
513542
dirs::get_config_path()
514543
}
@@ -1236,7 +1265,45 @@ pub fn run() {
12361265

12371266
#[cfg(test)]
12381267
mod tests {
1239-
use super::build_dashboard_url;
1268+
use super::{build_dashboard_url, default_linux_modules, module_available};
1269+
1270+
#[test]
1271+
fn default_linux_modules_prefers_awatcher_when_installed() {
1272+
assert_eq!(default_linux_modules(true, false), vec!["aw-awatcher"]);
1273+
assert_eq!(default_linux_modules(true, true), vec!["aw-awatcher"]);
1274+
}
1275+
1276+
#[test]
1277+
fn default_linux_modules_falls_back_without_awatcher() {
1278+
// X11 without aw-awatcher keeps the Python watchers
1279+
assert_eq!(
1280+
default_linux_modules(false, false),
1281+
vec!["aw-watcher-afk", "aw-watcher-window"]
1282+
);
1283+
// Wayland has no working Python window watcher, so stay on aw-awatcher
1284+
assert_eq!(default_linux_modules(false, true), vec!["aw-awatcher"]);
1285+
}
1286+
1287+
#[test]
1288+
fn module_available_checks_discovery_paths() {
1289+
let dir = std::env::temp_dir().join(format!(
1290+
"aw-tauri-test-module-available-{}",
1291+
std::process::id()
1292+
));
1293+
std::fs::create_dir_all(&dir).unwrap();
1294+
std::fs::write(dir.join("aw-test-module-xyz"), "").unwrap();
1295+
1296+
assert!(module_available(
1297+
"aw-test-module-xyz",
1298+
std::slice::from_ref(&dir)
1299+
));
1300+
assert!(!module_available(
1301+
"aw-test-module-missing",
1302+
std::slice::from_ref(&dir)
1303+
));
1304+
1305+
let _ = std::fs::remove_dir_all(&dir);
1306+
}
12401307

12411308
#[test]
12421309
fn build_dashboard_url_omits_token_when_auth_disabled() {

0 commit comments

Comments
 (0)