From 4b8bba6adf720b2f94171bb1e6e4a3d293db9f6c Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 17:58:38 +0000 Subject: [PATCH 1/5] 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 | 160 +++++++++++++++++++++++++++++++++++++++++++- aw-sync/src/main.rs | 12 ++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index d8291c17..c9246f9a 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -84,13 +84,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`. @@ -212,4 +305,67 @@ mod tests { assert!(files_dir_from_xdg_data_home(Path::new("data")).is_none()); assert!(files_dir_from_xdg_data_home(Path::new("/tmp/config")).is_none()); } + + #[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 02cfde09..fb79d1ae 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -189,6 +189,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 630caae1980af49dd2d7768ec9bb2cfb4297e874 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:14:49 +0000 Subject: [PATCH 2/5] 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. --- aw-sync/README.md | 8 +-- aw-sync/src/dirs.rs | 150 +++++++++++++------------------------------- aw-sync/src/main.rs | 15 +---- 3 files changed, 50 insertions(+), 123 deletions(-) diff --git a/aw-sync/README.md b/aw-sync/README.md index e1d19071..806ae79a 100644 --- a/aw-sync/README.md +++ b/aw-sync/README.md @@ -12,7 +12,7 @@ Was originally prototyped as a PR to aw-server: https://github.com/ActivityWatch ## Usage -This will start a daemon which pulls and pushes events with the sync directory (`~/ActivityWatchSync` by default) every 5 minutes: +This will start a daemon which pulls and pushes events with the sync directory every 5 minutes (platform data dir by default; `~/ActivityWatchSync` if that already exists): ```sh # Basic sync daemon (syncs all buckets every 5 minutes) @@ -46,7 +46,7 @@ For more options, see `aw-sync --help`. Some notable options: Once you have aw-sync running, you need to set up syncing with the sync directory using your preferred syncing tool. -The default sync directory is `~/ActivityWatchSync`, but you can change it using the `--sync-dir` option or by setting the `AW_SYNC_DIR` environment variable. +The default sync directory is 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`. ### Running from source @@ -94,7 +94,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 @@ -106,7 +106,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 c9246f9a..a6567a52 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -1,9 +1,7 @@ use dirs::home_dir; use std::error::Error; use std::fs; -#[cfg(any(target_os = "android", test))] -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Resolve the instance profile. /// `--profile` wins, then `AW_PROFILE`, then `--testing` → `"testing"`, else `"default"`. @@ -88,7 +86,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")? @@ -96,6 +93,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") { @@ -103,89 +122,19 @@ 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(()); - } - // `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(()) - } + legacy_sync_dir() } } -#[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`. /// `libaw_sync.so` is a separate cdylib from `libaw_server.so`, so /// `RustInterface.setDataDir` does not update this library's `ANDROID_DATA_DIR`. @@ -309,7 +258,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) @@ -320,52 +269,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(); + 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!(new.join("host1").join("device1").join("test.db").exists()); - assert!(!legacy.exists()); + 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 fb79d1ae..7dc8cdc0 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -60,7 +60,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, @@ -189,18 +190,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 68643b7a5ba47b40a4e796fdb7bbb84485cc99e1 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:38:02 +0000 Subject: [PATCH 3/5] 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. --- aw-sync/README.md | 6 +++--- aw-sync/src/dirs.rs | 35 +++++++++++++++++++++++++++++++++-- aw-sync/src/main.rs | 2 +- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/aw-sync/README.md b/aw-sync/README.md index 806ae79a..4dfa3587 100644 --- a/aw-sync/README.md +++ b/aw-sync/README.md @@ -12,7 +12,7 @@ Was originally prototyped as a PR to aw-server: https://github.com/ActivityWatch ## Usage -This will start a daemon which pulls and pushes events with the sync directory every 5 minutes (platform data dir by default; `~/ActivityWatchSync` if that already exists): +This will start a daemon which pulls and pushes events with the sync directory every 5 minutes (platform data dir by default; `~/ActivityWatchSync` if that already has content): ```sh # Basic sync daemon (syncs all buckets every 5 minutes) @@ -46,7 +46,7 @@ For more options, see `aw-sync --help`. Some notable options: Once you have aw-sync running, you need to set up syncing with the sync directory using your preferred syncing tool. -The default sync directory is 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`. +The default sync directory is 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`. ### Running from source @@ -94,7 +94,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 a6567a52..25b8870d 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -99,6 +99,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. @@ -106,9 +114,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() @@ -122,7 +137,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"))] { @@ -306,4 +321,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 7dc8cdc0..6b39af10 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -60,7 +60,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 bcdd1ceb37a90450d60f0321f1ba38b5afdabd47 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:46:10 +0000 Subject: [PATCH 4/5] 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. --- 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 25b8870d..1b6e3b82 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -99,12 +99,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 @@ -118,15 +123,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() + } + } } } @@ -337,4 +351,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 edb18794be6f537118ec2fe4a66a9d5fb7531821 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 1 Sep 2026 18:52:46 +0000 Subject: [PATCH 5/5] 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. --- 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 1b6e3b82..06b477f4 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -128,8 +128,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. @@ -370,4 +375,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); + } }