From b99d3eab1511aa684ad1d5cdef878359817b4d62 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 17:58:38 +0000 Subject: [PATCH 1/6] fix(aw-sync): default sync dir to documented data dir, migrate legacy ~/ActivityWatchSync aw-sync's default sync location was `~/ActivityWatchSync`, which a stock install (aw-tauri first-run autostarts `aw-sync daemon`) created in the user's home directory, clashing with the documented data directories (ActivityWatch/activitywatch#1418). - get_sync_dir() now defaults to data_dir()/activitywatch/aw-sync on desktop, matching aw-server's data_dir()/activitywatch/ convention and aw-sync's own config dir. Android keeps its app-scoped historical location; AW_SYNC_DIR/--sync-dir overrides are unchanged. - One-time migration moves an existing ~/ActivityWatchSync into the new location at daemon/CLI startup so synced data is preserved. It no-ops when an explicit location is set or no legacy dir exists, and refuses to auto-merge if both locations already have data (leaves both in place, logs a warning) rather than risk losing data. - Adds unit tests for the migrate, no-op, and refuse-to-merge paths. Closes ActivityWatch/activitywatch#1418 Git-Session-Id: fd9c --- aw-sync/src/dirs.rs | 161 +++++++++++++++++++++++++++++++++++++++++++- aw-sync/src/main.rs | 12 ++++ 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 03451ef0..ab1cfca0 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -167,13 +167,106 @@ pub fn get_server_config_path(testing: bool) -> Result { } } +/// The documented default sync data location: `data_dir()/activitywatch/aw-sync`, +/// mirroring aw-server's data-dir convention (`data_dir()/activitywatch/`) +/// and aw-sync's own config dir (`config_dir()/activitywatch/aw-sync`). +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] +fn default_sync_dir() -> Result> { + Ok(dirs::data_dir() + .ok_or("Unable to read user data dir")? + .join("activitywatch") + .join("aw-sync")) +} + pub fn get_sync_dir() -> Result> { // if AW_SYNC_DIR is set, use that if let Ok(dir) = std::env::var("AW_SYNC_DIR") { return Ok(PathBuf::from(dir)); } - let home_dir = home_dir().ok_or("Unable to read home_dir")?; - Ok(home_dir.join("ActivityWatchSync")) + // Desktop: keep sync data out of the user's home directory and follow the + // documented data-dir convention (ActivityWatch/activitywatch#1418). + #[cfg(not(target_os = "android"))] + { + default_sync_dir() + } + // Android is already app-scoped; keep the historical location there. + #[cfg(target_os = "android")] + { + let home_dir = home_dir().ok_or("Unable to read home_dir")?; + Ok(home_dir.join("ActivityWatchSync")) + } +} + +/// One-time migration from the legacy `~/ActivityWatchSync` location to the +/// documented data dir, so existing synced data is preserved while new installs +/// stop writing into the home directory (ActivityWatch/activitywatch#1418). +/// +/// No-op when an explicit location is in effect (`AW_SYNC_DIR` / `--sync-dir`), +/// when no legacy dir exists, or after the migration has already run. If both the +/// legacy and the new location already contain data, it refuses to merge and +/// leaves both in place (a manual merge is safer than an automated one). +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] // called by the aw-sync binary; unused in the lib copy +pub fn migrate_legacy_sync_dir() -> Result<(), Box> { + // Respect an explicit override: the user chose a location, don't relocate data. + if std::env::var("AW_SYNC_DIR").is_ok() { + return Ok(()); + } + let legacy = home_dir() + .ok_or("Unable to read home_dir")? + .join("ActivityWatchSync"); + let new = default_sync_dir()?; + migrate_legacy_sync_dir_to(&legacy, &new) +} + +/// Core migration: move `legacy` to `new` when `legacy` exists and `new` is absent +/// or empty. Refuses to merge two non-empty locations and never destroys data: +/// a failed rename leaves the source in place. +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] +fn migrate_legacy_sync_dir_to(legacy: &PathBuf, new: &PathBuf) -> Result<(), Box> { + if !legacy.exists() { + return Ok(()); + } + if new.exists() && is_non_empty(new)? { + warn!( + "Sync data exists in both legacy {:?} and documented {:?}; not auto-merging. \ + Move what you need and delete the rest manually.", + legacy, new + ); + return Ok(()); + } + // `fs::rename` replaces an empty dir target on Unix but fails on Windows, + // so drop an empty target first. + if new.exists() { + fs::remove_dir(new)?; + } + if let Some(parent) = new.parent() { + fs::create_dir_all(parent)?; + } + match fs::rename(legacy, new) { + Ok(()) => { + info!("Migrated legacy sync dir {:?} -> {:?}", legacy, new); + Ok(()) + } + // Cross-device rename (EXDEV) or other failure: leave the data where it + // is rather than risk losing it. + Err(e) => { + warn!( + "Could not migrate legacy sync dir {:?} to {:?} ({e}); move it manually \ + if you want the documented location.", + legacy, new + ); + Ok(()) + } + } +} + +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] +fn is_non_empty(dir: &PathBuf) -> Result> { + Ok(fs::read_dir(dir)?.next().is_some()) } /// SyncInterface.kt sets `XDG_DATA_HOME=$filesDir/data` before `loadLibrary`. @@ -295,6 +388,7 @@ mod tests { assert!(files_dir_from_xdg_data_home(Path::new("/tmp/config")).is_none()); } + // ActivityWatch/aw-server-rust#714: daemon pull is opt-in. #[test] fn effective_daemon_mode_explicit_cli_always_wins() { @@ -380,4 +474,67 @@ mod tests { assert!(config.unwrap().daemon.pull, "should read pull = true"); let _ = fs::remove_dir_all(&dir); } + + #[cfg(not(target_os = "android"))] + fn unique_root() -> PathBuf { + std::env::temp_dir().join(format!( + "aw-sync-migrate-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[cfg(not(target_os = "android"))] + #[test] + fn migrates_legacy_sync_dir_into_documented_location() { + let root = unique_root(); + let legacy = root.join("ActivityWatchSync"); + let new = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(legacy.join("host1").join("device1")).unwrap(); + fs::write( + legacy.join("host1").join("device1").join("test.db"), + b"sqlite", + ) + .unwrap(); + + migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); + + assert!(new.join("host1").join("device1").join("test.db").exists()); + assert!(!legacy.exists()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn migration_is_noop_without_legacy_dir() { + let root = unique_root(); + let legacy = root.join("ActivityWatchSync"); + let new = root.join("data").join("activitywatch").join("aw-sync"); + + migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); + + assert!(!new.exists()); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn migration_refuses_to_merge_two_non_empty_dirs() { + let root = unique_root(); + let legacy = root.join("ActivityWatchSync"); + let new = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(legacy.join("a")).unwrap(); + fs::write(legacy.join("a").join("x.db"), b"1").unwrap(); + fs::create_dir_all(new.join("b")).unwrap(); + fs::write(new.join("b").join("y.db"), b"2").unwrap(); + + migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); + + assert!(legacy.join("a").join("x.db").exists()); + assert!(new.join("b").join("y.db").exists()); + let _ = fs::remove_dir_all(&root); + } } diff --git a/aw-sync/src/main.rs b/aw-sync/src/main.rs index d69cf192..a7141a12 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -199,6 +199,18 @@ fn main() -> Result<(), Box> { std::env::set_var("AW_SYNC_DIR", sync_dir); } + // One-time: relocate a legacy ~/ActivityWatchSync into the documented data + // dir so existing synced data is preserved and new installs stop writing + // into the home directory (ActivityWatch/activitywatch#1418). No-op when the + // default location is not in effect (AW_SYNC_DIR / --sync-dir set) or when + // there is no legacy data to move. + #[cfg(not(target_os = "android"))] + { + if let Err(e) = dirs::migrate_legacy_sync_dir() { + warn!("Legacy sync-dir migration skipped: {e}"); + } + } + // Named profiles (and `--profile testing` without `--testing`) must use the // profile's own server config / port default, not the CLI testing bool. let testing = profile == "testing"; From ef423fc1527bcf4fad841cf78ed72cb1fbea27f3 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:14:49 +0000 Subject: [PATCH 2/6] fix(aw-sync): keep existing ~/ActivityWatchSync instead of auto-renaming Auto-migration on startup had two failure modes Greptile flagged: a failed cross-device rename left the daemon writing a fresh empty tree beside live data, and a successful rename disconnected any Syncthing/ Dropbox transport still watching the old path. New installs still default to data_dir()/activitywatch/aw-sync. If ~/ActivityWatchSync already exists, keep using it. AW_SYNC_DIR and --sync-dir are unchanged. Git-Session-Id: aa450161-741c-5b1f-afe9-6bd926372afe --- aw-sync/README.md | 10 +-- aw-sync/src/dirs.rs | 148 +++++++++++++------------------------------- aw-sync/src/main.rs | 15 +---- 3 files changed, 51 insertions(+), 122 deletions(-) diff --git a/aw-sync/README.md b/aw-sync/README.md index fd1d3985..3f7e6709 100644 --- a/aw-sync/README.md +++ b/aw-sync/README.md @@ -47,15 +47,15 @@ For more options, see `aw-sync --help`. Some notable options: Share the sync directory with Syncthing, Dropbox, Drive, or rsync **before** running aw-sync. aw-sync only reads and writes files in that folder; it does not transport them between devices. -Default directory: `~/ActivityWatchSync` (`--sync-dir` or `AW_SYNC_DIR`). +Default directory: the platform data dir (`~/.local/share/activitywatch/aw-sync` on Linux, `~/Library/Application Support/activitywatch/aw-sync` on macOS, `%APPDATA%/activitywatch/aw-sync` on Windows). If `~/ActivityWatchSync` already exists (the previous default), that path is kept so existing Syncthing/Dropbox setups keep working. Override with `--sync-dir` or `AW_SYNC_DIR`. Working paths (bare `aw-sync sync`, Android) write: ```txt -~/ActivityWatchSync/{hostname}/{device_id}/test.db +{sync_dir}/{hostname}/{device_id}/test.db ``` -The default daemon still writes `~/ActivityWatchSync/{device_id}/test.db` (two levels). `aw-sync sync` and the Android app cannot see that file. +The default daemon still writes `{sync_dir}/{device_id}/test.db` (two levels). `aw-sync sync` and the Android app cannot see that file. ### Running from source @@ -102,7 +102,7 @@ We will use some helper scripts to do the following: 1. `./test-sync-push.sh` - Creates a sync directory **for you to set up sync** with Syncthing/Dropbox/Gdrive/rclone/whatever - - By default `~/ActivityWatchSync` + - Platform data dir by default; `~/ActivityWatchSync` if that already exists - Creates a datastore for the current host in the sync folder - Sync all local buckets of interest (window & afk buckets, by default) to the sync dir @@ -114,7 +114,7 @@ We will use some helper scripts to do the following: 4. You should now have all events synced to a local testing instance! - You can browse [127.0.0.1:5667](http://127.0.0.1:5667) to view testing instance, where you'll see events from synced all hosts. - - You can now set up syncing for `~/ActivityWatchSync` on more devices, and on each one use the script `./test-sync.sh` to push their events into the sync folder, then run `./test-import-sync.sh` on the device where you have the testing instance to update the data there. + - You can now set up syncing for the sync directory on more devices, and on each one use the script `./test-sync.sh` to push their events into the sync folder, then run `./test-import-sync.sh` on the device where you have the testing instance to update the data there. 5. To view data from all devices at once, go into [127.0.0.1:5667/#/settings](127.0.0.1:5667/#/settings) and check the "Use multidevice query" checkbox (near the bottom, under "developer settings"). - You can now navigate back to the activity view for any device, where you should see data from multiple devices being included in (most of) the visualizations. diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index ab1cfca0..d64eba17 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -171,7 +171,6 @@ pub fn get_server_config_path(testing: bool) -> Result { /// mirroring aw-server's data-dir convention (`data_dir()/activitywatch/`) /// and aw-sync's own config dir (`config_dir()/activitywatch/aw-sync`). #[cfg(not(target_os = "android"))] -#[allow(dead_code)] fn default_sync_dir() -> Result> { Ok(dirs::data_dir() .ok_or("Unable to read user data dir")? @@ -179,6 +178,28 @@ fn default_sync_dir() -> Result> { .join("aw-sync")) } +fn legacy_sync_dir() -> Result> { + Ok(home_dir() + .ok_or("Unable to read home_dir")? + .join("ActivityWatchSync")) +} + +/// Prefer an existing `~/ActivityWatchSync` so folder-sync setups +/// (Syncthing/Dropbox/etc watching that path) keep working. New installs +/// with no legacy dir use the documented data-dir location. +/// +/// Do not auto-rename: a failed cross-device `rename` would leave the daemon +/// writing a fresh empty tree, and a successful one would disconnect any +/// external transport still pointed at the old path. +#[cfg(not(target_os = "android"))] +fn resolve_sync_dir(legacy: &Path, documented: &Path) -> PathBuf { + if legacy.exists() { + legacy.to_path_buf() + } else { + documented.to_path_buf() + } +} + pub fn get_sync_dir() -> Result> { // if AW_SYNC_DIR is set, use that if let Ok(dir) = std::env::var("AW_SYNC_DIR") { @@ -186,87 +207,17 @@ pub fn get_sync_dir() -> Result> { } // Desktop: keep sync data out of the user's home directory and follow the // documented data-dir convention (ActivityWatch/activitywatch#1418). + // If the previous default already exists, keep using it — aw-sync's + // transport is an external folder synchronizer watching that path. #[cfg(not(target_os = "android"))] { - default_sync_dir() + Ok(resolve_sync_dir(&legacy_sync_dir()?, &default_sync_dir()?)) } // Android is already app-scoped; keep the historical location there. #[cfg(target_os = "android")] { - let home_dir = home_dir().ok_or("Unable to read home_dir")?; - Ok(home_dir.join("ActivityWatchSync")) - } -} - -/// One-time migration from the legacy `~/ActivityWatchSync` location to the -/// documented data dir, so existing synced data is preserved while new installs -/// stop writing into the home directory (ActivityWatch/activitywatch#1418). -/// -/// No-op when an explicit location is in effect (`AW_SYNC_DIR` / `--sync-dir`), -/// when no legacy dir exists, or after the migration has already run. If both the -/// legacy and the new location already contain data, it refuses to merge and -/// leaves both in place (a manual merge is safer than an automated one). -#[cfg(not(target_os = "android"))] -#[allow(dead_code)] // called by the aw-sync binary; unused in the lib copy -pub fn migrate_legacy_sync_dir() -> Result<(), Box> { - // Respect an explicit override: the user chose a location, don't relocate data. - if std::env::var("AW_SYNC_DIR").is_ok() { - return Ok(()); - } - let legacy = home_dir() - .ok_or("Unable to read home_dir")? - .join("ActivityWatchSync"); - let new = default_sync_dir()?; - migrate_legacy_sync_dir_to(&legacy, &new) -} - -/// Core migration: move `legacy` to `new` when `legacy` exists and `new` is absent -/// or empty. Refuses to merge two non-empty locations and never destroys data: -/// a failed rename leaves the source in place. -#[cfg(not(target_os = "android"))] -#[allow(dead_code)] -fn migrate_legacy_sync_dir_to(legacy: &PathBuf, new: &PathBuf) -> Result<(), Box> { - if !legacy.exists() { - return Ok(()); - } - if new.exists() && is_non_empty(new)? { - warn!( - "Sync data exists in both legacy {:?} and documented {:?}; not auto-merging. \ - Move what you need and delete the rest manually.", - legacy, new - ); - return Ok(()); + legacy_sync_dir() } - // `fs::rename` replaces an empty dir target on Unix but fails on Windows, - // so drop an empty target first. - if new.exists() { - fs::remove_dir(new)?; - } - if let Some(parent) = new.parent() { - fs::create_dir_all(parent)?; - } - match fs::rename(legacy, new) { - Ok(()) => { - info!("Migrated legacy sync dir {:?} -> {:?}", legacy, new); - Ok(()) - } - // Cross-device rename (EXDEV) or other failure: leave the data where it - // is rather than risk losing it. - Err(e) => { - warn!( - "Could not migrate legacy sync dir {:?} to {:?} ({e}); move it manually \ - if you want the documented location.", - legacy, new - ); - Ok(()) - } - } -} - -#[cfg(not(target_os = "android"))] -#[allow(dead_code)] -fn is_non_empty(dir: &PathBuf) -> Result> { - Ok(fs::read_dir(dir)?.next().is_some()) } /// SyncInterface.kt sets `XDG_DATA_HOME=$filesDir/data` before `loadLibrary`. @@ -478,7 +429,7 @@ mod tests { #[cfg(not(target_os = "android"))] fn unique_root() -> PathBuf { std::env::temp_dir().join(format!( - "aw-sync-migrate-{}-{}", + "aw-sync-resolve-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -489,52 +440,41 @@ mod tests { #[cfg(not(target_os = "android"))] #[test] - fn migrates_legacy_sync_dir_into_documented_location() { + fn prefers_existing_legacy_sync_dir() { let root = unique_root(); let legacy = root.join("ActivityWatchSync"); - let new = root.join("data").join("activitywatch").join("aw-sync"); - fs::create_dir_all(legacy.join("host1").join("device1")).unwrap(); - fs::write( - legacy.join("host1").join("device1").join("test.db"), - b"sqlite", - ) - .unwrap(); - - migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); - - assert!(new.join("host1").join("device1").join("test.db").exists()); - assert!(!legacy.exists()); + let documented = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(&legacy).unwrap(); + fs::write(legacy.join("test.db"), b"sqlite").unwrap(); + + assert_eq!(resolve_sync_dir(&legacy, &documented), legacy); let _ = fs::remove_dir_all(&root); } #[cfg(not(target_os = "android"))] #[test] - fn migration_is_noop_without_legacy_dir() { + fn uses_documented_dir_when_no_legacy() { let root = unique_root(); let legacy = root.join("ActivityWatchSync"); - let new = root.join("data").join("activitywatch").join("aw-sync"); + let documented = root.join("data").join("activitywatch").join("aw-sync"); - migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); - - assert!(!new.exists()); + assert_eq!(resolve_sync_dir(&legacy, &documented), documented); let _ = fs::remove_dir_all(&root); } #[cfg(not(target_os = "android"))] #[test] - fn migration_refuses_to_merge_two_non_empty_dirs() { + fn prefers_legacy_even_if_documented_also_exists() { + // Don't silently switch away from a live folder-sync path. let root = unique_root(); let legacy = root.join("ActivityWatchSync"); - let new = root.join("data").join("activitywatch").join("aw-sync"); - fs::create_dir_all(legacy.join("a")).unwrap(); - fs::write(legacy.join("a").join("x.db"), b"1").unwrap(); - fs::create_dir_all(new.join("b")).unwrap(); - fs::write(new.join("b").join("y.db"), b"2").unwrap(); - - migrate_legacy_sync_dir_to(&legacy, &new).unwrap(); + let documented = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(&legacy).unwrap(); + fs::write(legacy.join("legacy.db"), b"1").unwrap(); + fs::create_dir_all(&documented).unwrap(); + fs::write(documented.join("new.db"), b"2").unwrap(); - assert!(legacy.join("a").join("x.db").exists()); - assert!(new.join("b").join("y.db").exists()); + assert_eq!(resolve_sync_dir(&legacy, &documented), legacy); let _ = fs::remove_dir_all(&root); } } diff --git a/aw-sync/src/main.rs b/aw-sync/src/main.rs index a7141a12..01d09731 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -62,7 +62,8 @@ struct Opts { testing: bool, /// Full path to sync directory. - /// If not specified, use AW_SYNC_DIR env var, or default to ~/ActivityWatchSync + /// If not specified, use AW_SYNC_DIR, then an existing ~/ActivityWatchSync, + /// otherwise the platform data dir (`.../activitywatch/aw-sync`). #[clap(long)] sync_dir: Option, @@ -199,18 +200,6 @@ fn main() -> Result<(), Box> { std::env::set_var("AW_SYNC_DIR", sync_dir); } - // One-time: relocate a legacy ~/ActivityWatchSync into the documented data - // dir so existing synced data is preserved and new installs stop writing - // into the home directory (ActivityWatch/activitywatch#1418). No-op when the - // default location is not in effect (AW_SYNC_DIR / --sync-dir set) or when - // there is no legacy data to move. - #[cfg(not(target_os = "android"))] - { - if let Err(e) = dirs::migrate_legacy_sync_dir() { - warn!("Legacy sync-dir migration skipped: {e}"); - } - } - // Named profiles (and `--profile testing` without `--testing`) must use the // profile's own server config / port default, not the CLI testing bool. let testing = profile == "testing"; From 4c3c04647a86591de6ff4362b4a80c32723694ef Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:38:02 +0000 Subject: [PATCH 3/6] fix(aw-sync): empty ~/ActivityWatchSync must not hide live data dir An empty leftover of the legacy default was enough to switch every sync operation away from remote databases already in the documented directory. Prefer the documented path when it has content and the legacy dir does not. Git-Session-Id: aa450161-741c-5b1f-afe9-6bd926372afe --- aw-sync/README.md | 5 ++--- aw-sync/src/dirs.rs | 35 +++++++++++++++++++++++++++++++++-- aw-sync/src/main.rs | 2 +- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/aw-sync/README.md b/aw-sync/README.md index 3f7e6709..8e6b0ed3 100644 --- a/aw-sync/README.md +++ b/aw-sync/README.md @@ -14,7 +14,6 @@ Was originally prototyped as a PR to aw-server: https://github.com/ActivityWatch ## Usage -The command that actually pulls and pushes today is a one-shot pass. Share the sync directory first (see below), then: ```sh # Pull every 3-level peer and push this device, then exit @@ -47,7 +46,7 @@ For more options, see `aw-sync --help`. Some notable options: Share the sync directory with Syncthing, Dropbox, Drive, or rsync **before** running aw-sync. aw-sync only reads and writes files in that folder; it does not transport them between devices. -Default directory: the platform data dir (`~/.local/share/activitywatch/aw-sync` on Linux, `~/Library/Application Support/activitywatch/aw-sync` on macOS, `%APPDATA%/activitywatch/aw-sync` on Windows). If `~/ActivityWatchSync` already exists (the previous default), that path is kept so existing Syncthing/Dropbox setups keep working. Override with `--sync-dir` or `AW_SYNC_DIR`. +Default directory: the platform data dir (`~/.local/share/activitywatch/aw-sync` on Linux, `~/Library/Application Support/activitywatch/aw-sync` on macOS, `%APPDATA%/activitywatch/aw-sync` on Windows). If `~/ActivityWatchSync` already has content (the previous default), that path is kept so existing Syncthing/Dropbox setups keep working. An empty leftover of that path does not displace live data in the documented directory. Override with `--sync-dir` or `AW_SYNC_DIR`. Working paths (bare `aw-sync sync`, Android) write: @@ -102,7 +101,7 @@ We will use some helper scripts to do the following: 1. `./test-sync-push.sh` - Creates a sync directory **for you to set up sync** with Syncthing/Dropbox/Gdrive/rclone/whatever - - Platform data dir by default; `~/ActivityWatchSync` if that already exists + - Platform data dir by default; `~/ActivityWatchSync` if that already has content - Creates a datastore for the current host in the sync folder - Sync all local buckets of interest (window & afk buckets, by default) to the sync dir diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index d64eba17..7211b262 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -184,6 +184,14 @@ fn legacy_sync_dir() -> Result> { .join("ActivityWatchSync")) } +#[cfg(not(target_os = "android"))] +fn dir_has_entries(path: &Path) -> bool { + fs::read_dir(path) + .ok() + .and_then(|mut it| it.next()) + .is_some() +} + /// Prefer an existing `~/ActivityWatchSync` so folder-sync setups /// (Syncthing/Dropbox/etc watching that path) keep working. New installs /// with no legacy dir use the documented data-dir location. @@ -191,9 +199,16 @@ fn legacy_sync_dir() -> Result> { /// Do not auto-rename: a failed cross-device `rename` would leave the daemon /// writing a fresh empty tree, and a successful one would disconnect any /// external transport still pointed at the old path. +/// +/// An empty leftover `~/ActivityWatchSync` must not displace live data +/// already in the documented directory (backup restore of an empty folder, +/// old docs creating the path after a new install has started syncing). #[cfg(not(target_os = "android"))] fn resolve_sync_dir(legacy: &Path, documented: &Path) -> PathBuf { - if legacy.exists() { + if !legacy.exists() { + return documented.to_path_buf(); + } + if dir_has_entries(legacy) || !dir_has_entries(documented) { legacy.to_path_buf() } else { documented.to_path_buf() @@ -207,7 +222,7 @@ pub fn get_sync_dir() -> Result> { } // Desktop: keep sync data out of the user's home directory and follow the // documented data-dir convention (ActivityWatch/activitywatch#1418). - // If the previous default already exists, keep using it — aw-sync's + // If the previous default already has content, keep using it — aw-sync's // transport is an external folder synchronizer watching that path. #[cfg(not(target_os = "android"))] { @@ -477,4 +492,20 @@ mod tests { assert_eq!(resolve_sync_dir(&legacy, &documented), legacy); let _ = fs::remove_dir_all(&root); } + + #[cfg(not(target_os = "android"))] + #[test] + fn empty_legacy_does_not_displace_populated_documented() { + // A restored/recreated empty ~/ActivityWatchSync must not hide + // remote databases already in the documented data dir. + let root = unique_root(); + let legacy = root.join("ActivityWatchSync"); + let documented = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(&legacy).unwrap(); + fs::create_dir_all(&documented).unwrap(); + fs::write(documented.join("test.db"), b"sqlite").unwrap(); + + assert_eq!(resolve_sync_dir(&legacy, &documented), documented); + let _ = fs::remove_dir_all(&root); + } } diff --git a/aw-sync/src/main.rs b/aw-sync/src/main.rs index 01d09731..b2db2ce6 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -62,7 +62,7 @@ struct Opts { testing: bool, /// Full path to sync directory. - /// If not specified, use AW_SYNC_DIR, then an existing ~/ActivityWatchSync, + /// If not specified, use AW_SYNC_DIR, then a non-empty ~/ActivityWatchSync, /// otherwise the platform data dir (`.../activitywatch/aw-sync`). #[clap(long)] sync_dir: Option, From fd11d03fdf8e51ceb0adecb1856ce3c4e0237376 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:46:10 +0000 Subject: [PATCH 4/6] fix(aw-sync): treat unreadable ~/ActivityWatchSync as live, not empty A read_dir error on the legacy path must not be treated as emptiness. That silently switched the daemon onto the documented data dir while Syncthing/Dropbox still watched the old path. Git-Session-Id: aa450161-741c-5b1f-afe9-6bd926372afe --- aw-sync/src/dirs.rs | 51 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 7211b262..ad8df1ad 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -184,12 +184,17 @@ fn legacy_sync_dir() -> Result> { .join("ActivityWatchSync")) } +/// Whether `path` has at least one directory entry. +/// +/// `None` means the path could not be enumerated (permission error, not a +/// directory, I/O). Callers must not treat that as empty: an unreadable +/// `~/ActivityWatchSync` is still the live transport root. #[cfg(not(target_os = "android"))] -fn dir_has_entries(path: &Path) -> bool { - fs::read_dir(path) - .ok() - .and_then(|mut it| it.next()) - .is_some() +fn dir_has_entries(path: &Path) -> Option { + match fs::read_dir(path) { + Ok(mut it) => Some(it.next().is_some()), + Err(_) => None, + } } /// Prefer an existing `~/ActivityWatchSync` so folder-sync setups @@ -203,15 +208,24 @@ fn dir_has_entries(path: &Path) -> bool { /// An empty leftover `~/ActivityWatchSync` must not displace live data /// already in the documented directory (backup restore of an empty folder, /// old docs creating the path after a new install has started syncing). +/// A legacy path that exists but cannot be enumerated is kept: treating a +/// read error as emptiness would silently switch the daemon onto the +/// documented dir while Syncthing/Dropbox still watch the old path. #[cfg(not(target_os = "android"))] fn resolve_sync_dir(legacy: &Path, documented: &Path) -> PathBuf { if !legacy.exists() { return documented.to_path_buf(); } - if dir_has_entries(legacy) || !dir_has_entries(documented) { - legacy.to_path_buf() - } else { - documented.to_path_buf() + match dir_has_entries(legacy) { + // Has content, or unreadable: keep the transport-attached path. + Some(true) | None => legacy.to_path_buf(), + Some(false) => { + if dir_has_entries(documented) == Some(true) { + documented.to_path_buf() + } else { + legacy.to_path_buf() + } + } } } @@ -508,4 +522,23 @@ mod tests { assert_eq!(resolve_sync_dir(&legacy, &documented), documented); let _ = fs::remove_dir_all(&root); } + + #[cfg(not(target_os = "android"))] + #[test] + fn unreadable_legacy_keeps_legacy_even_if_documented_populated() { + // read_dir error must not be treated as "empty" — that would switch + // the daemon onto the documented dir while Syncthing still watches + // the legacy path. A regular file at the legacy path is a portable + // stand-in for permission/IO failure. + let root = unique_root(); + let legacy = root.join("ActivityWatchSync"); + let documented = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(&root).unwrap(); + fs::write(&legacy, b"not-a-directory").unwrap(); + fs::create_dir_all(&documented).unwrap(); + fs::write(documented.join("test.db"), b"sqlite").unwrap(); + + assert_eq!(resolve_sync_dir(&legacy, &documented), legacy); + let _ = fs::remove_dir_all(&root); + } } From cd2d3cb87deb437999f1c5449275881dd05a3fe3 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:52:46 +0000 Subject: [PATCH 5/6] fix(aw-sync): fail closed when legacy sync-dir metadata is unreadable Path::exists() maps IO/permission errors to false, which selected the documented data dir and abandoned a Syncthing/Dropbox root we could not stat. Use try_exists() and keep the legacy path on Err. Git-Session-Id: aa450161-741c-5b1f-afe9-6bd926372afe --- aw-sync/src/dirs.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index ad8df1ad..453890bd 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -213,8 +213,13 @@ fn dir_has_entries(path: &Path) -> Option { /// documented dir while Syncthing/Dropbox still watch the old path. #[cfg(not(target_os = "android"))] fn resolve_sync_dir(legacy: &Path, documented: &Path) -> PathBuf { - if !legacy.exists() { - return documented.to_path_buf(); + // exists() maps metadata errors to false, which would silently switch + // onto the documented dir while Syncthing/Dropbox still watch the + // legacy path. Fail closed: only leave legacy when we *know* it is absent. + match legacy.try_exists() { + Ok(false) => return documented.to_path_buf(), + Ok(true) => {} + Err(_) => return legacy.to_path_buf(), } match dir_has_entries(legacy) { // Has content, or unreadable: keep the transport-attached path. @@ -541,4 +546,32 @@ mod tests { assert_eq!(resolve_sync_dir(&legacy, &documented), legacy); let _ = fs::remove_dir_all(&root); } + + #[cfg(all(unix, not(target_os = "android")))] + #[test] + fn metadata_error_on_legacy_keeps_legacy() { + // Path::exists() treats a metadata error as absence. try_exists() + // must fail closed onto the legacy path so we don't abandon a + // Syncthing/Dropbox root whose parent we cannot stat. + use std::os::unix::fs::PermissionsExt; + + let root = unique_root(); + let parent = root.join("hidden"); + let legacy = parent.join("ActivityWatchSync"); + let documented = root.join("data").join("activitywatch").join("aw-sync"); + fs::create_dir_all(&legacy).unwrap(); + fs::write(legacy.join("test.db"), b"sqlite").unwrap(); + fs::create_dir_all(&documented).unwrap(); + fs::write(documented.join("new.db"), b"2").unwrap(); + + let mut perms = fs::metadata(&parent).unwrap().permissions(); + perms.set_mode(0o000); + fs::set_permissions(&parent, perms).unwrap(); + + let chosen = resolve_sync_dir(&legacy, &documented); + let _ = fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)); + + assert_eq!(chosen, legacy); + let _ = fs::remove_dir_all(&root); + } } From 9aa33e91d2dc2c8b881edac83023492c7e42847a Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 18 Sep 2026 15:57:16 +0000 Subject: [PATCH 6/6] style(aw-sync): fix extra blank line in dirs.rs tests (fmt) Git-Session-Id: d3c0 --- aw-sync/src/dirs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 453890bd..42ade268 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -373,7 +373,6 @@ mod tests { assert!(files_dir_from_xdg_data_home(Path::new("/tmp/config")).is_none()); } - // ActivityWatch/aw-server-rust#714: daemon pull is opt-in. #[test] fn effective_daemon_mode_explicit_cli_always_wins() {