From 3ab8291faa359dacb3d499c7a213bef7b60f4641 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= <erik@bjareho.lt>
Date: Mon, 30 Oct 2023 11:19:18 +0100
Subject: [PATCH] feat(sync): moved logic in sync-test scripts into rust,
 enabling sync with a single command (#430)

* feat(sync): moved logic in `test-sync-pull/push.sh` scripts into rust code, enabling easy sync with a single command

* fix: fixes to sync dirs

* fix: moved --sync-dir arg into sync-advanced subcommand, fixed warnings

* fix: pass around client instead of port/testing

* fix: fixed log dir for aw-sync, misc sync fixes and refactor
---
 Cargo.lock                  |  15 ++++-
 aw-server/src/dirs.rs       |   8 +--
 aw-server/src/logging.rs    |  22 +++----
 aw-server/src/main.rs       |   3 +-
 aw-sync/Cargo.toml          |   4 ++
 aw-sync/src/dirs.rs         |  27 ++++++++
 aw-sync/src/lib.rs          |   7 ++
 aw-sync/src/main.rs         | 116 +++++++++++++++++++++------------
 aw-sync/src/sync.rs         |  57 +++--------------
 aw-sync/src/sync_wrapper.rs | 102 +++++++++++++++++++++++++++++
 aw-sync/src/util.rs         | 124 ++++++++++++++++++++++++++++++++++++
 aw-sync/test-sync-pull.sh   |   5 +-
 aw-sync/test-sync-push.sh   |   5 +-
 13 files changed, 385 insertions(+), 110 deletions(-)
 create mode 100644 aw-sync/src/dirs.rs
 create mode 100644 aw-sync/src/sync_wrapper.rs
 create mode 100644 aw-sync/src/util.rs

diff --git a/Cargo.lock b/Cargo.lock
index 385170d7..25e17223 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -290,16 +290,20 @@ dependencies = [
 name = "aw-sync"
 version = "0.1.0"
 dependencies = [
+ "appdirs",
  "aw-client-rust",
  "aw-datastore",
  "aw-models",
  "aw-server",
  "chrono",
  "clap",
+ "dirs 3.0.2",
+ "gethostname",
  "log",
  "reqwest",
  "serde",
  "serde_json",
+ "toml",
 ]
 
 [[package]]
@@ -752,6 +756,15 @@ dependencies = [
  "crypto-common",
 ]
 
+[[package]]
+name = "dirs"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309"
+dependencies = [
+ "dirs-sys",
+]
+
 [[package]]
 name = "dirs"
 version = "4.0.0"
@@ -2338,7 +2351,7 @@ version = "2.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
 dependencies = [
- "dirs",
+ "dirs 4.0.0",
 ]
 
 [[package]]
diff --git a/aw-server/src/dirs.rs b/aw-server/src/dirs.rs
index b94456e9..755d25b7 100644
--- a/aw-server/src/dirs.rs
+++ b/aw-server/src/dirs.rs
@@ -53,15 +53,15 @@ pub fn get_cache_dir() -> Result<PathBuf, ()> {
 }
 
 #[cfg(not(target_os = "android"))]
-pub fn get_log_dir() -> Result<PathBuf, ()> {
+pub fn get_log_dir(module: &str) -> Result<PathBuf, ()> {
     let mut dir = appdirs::user_log_dir(Some("activitywatch"), None)?;
-    dir.push("aw-server-rust");
+    dir.push(module);
     fs::create_dir_all(dir.clone()).expect("Unable to create log dir");
     Ok(dir)
 }
 
 #[cfg(target_os = "android")]
-pub fn get_log_dir() -> Result<PathBuf, ()> {
+pub fn get_log_dir(module: &str) -> Result<PathBuf, ()> {
     panic!("not implemented on Android");
 }
 
@@ -87,7 +87,7 @@ fn test_get_dirs() {
     set_android_data_dir("/test");
 
     get_cache_dir().unwrap();
-    get_log_dir().unwrap();
+    get_log_dir("aw-server-rust").unwrap();
     db_path(true).unwrap();
     db_path(false).unwrap();
 }
diff --git a/aw-server/src/logging.rs b/aw-server/src/logging.rs
index 6187d2db..8ec4568f 100644
--- a/aw-server/src/logging.rs
+++ b/aw-server/src/logging.rs
@@ -5,19 +5,17 @@ use fern::colors::{Color, ColoredLevelConfig};
 
 use crate::dirs;
 
-pub fn setup_logger(testing: bool, verbose: bool) -> Result<(), fern::InitError> {
+pub fn setup_logger(module: &str, testing: bool, verbose: bool) -> Result<(), fern::InitError> {
     let mut logfile_path: PathBuf =
-        dirs::get_log_dir().expect("Unable to get log dir to store logs in");
+        dirs::get_log_dir(module).expect("Unable to get log dir to store logs in");
     fs::create_dir_all(logfile_path.clone()).expect("Unable to create folder for logs");
-    logfile_path.push(
-        chrono::Local::now()
-            .format(if !testing {
-                "aw-server_%Y-%m-%dT%H-%M-%S%z.log"
-            } else {
-                "aw-server-testing_%Y-%m-%dT%H-%M-%S%z.log"
-            })
-            .to_string(),
-    );
+    let filename = if !testing {
+        format!("{}_%Y-%m-%dT%H-%M-%S%z.log", module)
+    } else {
+        format!("{}-testing_%Y-%m-%dT%H-%M-%S%z.log", module)
+    };
+
+    logfile_path.push(chrono::Local::now().format(&filename).to_string());
 
     log_panics::init();
 
@@ -93,6 +91,6 @@ mod tests {
     #[ignore]
     #[test]
     fn test_setup_logger() {
-        setup_logger(true, true).unwrap();
+        setup_logger("aw-server-rust", true, true).unwrap();
     }
 }
diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs
index 73185a64..933366e6 100644
--- a/aw-server/src/main.rs
+++ b/aw-server/src/main.rs
@@ -70,7 +70,8 @@ async fn main() -> Result<(), rocket::Error> {
         testing = true;
     }
 
-    logging::setup_logger(testing, opts.verbose).expect("Failed to setup logging");
+    logging::setup_logger("aw-server-rust", testing, opts.verbose)
+        .expect("Failed to setup logging");
 
     if testing {
         info!("Running server in Testing mode");
diff --git a/aw-sync/Cargo.toml b/aw-sync/Cargo.toml
index 2380a005..6ff9dd69 100644
--- a/aw-sync/Cargo.toml
+++ b/aw-sync/Cargo.toml
@@ -14,12 +14,16 @@ path = "src/main.rs"
 
 [dependencies]
 log = "0.4"
+toml = "0.7"
 chrono = { version = "0.4", features = ["serde"] }
 serde = "1.0"
 serde_json = "1.0"
 reqwest = { version = "0.11", features = ["json", "blocking"] }
 clap = { version = "4.1", features = ["derive"] }
+appdirs = "0.2.0"
+dirs = "3.0.2"
 aw-server = { path = "../aw-server" }
 aw-models = { path = "../aw-models" }
 aw-datastore = { path = "../aw-datastore" }
 aw-client-rust = { path = "../aw-client-rust" }
+gethostname = "0.4.3"
diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs
new file mode 100644
index 00000000..5e3faffd
--- /dev/null
+++ b/aw-sync/src/dirs.rs
@@ -0,0 +1,27 @@
+use dirs::home_dir;
+use std::fs;
+use std::path::PathBuf;
+
+// TODO: This could be refactored to share logic with aw-server/src/dirs.rs
+// TODO: add proper config support
+#[allow(dead_code)]
+pub fn get_config_dir() -> Result<PathBuf, ()> {
+    let mut dir = appdirs::user_config_dir(Some("activitywatch"), None, false)?;
+    dir.push("aw-sync");
+    fs::create_dir_all(dir.clone()).expect("Unable to create config dir");
+    Ok(dir)
+}
+
+pub fn get_server_config_path(testing: bool) -> Result<PathBuf, ()> {
+    let dir = aw_server::dirs::get_config_dir()?;
+    Ok(dir.join(if testing {
+        "config-testing.toml"
+    } else {
+        "config.toml"
+    }))
+}
+
+pub fn get_sync_dir() -> Result<PathBuf, ()> {
+    // TODO: make this configurable
+    home_dir().map(|p| p.join("ActivityWatchSync")).ok_or(())
+}
diff --git a/aw-sync/src/lib.rs b/aw-sync/src/lib.rs
index b107c9bd..8ba7bbd4 100644
--- a/aw-sync/src/lib.rs
+++ b/aw-sync/src/lib.rs
@@ -10,5 +10,12 @@ pub use sync::sync_datastores;
 pub use sync::sync_run;
 pub use sync::SyncSpec;
 
+mod sync_wrapper;
+pub use sync_wrapper::push;
+pub use sync_wrapper::{pull, pull_all};
+
 mod accessmethod;
 pub use accessmethod::AccessMethod;
+
+mod dirs;
+mod util;
diff --git a/aw-sync/src/main.rs b/aw-sync/src/main.rs
index 518e705d..83dacef5 100644
--- a/aw-sync/src/main.rs
+++ b/aw-sync/src/main.rs
@@ -23,9 +23,10 @@ use clap::{Parser, Subcommand};
 use aw_client_rust::blocking::AwClient;
 
 mod accessmethod;
+mod dirs;
 mod sync;
-
-const DEFAULT_PORT: &str = "5600";
+mod sync_wrapper;
+mod util;
 
 #[derive(Parser)]
 #[clap(version = "0.1", author = "Erik Bjäreholt")]
@@ -38,8 +39,8 @@ struct Opts {
     host: String,
 
     /// Port of instance to connect to.
-    #[clap(long, default_value = DEFAULT_PORT)]
-    port: String,
+    #[clap(long)]
+    port: Option<String>,
 
     /// Convenience option for using the default testing host and port.
     #[clap(long)]
@@ -48,42 +49,53 @@ struct Opts {
     /// Enable debug logging.
     #[clap(long)]
     verbose: bool,
-
-    /// Full path to sync directory.
-    /// If not specified, exit.
-    #[clap(long)]
-    sync_dir: String,
-
-    /// Full path to sync db file
-    /// Useful for syncing buckets from a specific db file in the sync directory.
-    /// Must be a valid absolute path to a file in the sync directory.
-    #[clap(long)]
-    sync_db: Option<String>,
 }
 
 #[derive(Subcommand)]
 enum Commands {
-    /// Sync subcommand.
+    /// Sync subcommand (basic)
+    ///
+    /// Pulls remote buckets then pushes local buckets.
+    Sync {
+        /// Host(s) to pull from, comma separated. Will pull from all hosts if not specified.
+        #[clap(long)]
+        host: Option<String>,
+    },
+
+    /// Sync subcommand (advanced)
     ///
     /// Pulls remote buckets then pushes local buckets.
     /// First pulls remote buckets in the sync directory to the local aw-server.
     /// Then pushes local buckets from the aw-server to the local sync directory.
     #[clap(arg_required_else_help = true)]
-    Sync {
+    SyncAdvanced {
         /// Date to start syncing from.
         /// If not specified, start from beginning.
         /// NOTE: might be unstable, as count cannot be used to verify integrity of sync.
         /// Format: YYYY-MM-DD
         #[clap(long)]
         start_date: Option<String>,
+
         /// Specify buckets to sync using a comma-separated list.
         /// If not specified, all buckets will be synced.
         #[clap(long)]
         buckets: Option<String>,
+
         /// Mode to sync in. Can be "push", "pull", or "both".
         /// Defaults to "both".
         #[clap(long, default_value = "both")]
         mode: String,
+
+        /// Full path to sync directory.
+        /// If not specified, exit.
+        #[clap(long)]
+        sync_dir: String,
+
+        /// Full path to sync db file
+        /// Useful for syncing buckets from a specific db file in the sync directory.
+        /// Must be a valid absolute path to a file in the sync directory.
+        #[clap(long)]
+        sync_db: Option<String>,
     },
     /// List buckets and their sync status.
     List {},
@@ -95,35 +107,59 @@ fn main() -> Result<(), Box<dyn Error>> {
 
     info!("Started aw-sync...");
 
-    aw_server::logging::setup_logger(true, verbose).expect("Failed to setup logging");
-
-    let sync_directory = if opts.sync_dir.is_empty() {
-        println!("No sync directory specified, exiting...");
-        std::process::exit(1);
-    } else {
-        Path::new(&opts.sync_dir)
-    };
-    info!("Using sync dir: {}", sync_directory.display());
-
-    if let Some(sync_db) = &opts.sync_db {
-        info!("Using sync db: {}", sync_db);
-    }
+    aw_server::logging::setup_logger("aw-sync", opts.testing, verbose)
+        .expect("Failed to setup logging");
 
-    let port = if opts.testing && opts.port == DEFAULT_PORT {
-        "5666"
-    } else {
-        &opts.port
-    };
+    let port = opts
+        .port
+        .or_else(|| Some(crate::util::get_server_port(opts.testing).ok()?.to_string()))
+        .unwrap();
 
-    let client = AwClient::new(opts.host.as_str(), port, "aw-sync");
+    let client = AwClient::new(opts.host.as_str(), port.as_str(), "aw-sync");
 
     match &opts.command {
+        // Perform basic sync
+        Commands::Sync { host } => {
+            // Pull
+            match host {
+                Some(host) => {
+                    let hosts: Vec<&str> = host.split(',').collect();
+                    for host in hosts.iter() {
+                        info!("Pulling from host: {}", host);
+                        sync_wrapper::pull(host, &client)?;
+                    }
+                }
+                None => {
+                    info!("Pulling from all hosts");
+                    sync_wrapper::pull_all(&client)?;
+                }
+            }
+
+            // Push
+            info!("Pushing local data");
+            sync_wrapper::push(&client)?;
+            Ok(())
+        }
         // Perform two-way sync
-        Commands::Sync {
+        Commands::SyncAdvanced {
             start_date,
             buckets,
             mode,
+            sync_dir,
+            sync_db,
         } => {
+            let sync_directory = if sync_dir.is_empty() {
+                error!("No sync directory specified, exiting...");
+                std::process::exit(1);
+            } else {
+                Path::new(&sync_dir)
+            };
+            info!("Using sync dir: {}", sync_directory.display());
+
+            if let Some(sync_db) = &sync_db {
+                info!("Using sync db: {}", sync_db);
+            }
+
             let start: Option<DateTime<Utc>> = start_date.as_ref().map(|date| {
                 println!("{}", date.clone());
                 chrono::NaiveDate::parse_from_str(&date.clone(), "%Y-%m-%d")
@@ -140,7 +176,7 @@ fn main() -> Result<(), Box<dyn Error>> {
                 .as_ref()
                 .map(|b| b.split(',').map(|s| s.to_string()).collect());
 
-            let sync_db: Option<PathBuf> = opts.sync_db.as_ref().map(|db| {
+            let sync_db: Option<PathBuf> = sync_db.as_ref().map(|db| {
                 let db_path = Path::new(db);
                 if !db_path.is_absolute() {
                     panic!("Sync db path must be absolute");
@@ -165,11 +201,11 @@ fn main() -> Result<(), Box<dyn Error>> {
                 _ => panic!("Invalid mode"),
             };
 
-            sync::sync_run(client, &sync_spec, mode_enum)
+            sync::sync_run(&client, &sync_spec, mode_enum)
         }
 
         // List all buckets
-        Commands::List {} => sync::list_buckets(&client, sync_directory),
+        Commands::List {} => sync::list_buckets(&client),
     }?;
 
     // Needed to give the datastores some time to commit before program is shut down.
diff --git a/aw-sync/src/sync.rs b/aw-sync/src/sync.rs
index 8c4cfbb6..c842f5a6 100644
--- a/aw-sync/src/sync.rs
+++ b/aw-sync/src/sync.rs
@@ -9,7 +9,6 @@ extern crate chrono;
 extern crate reqwest;
 extern crate serde_json;
 
-use std::ffi::OsStr;
 use std::fs;
 use std::path::{Path, PathBuf};
 
@@ -28,6 +27,7 @@ pub enum SyncMode {
     Both,
 }
 
+#[derive(Debug)]
 pub struct SyncSpec {
     /// Path of sync folder
     pub path: PathBuf,
@@ -54,7 +54,7 @@ impl Default for SyncSpec {
 }
 
 /// Performs a single sync pass
-pub fn sync_run(client: AwClient, sync_spec: &SyncSpec, mode: SyncMode) -> Result<(), String> {
+pub fn sync_run(client: &AwClient, sync_spec: &SyncSpec, mode: SyncMode) -> Result<(), String> {
     let info = client.get_info().map_err(|e| e.to_string())?;
 
     // FIXME: Here it is assumed that the device_id for the local server is the one used by
@@ -64,7 +64,7 @@ pub fn sync_run(client: AwClient, sync_spec: &SyncSpec, mode: SyncMode) -> Resul
 
     // FIXME: Bad device_id assumption?
     let ds_localremote = setup_local_remote(sync_spec.path.as_path(), device_id)?;
-    let remote_dbfiles = find_remotes_nonlocal(
+    let remote_dbfiles = crate::util::find_remotes_nonlocal(
         sync_spec.path.as_path(),
         device_id,
         sync_spec.path_db.as_ref(),
@@ -99,14 +99,14 @@ pub fn sync_run(client: AwClient, sync_spec: &SyncSpec, mode: SyncMode) -> Resul
     if mode == SyncMode::Pull || mode == SyncMode::Both {
         info!("Pulling...");
         for ds_from in &ds_remotes {
-            sync_datastores(ds_from, &client, false, None, sync_spec);
+            sync_datastores(ds_from, client, false, None, sync_spec);
         }
     }
 
     // Push local server buckets to sync folder
     if mode == SyncMode::Push || mode == SyncMode::Both {
         info!("Pushing...");
-        sync_datastores(&client, &ds_localremote, true, Some(device_id), sync_spec);
+        sync_datastores(client, &ds_localremote, true, Some(device_id), sync_spec);
     }
 
     // Close open database connections
@@ -128,14 +128,16 @@ pub fn sync_run(client: AwClient, sync_spec: &SyncSpec, mode: SyncMode) -> Resul
 }
 
 #[allow(dead_code)]
-pub fn list_buckets(client: &AwClient, sync_directory: &Path) -> Result<(), String> {
+pub fn list_buckets(client: &AwClient) -> Result<(), String> {
+    let sync_directory = crate::dirs::get_sync_dir().map_err(|_| "Could not get sync dir")?;
+    let sync_directory = sync_directory.as_path();
     let info = client.get_info().map_err(|e| e.to_string())?;
 
     // FIXME: Incorrect device_id assumption?
     let device_id = info.device_id.as_str();
     let ds_localremote = setup_local_remote(sync_directory, device_id)?;
 
-    let remote_dbfiles = find_remotes_nonlocal(sync_directory, device_id, None);
+    let remote_dbfiles = crate::util::find_remotes_nonlocal(sync_directory, device_id, None);
     info!("Found remotes: {:?}", remote_dbfiles);
 
     // TODO: Check for compatible remote db version before opening
@@ -172,47 +174,6 @@ fn setup_local_remote(path: &Path, device_id: &str) -> Result<Datastore, String>
     Ok(ds_localremote)
 }
 
-/// Returns a list of all remote dbs
-fn find_remotes(sync_directory: &Path) -> std::io::Result<Vec<PathBuf>> {
-    let dbs = fs::read_dir(sync_directory)?
-        .map(|res| res.ok().unwrap().path())
-        .filter(|p| p.is_dir())
-        .flat_map(|d| fs::read_dir(d).unwrap())
-        .map(|res| res.ok().unwrap().path())
-        .filter(|path| path.extension().unwrap_or_else(|| OsStr::new("")) == "db")
-        .collect();
-    Ok(dbs)
-}
-
-/// Returns a list of all remotes, excluding local ones
-fn find_remotes_nonlocal(
-    sync_directory: &Path,
-    device_id: &str,
-    sync_db: Option<&PathBuf>,
-) -> Vec<PathBuf> {
-    let remotes_all = find_remotes(sync_directory).unwrap();
-    remotes_all
-        .into_iter()
-        // Filter out own remote
-        .filter(|path| {
-            !(path
-                .clone()
-                .into_os_string()
-                .into_string()
-                .unwrap()
-                .contains(device_id))
-        })
-        // If sync_db is Some, return only remotes in that path
-        .filter(|path| {
-            if let Some(sync_db) = sync_db {
-                path.starts_with(sync_db)
-            } else {
-                true
-            }
-        })
-        .collect()
-}
-
 pub fn create_datastore(path: &Path) -> Datastore {
     let pathstr = path.as_os_str().to_str().unwrap();
     Datastore::new(pathstr.to_string(), false)
diff --git a/aw-sync/src/sync_wrapper.rs b/aw-sync/src/sync_wrapper.rs
new file mode 100644
index 00000000..11daae50
--- /dev/null
+++ b/aw-sync/src/sync_wrapper.rs
@@ -0,0 +1,102 @@
+use std::boxed::Box;
+use std::error::Error;
+use std::fs;
+use std::net::TcpStream;
+
+use crate::sync::{sync_run, SyncMode, SyncSpec};
+use aw_client_rust::blocking::AwClient;
+
+pub fn pull_all(client: &AwClient) -> Result<(), Box<dyn Error>> {
+    let hostnames = crate::util::get_remotes()?;
+    for host in hostnames {
+        pull(&host, client)?
+    }
+    Ok(())
+}
+
+pub fn pull(host: &str, client: &AwClient) -> Result<(), Box<dyn Error>> {
+    // Check if server is running
+    let parts: Vec<&str> = client.baseurl.split("://").collect();
+    let host_parts: Vec<&str> = parts[1].split(':').collect();
+    let addr = host_parts[0];
+    let port = host_parts[1].parse::<u16>().unwrap();
+
+    if TcpStream::connect((addr, port)).is_err() {
+        return Err(format!("Local server {} not running", &client.baseurl).into());
+    }
+
+    // Path to the sync folder
+    // Sync folder is structured ./{hostname}/{device_id}/test.db
+    let sync_root_dir = crate::dirs::get_sync_dir().map_err(|_| "Could not get sync dir")?;
+    let sync_dir = sync_root_dir.join(host);
+    let dbs = fs::read_dir(&sync_dir)?
+        .filter_map(Result::ok)
+        .filter(|entry| entry.path().is_dir())
+        .map(|entry| fs::read_dir(entry.path()))
+        .filter_map(Result::ok)
+        .flatten()
+        .filter_map(Result::ok)
+        .filter(|entry| {
+            entry.path().is_file()
+                && entry.path().extension().and_then(|os_str| os_str.to_str()) == Some("db")
+        })
+        .collect::<Vec<_>>();
+
+    // filter out dbs that are smaller than 50kB (workaround for trying to sync empty database
+    // files that are spuriously created somewhere)
+    let mut dbs = dbs
+        .into_iter()
+        .filter(|entry| entry.metadata().map(|m| m.len() > 50_000).unwrap_or(false))
+        .collect::<Vec<_>>();
+
+    // if more than one db, warn and use the largest one
+    if dbs.len() > 1 {
+        warn!(
+            "More than one db found in sync folder for host, choosing largest db {:?}",
+            dbs
+        );
+        dbs = vec![dbs
+            .into_iter()
+            .max_by_key(|entry| entry.metadata().map(|m| m.len()).unwrap_or(0))
+            .unwrap()];
+    }
+    // if no db, error
+    if dbs.is_empty() {
+        return Err(format!("No db found in sync folder {:?}", sync_dir).into());
+    }
+
+    for db in dbs {
+        let sync_spec = SyncSpec {
+            path: sync_dir.clone(),
+            path_db: Some(db.path().clone()),
+            buckets: Some(vec![
+                format!("aw-watcher-window_{}", host),
+                format!("aw-watcher-afk_{}", host),
+            ]),
+            start: None,
+        };
+        sync_run(client, &sync_spec, SyncMode::Pull)?;
+    }
+
+    Ok(())
+}
+
+pub fn push(client: &AwClient) -> Result<(), Box<dyn Error>> {
+    let hostname = crate::util::get_hostname()?;
+    let sync_dir = crate::dirs::get_sync_dir()
+        .map_err(|_| "Could not get sync dir")?
+        .join(&hostname);
+
+    let sync_spec = SyncSpec {
+        path: sync_dir,
+        path_db: None,
+        buckets: Some(vec![
+            format!("aw-watcher-window_{}", hostname),
+            format!("aw-watcher-afk_{}", hostname),
+        ]),
+        start: None,
+    };
+    sync_run(client, &sync_spec, SyncMode::Push)?;
+
+    Ok(())
+}
diff --git a/aw-sync/src/util.rs b/aw-sync/src/util.rs
new file mode 100644
index 00000000..f2a38f0e
--- /dev/null
+++ b/aw-sync/src/util.rs
@@ -0,0 +1,124 @@
+use std::boxed::Box;
+use std::error::Error;
+use std::ffi::OsStr;
+use std::fs;
+use std::fs::File;
+use std::io::Read;
+use std::path::{Path, PathBuf};
+
+pub fn get_hostname() -> Result<String, Box<dyn Error>> {
+    let hostname = gethostname::gethostname()
+        .into_string()
+        .map_err(|_| "Failed to convert hostname to string")?;
+    Ok(hostname)
+}
+
+/// Returns the port of the local aw-server instance
+pub fn get_server_port(testing: bool) -> Result<u16, Box<dyn Error>> {
+    // TODO: get aw-server config more reliably
+    let aw_server_conf = crate::dirs::get_server_config_path(testing)
+        .map_err(|_| "Could not get aw-server config path")?;
+    let fallback: u16 = if testing { 5666 } else { 5600 };
+    let port = if aw_server_conf.exists() {
+        let mut file = File::open(&aw_server_conf)?;
+        let mut contents = String::new();
+        file.read_to_string(&mut contents)?;
+        let value: toml::Value = toml::from_str(&contents)?;
+        value
+            .get("port")
+            .and_then(|v| v.as_integer())
+            .map(|v| v as u16)
+            .unwrap_or(fallback)
+    } else {
+        fallback
+    };
+    Ok(port)
+}
+
+/// Check if a directory contains a .db file
+fn contains_db_file(dir: &std::path::Path) -> bool {
+    fs::read_dir(dir)
+        .ok()
+        .map(|entries| {
+            entries.filter_map(Result::ok).any(|entry| {
+                entry
+                    .path()
+                    .extension()
+                    .map(|ext| ext == "db")
+                    .unwrap_or(false)
+            })
+        })
+        .unwrap_or(false)
+}
+
+/// Check if a directory contains a subdirectory that contains a .db file
+fn contains_subdir_with_db_file(dir: &std::path::Path) -> bool {
+    fs::read_dir(dir)
+        .ok()
+        .map(|entries| {
+            entries
+                .filter_map(Result::ok)
+                .any(|entry| entry.path().is_dir() && contains_db_file(&entry.path()))
+        })
+        .unwrap_or(false)
+}
+
+/// Return all remotes in the sync folder
+/// Only returns folders that match ./{host}/{device_id}/*.db
+// TODO: share logic with find_remotes and find_remotes_nonlocal
+pub fn get_remotes() -> Result<Vec<String>, Box<dyn Error>> {
+    let sync_root_dir = crate::dirs::get_sync_dir().map_err(|_| "Could not get sync dir")?;
+    let hostnames = fs::read_dir(sync_root_dir)?
+        .filter_map(Result::ok)
+        .filter(|entry| entry.path().is_dir() && contains_subdir_with_db_file(&entry.path()))
+        .filter_map(|entry| {
+            entry
+                .path()
+                .file_name()
+                .and_then(|os_str| os_str.to_str().map(String::from))
+        })
+        .collect();
+    info!("Found remotes: {:?}", hostnames);
+    Ok(hostnames)
+}
+
+/// Returns a list of all remote dbs
+fn find_remotes(sync_directory: &Path) -> std::io::Result<Vec<PathBuf>> {
+    let dbs = fs::read_dir(sync_directory)?
+        .map(|res| res.ok().unwrap().path())
+        .filter(|p| p.is_dir())
+        .flat_map(|d| fs::read_dir(d).unwrap())
+        .map(|res| res.ok().unwrap().path())
+        .filter(|path| path.extension().unwrap_or_else(|| OsStr::new("")) == "db")
+        .collect();
+    Ok(dbs)
+}
+
+/// Returns a list of all remotes, excluding local ones
+pub fn find_remotes_nonlocal(
+    sync_directory: &Path,
+    device_id: &str,
+    sync_db: Option<&PathBuf>,
+) -> Vec<PathBuf> {
+    let remotes_all = find_remotes(sync_directory).unwrap();
+    remotes_all
+        .into_iter()
+        // Filter out own remote
+        .filter(|path| {
+            !(path
+                .clone()
+                .into_os_string()
+                .into_string()
+                .unwrap()
+                .contains(device_id))
+        })
+        // If sync_db is Some, return only remotes in that path
+        .filter(|path| {
+            if let Some(sync_db) = sync_db {
+                path.starts_with(sync_db)
+            } else {
+                true
+            }
+        })
+        .collect()
+}
diff --git a/aw-sync/test-sync-pull.sh b/aw-sync/test-sync-pull.sh
index 89f4235a..851f8216 100755
--- a/aw-sync/test-sync-pull.sh
+++ b/aw-sync/test-sync-pull.sh
@@ -34,11 +34,12 @@ function sync_host() {
             continue
         fi
 
-        AWSYNCPARAMS="--port $PORT --sync-dir $SYNCDIR --sync-db $db"
+        AWSYNC_ARGS="--port $PORT"
+        AWSYNC_ARGS_ADV="--sync-dir $SYNCDIR --sync-db $db"
         BUCKETS="aw-watcher-window_$host,aw-watcher-afk_$host"
 
         echo "Syncing $db to $host"
-        cargo run --bin aw-sync -- $AWSYNCPARAMS sync --mode pull --buckets $BUCKETS
+        cargo run --bin aw-sync -- $AWSYNC_ARGS sync-advanced $AWSYNC_ARGS_ADV --mode pull --buckets $BUCKETS
         # TODO: If there are no buckets from the expected host, emit a warning at the end.
         #       (push-script should not have created them to begin with)
     done
diff --git a/aw-sync/test-sync-push.sh b/aw-sync/test-sync-push.sh
index 58492669..3ba4b179 100755
--- a/aw-sync/test-sync-push.sh
+++ b/aw-sync/test-sync-push.sh
@@ -25,7 +25,8 @@ else
 fi
 
 SYNCDIR="$HOME/ActivityWatchSync/$HOSTNAME"
-AWSYNCPARAMS="--port $PORT --sync-dir $SYNCDIR"
+AWSYNC_ARGS="--port $PORT"
+AWSYNC_ARGS_ADV="--sync-dir $SYNCDIR"
 
 # NOTE: Only sync window and AFK buckets, for now
-cargo run --bin aw-sync --release -- $AWSYNCPARAMS sync --mode push --buckets aw-watcher-window_$HOSTNAME,aw-watcher-afk_$HOSTNAME
+cargo run --bin aw-sync --release -- $AWSYNC_ARGS sync-advanced $AWSYNC_ARGS_ADV --mode push --buckets aw-watcher-window_$HOSTNAME,aw-watcher-afk_$HOSTNAME