diff --git a/docs/superpowers/plans/2026-06-02-config-sync.md b/docs/superpowers/plans/2026-06-02-config-sync.md new file mode 100644 index 00000000..4fb39bf9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-config-sync.md @@ -0,0 +1,2029 @@ +# Config Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add cross-device configuration synchronization via S3/WebDAV with end-to-end encryption, supporting both automatic and manual sync modes. + +**Architecture:** Snapshot-based sync — export all config data (connections, queries, AI providers, settings) to a JSON file, encrypt with AES-256-GCM (key derived from user password via PBKDF2), and upload to S3 or WebDAV. A `SyncProvider` trait abstracts the storage backend. `SyncManager` orchestrates export/import/change-detection/auto-sync-timer. + +**Tech Stack:** Rust (sha2, hmac, pbkdf2, aes-gcm, reqwest), TypeScript/React (existing Tauri + Radix UI patterns) + +**Spec:** `docs/superpowers/specs/2026-06-02-config-sync-design.md` + +--- + +## File Structure + +### New Files (Backend — Rust) +| File | Responsibility | +|------|---------------| +| `src-tauri/src/sync/mod.rs` | Module re-exports | +| `src-tauri/src/sync/provider.rs` | `SyncProvider` trait + config types | +| `src-tauri/src/sync/crypto.rs` | PBKDF2 key derivation + AES-256-GCM encrypt/decrypt + snapshot hashing | +| `src-tauri/src/sync/manager.rs` | `SyncManager` — export/import/merge/auto-sync/change detection | +| `src-tauri/src/sync/s3.rs` | S3 `SyncProvider` implementation (reqwest + AWS Sig V4) | +| `src-tauri/src/sync/webdav.rs` | WebDAV `SyncProvider` implementation (reqwest) | +| `src-tauri/src/commands/sync.rs` | Tauri command handlers for sync operations | +| `src-tauri/migrations/017_sync_state.sql` | Migration for `sync_state` table | + +### New Files (Frontend — TypeScript/React) +| File | Responsibility | +|------|---------------| +| `src/components/settings/SyncSettings.tsx` | Sync settings panel component | + +### Modified Files +| File | Change | +|------|--------| +| `src-tauri/Cargo.toml` | Add sha2, hmac, pbkdf2 dependencies | +| `src-tauri/src/lib.rs` | Register sync module + commands, wire auto-sync lifecycle | +| `src-tauri/src/state.rs` | Add `sync_manager` field to `AppState` | +| `src-tauri/src/db/local.rs` | Add `sync_state` CRUD methods + migration | +| `src-tauri/src/commands/mod.rs` | Add `sync` module | +| `src/services/api.ts` | Add `syncApi` namespace | +| `src/components/settings/SettingsDialog.tsx` | Add "Sync" tab | + +--- + +## Task 1: Add Dependencies and Migration + +**Files:** +- Modify: `src-tauri/Cargo.toml` +- Create: `src-tauri/migrations/017_sync_state.sql` +- Modify: `src-tauri/src/db/local.rs` + +- [ ] **Step 1: Add Rust dependencies** + +Add to `src-tauri/Cargo.toml` under `[dependencies]`: + +```toml +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = "0.12" +``` + +- [ ] **Step 2: Create sync_state migration** + +Create `src-tauri/migrations/017_sync_state.sql`: + +```sql +CREATE TABLE IF NOT EXISTS sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +- [ ] **Step 3: Add migration to LocalDb::init_with_app_dir** + +In `src-tauri/src/db/local.rs`, add after the migration 016 block (after `if !has_redis_command_logs { ... }`): + +```rust + let has_sync_state: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='sync_state')", + ) + .fetch_one(&pool) + .await + .map_err(|e| format!("[MIGRATION_017_CHECK_ERROR] {e}"))?; + + if !has_sync_state { + sqlx::query(include_str!("../../migrations/017_sync_state.sql")) + .execute(&pool) + .await + .map_err(|e| format!("[MIGRATION_017_ERROR] {e}"))?; + } +``` + +Also add the migration string to the `make_test_db` function's migration array in the `#[cfg(test)]` module: + +```rust + include_str!("../../migrations/017_sync_state.sql"), +``` + +- [ ] **Step 4: Add sync_state CRUD methods to LocalDb** + +In `src-tauri/src/db/local.rs`, add these methods to the `impl LocalDb` block (after the `list_redis_command_logs` method): + +```rust + pub async fn get_sync_state(&self, key: &str) -> Result, String> { + let row = sqlx::query_as::<_, (String,)>( + "SELECT value FROM sync_state WHERE key = ?", + ) + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|e| format!("[GET_SYNC_STATE_ERROR] {e}"))?; + + Ok(row.map(|(v,)| v)) + } + + pub async fn set_sync_state(&self, key: &str, value: &str) -> Result<(), String> { + sqlx::query( + "INSERT INTO sync_state (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .execute(&self.pool) + .await + .map_err(|e| format!("[SET_SYNC_STATE_ERROR] {e}"))?; + Ok(()) + } + + pub async fn delete_sync_state(&self, key: &str) -> Result<(), String> { + sqlx::query("DELETE FROM sync_state WHERE key = ?") + .bind(key) + .execute(&self.pool) + .await + .map_err(|e| format!("[DELETE_SYNC_STATE_ERROR] {e}"))?; + Ok(()) + } +``` + +- [ ] **Step 5: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS (compiles with new deps + migration + new methods) + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/migrations/017_sync_state.sql src-tauri/src/db/local.rs +git commit -m "feat(sync): add sync_state migration and LocalDb CRUD methods" +``` + +--- + +## Task 2: SyncProvider Trait and Config Types + +**Files:** +- Create: `src-tauri/src/sync/mod.rs` +- Create: `src-tauri/src/sync/provider.rs` + +- [ ] **Step 1: Create sync module entry** + +Create `src-tauri/src/sync/mod.rs`: + +```rust +pub mod crypto; +pub mod manager; +pub mod provider; +pub mod s3; +pub mod webdav; +``` + +- [ ] **Step 2: Create provider trait and config types** + +Create `src-tauri/src/sync/provider.rs`: + +```rust +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum ProviderType { + S3, + WebDAV, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncConfig { + pub provider_type: ProviderType, + // S3 fields + pub endpoint: Option, + pub region: Option, + pub bucket: Option, + pub access_key_id: Option, + pub secret_access_key: Option, + pub path_prefix: Option, + // WebDAV fields + pub server_url: Option, + pub username: Option, + pub password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncStatus { + pub enabled: bool, + pub provider_type: Option, + pub endpoint: Option, + pub last_sync_at: Option, + pub last_sync_result: Option, + pub device_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResult { + pub action: String, + pub timestamp: String, + pub remote_device_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncSnapshot { + pub version: u32, + pub device_id: String, + pub timestamp: String, + pub snapshot_hash: String, + pub data: SyncSnapshotData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SyncSnapshotData { + pub connections: Vec, + pub saved_queries: Vec, + pub ai_providers: Vec, + pub settings: serde_json::Value, +} + +#[async_trait] +pub trait SyncProvider: Send + Sync { + async fn test_connection(&self) -> Result<(), String>; + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String>; + async fn get_object(&self, key: &str) -> Result>, String>; + async fn delete_object(&self, key: &str) -> Result<(), String>; +} + +/// Build a `SyncProvider` from a `SyncConfig`. +pub fn build_provider(config: &SyncConfig) -> Result, String> { + match config.provider_type { + ProviderType::S3 => { + let endpoint = config.endpoint.as_deref().unwrap_or("").trim(); + let region = config.region.as_deref().unwrap_or("").trim(); + let bucket = config.bucket.as_deref().unwrap_or("").trim(); + let access_key_id = config.access_key_id.as_deref().unwrap_or("").trim(); + let secret_access_key = config.secret_access_key.as_deref().unwrap_or("").trim(); + let path_prefix = config.path_prefix.as_deref().unwrap_or("dbpaw/").trim(); + + if endpoint.is_empty() || bucket.is_empty() || access_key_id.is_empty() || secret_access_key.is_empty() { + return Err("[SYNC_CONFIG_ERROR] S3 endpoint, bucket, accessKeyId and secretAccessKey are required".to_string()); + } + + Ok(Box::new(crate::sync::s3::S3Provider::new( + endpoint.to_string(), + region.to_string(), + bucket.to_string(), + access_key_id.to_string(), + secret_access_key.to_string(), + path_prefix.to_string(), + ))) + } + ProviderType::WebDAV => { + let server_url = config.server_url.as_deref().unwrap_or("").trim(); + let username = config.username.as_deref().unwrap_or("").trim(); + let password = config.password.as_deref().unwrap_or("").trim(); + + if server_url.is_empty() || username.is_empty() || password.is_empty() { + return Err("[SYNC_CONFIG_ERROR] WebDAV serverUrl, username and password are required".to_string()); + } + + Ok(Box::new(crate::sync::webdav::WebdavProvider::new( + server_url.to_string(), + username.to_string(), + password.to_string(), + ))) + } + } +} +``` + +- [ ] **Step 3: Register sync module in lib.rs** + +In `src-tauri/src/lib.rs`, add after `pub mod ssh;`: + +```rust +pub mod sync; +``` + +- [ ] **Step 4: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: FAIL — `crypto`, `manager`, `s3`, `webdav` modules not yet created. This is expected; the module files will be created in subsequent tasks. Temporarily comment out the module references in `sync/mod.rs` to verify the trait compiles: + +```rust +// pub mod crypto; +// pub mod manager; +pub mod provider; +// pub mod s3; +// pub mod webdav; +``` + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/sync/mod.rs src-tauri/src/sync/provider.rs src-tauri/src/lib.rs +git commit -m "feat(sync): add SyncProvider trait and config types" +``` + +--- + +## Task 3: Crypto Engine + +**Files:** +- Create: `src-tauri/src/sync/crypto.rs` +- Modify: `src-tauri/src/sync/mod.rs` (uncomment crypto module) + +- [ ] **Step 1: Create crypto module** + +Create `src-tauri/src/sync/crypto.rs`: + +```rust +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::{engine::general_purpose, Engine as _}; +use pbkdf2::pbkdf2_hmac; +use rand::RngCore; +use sha2::Sha256; + +const PBKDF2_ITERATIONS: u32 = 600_000; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; + +/// Derive a 32-byte AES key from a user password and salt using PBKDF2-SHA256. +fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { + let mut key = [0u8; 32]; + pbkdf2_hmac::(password.as_bytes(), salt, PBKDF2_ITERATIONS, &mut key); + key +} + +/// Compute SHA-256 hash of the given data, returned as hex string. +pub fn snapshot_hash(data: &[u8]) -> String { + use sha2::Digest; + let mut hasher = sha2::Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +/// Encrypt plaintext bytes with a user password. +/// Format: [16 bytes salt][12 bytes nonce][ciphertext + GCM tag] +pub fn encrypt(password: &str, plaintext: &[u8]) -> Result, String> { + let mut salt = [0u8; SALT_LEN]; + rand::rng().fill_bytes(&mut salt); + + let key = derive_key(password, &salt); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Encryption failed: {e}"))?; + + let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); + output.extend_from_slice(&salt); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +/// Decrypt data encrypted by `encrypt`. Returns plaintext bytes. +pub fn decrypt(password: &str, data: &[u8]) -> Result, String> { + if data.len() < SALT_LEN + NONCE_LEN + 16 { + return Err("[SYNC_CRYPTO_ERROR] Data too short".to_string()); + } + + let (salt, rest) = data.split_at(SALT_LEN); + let (nonce_bytes, ciphertext) = rest.split_at(NONCE_LEN); + + let key = derive_key(password, salt); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + + let nonce = Nonce::from_slice(nonce_bytes); + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("[SYNC_PASSWORD_ERROR] Decryption failed (wrong password?): {e}")) +} + +/// Encrypt a string value for local storage using the given key material. +/// Used for encrypting provider credentials before saving to sync_state. +/// Reuses the same pattern as LocalDb AI key encryption. +pub fn encrypt_with_key(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Encryption failed: {e}"))?; + + let mut payload = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + payload.extend_from_slice(&nonce_bytes); + payload.extend_from_slice(&ciphertext); + Ok(format!("enc:sync:{}", general_purpose::STANDARD.encode(payload))) +} + +/// Decrypt a string value that was encrypted with `encrypt_with_key`. +pub fn decrypt_with_key(key: &[u8; 32], encrypted: &str) -> Result { + let prefix = "enc:sync:"; + if !encrypted.starts_with(prefix) { + return Err("[SYNC_CRYPTO_ERROR] Invalid encrypted format".to_string()); + } + let b64 = &encrypted[prefix.len()..]; + let payload = general_purpose::STANDARD + .decode(b64) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Base64 decode: {e}"))?; + if payload.len() < NONCE_LEN + 16 { + return Err("[SYNC_CRYPTO_ERROR] Payload too short".to_string()); + } + let (nonce_bytes, ciphertext) = payload.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Decryption failed: {e}"))?; + String::from_utf8(plaintext).map_err(|e| format!("[SYNC_CRYPTO_ERROR] UTF-8: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_round_trip() { + let password = "test-sync-password-123"; + let plaintext = br#"{"version":1,"data":{"connections":[]}}"#; + let encrypted = encrypt(password, plaintext).unwrap(); + let decrypted = decrypt(password, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_with_wrong_password_fails() { + let encrypted = encrypt("correct-password", b"secret data").unwrap(); + let result = decrypt("wrong-password", &encrypted); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("[SYNC_PASSWORD_ERROR]")); + } + + #[test] + fn snapshot_hash_is_deterministic() { + let data = b"hello world"; + let h1 = snapshot_hash(data); + let h2 = snapshot_hash(data); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); // SHA-256 hex + } + + #[test] + fn encrypt_decrypt_with_key_round_trip() { + let mut key = [0u8; 32]; + rand::rng().fill_bytes(&mut key); + let encrypted = encrypt_with_key(&key, "secret-value").unwrap(); + let decrypted = decrypt_with_key(&key, &encrypted).unwrap(); + assert_eq!(decrypted, "secret-value"); + } +} +``` + +- [ ] **Step 2: Uncomment crypto module** + +In `src-tauri/src/sync/mod.rs`, uncomment the crypto line: + +```rust +pub mod crypto; +// pub mod manager; +pub mod provider; +// pub mod s3; +// pub mod webdav; +``` + +- [ ] **Step 3: Run cargo check + tests** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +Run: `cargo test --manifest-path src-tauri/Cargo.toml --lib -- sync::crypto` +Expected: 4 tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/sync/crypto.rs src-tauri/src/sync/mod.rs +git commit -m "feat(sync): add crypto engine with PBKDF2 + AES-256-GCM" +``` + +--- + +## Task 4: S3 Provider + +**Files:** +- Create: `src-tauri/src/sync/s3.rs` +- Modify: `src-tauri/src/sync/mod.rs` (uncomment s3 module) + +- [ ] **Step 1: Create S3 provider** + +Create `src-tauri/src/sync/s3.rs`: + +```rust +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +pub struct S3Provider { + endpoint: String, + region: String, + bucket: String, + access_key_id: String, + secret_access_key: String, + path_prefix: String, + client: Client, +} + +impl S3Provider { + pub fn new( + endpoint: String, + region: String, + bucket: String, + access_key_id: String, + secret_access_key: String, + path_prefix: String, + ) -> Self { + Self { + endpoint: endpoint.trim_end_matches('/').to_string(), + region: if region.is_empty() { "us-east-1".to_string() } else { region }, + bucket, + access_key_id, + secret_access_key, + path_prefix: if path_prefix.is_empty() { "dbpaw/".to_string() } else { path_prefix }, + client: Client::new(), + } + } + + fn object_url(&self, key: &str) -> String { + format!("{}/{}/{}{}", self.endpoint, self.bucket, self.path_prefix, key) + } + + /// Generate AWS Signature V4 for a request. + fn sign_request( + &self, + method: &str, + url: &url::Url, + headers: &mut vec1::Vec1<(String, String)>, + payload_hash: &str, + date: &str, + datetime: &str, + ) { + let host = url.host_str().unwrap_or(""); + let path = url.path(); + let query = url.query().unwrap_or(""); + + // Canonical headers must be sorted + headers.push(("host".to_string(), host.to_string())); + headers.push(("x-amz-content-sha256".to_string(), payload_hash.to_string())); + headers.push(("x-amz-date".to_string(), datetime.to_string())); + headers.sort_by(|a, b| a.0.cmp(&b.0)); + + let signed_headers: String = headers.iter().map(|(k, _)| k.as_str()).collect::>().join(";"); + let canonical_headers: String = headers.iter().map(|(k, v)| format!("{}:{}", k.to_lowercase(), v.trim())).collect::>().join("\n"); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n\n{}\n{}", + method, path, query, canonical_headers, signed_headers, payload_hash + ); + + let credential_scope = format!("{}/{}/s3/aws4_request", self.region, date); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + datetime, + credential_scope, + hex::encode(sha256(canonical_request.as_bytes())) + ); + + let signing_key = self.derive_signing_key(date); + let signature = hmac_sha256_hex(&signing_key, string_to_sign.as_bytes()); + + headers.push(( + "Authorization".to_string(), + format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key_id, credential_scope, signed_headers, signature + ), + )); + } + + fn derive_signing_key(&self, date: &str) -> Vec { + let k_date = hmac_sha256_bytes(format!("AWS4{}", self.secret_access_key).as_bytes(), date.as_bytes()); + let k_region = hmac_sha256_bytes(&k_date, self.region.as_bytes()); + let k_service = hmac_sha256_bytes(&k_region, b"s3"); + hmac_sha256_bytes(&k_service, b"aws4_request") + } + + fn now_timestamps() -> (String, String) { + let now = chrono::Utc::now(); + let date = now.format("%Y%m%d").to_string(); + let datetime = now.format("%Y%m%dT%H%M%SZ").to_string(); + (date, datetime) + } +} + +fn sha256(data: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().to_vec() +} + +fn hex_encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{:02x}", b)).collect() +} + +// Wrapper to avoid name collision with the sha256 function above +fn hex_sha256(data: &[u8]) -> String { + hex_encode(&sha256(data)) +} + +fn hmac_sha256_bytes(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key length is valid"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn hmac_sha256_hex(key: &[u8], data: &[u8]) -> String { + hex_encode(&hmac_sha256_bytes(key, data)) +} + +#[async_trait] +impl SyncProvider for S3Provider { + async fn test_connection(&self) -> Result<(), String> { + let url: url::Url = format!("{}/{}/", self.endpoint, self.bucket) + .parse() + .map_err(|e: url::ParseError| format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}"))?; + + let empty_payload_hash = hex_sha256(b""); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec1::vec1![("Content-Type".to_string(), "application/octet-stream".to_string())]; + self.sign_request("GET", &url, &mut headers, &empty_payload_hash, &date, &datetime); + + let mut req = self.client.get(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req.send().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + let status = resp.status(); + if status.is_success() || status.as_u16() == 200 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] S3 returned {}: {}", status, body.chars().take(200).collect::())) + } + } + + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String> { + let url: url::Url = self.object_url(key) + .parse() + .map_err(|e: url::ParseError| format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}"))?; + + let payload_hash = hex_sha256(data); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec1::vec1![("Content-Type".to_string(), "application/octet-stream".to_string())]; + self.sign_request("PUT", &url, &mut headers, &payload_hash, &date, &datetime); + + let mut req = self.client.put(url.as_str()).body(data.to_vec()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req.send().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + if resp.status().is_success() { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] S3 PUT failed {}: {}", resp.status(), body.chars().take(200).collect::())) + } + } + + async fn get_object(&self, key: &str) -> Result>, String> { + let url: url::Url = self.object_url(key) + .parse() + .map_err(|e: url::ParseError| format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}"))?; + + let empty_payload_hash = hex_sha256(b""); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec1::vec1![("Content-Type".to_string(), "application/octet-stream".to_string())]; + self.sign_request("GET", &url, &mut headers, &empty_payload_hash, &date, &datetime); + + let mut req = self.client.get(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req.send().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + if resp.status().as_u16() == 404 { + return Ok(None); + } + if resp.status().is_success() { + let bytes = resp.bytes().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] Read body: {e}"))?; + Ok(Some(bytes.to_vec())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] S3 GET failed {}: {}", resp.status(), body.chars().take(200).collect::())) + } + } + + async fn delete_object(&self, key: &str) -> Result<(), String> { + let url: url::Url = self.object_url(key) + .parse() + .map_err(|e: url::ParseError| format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}"))?; + + let empty_payload_hash = hex_sha256(b""); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec1::vec1![("Content-Type".to_string(), "application/octet-stream".to_string())]; + self.sign_request("DELETE", &url, &mut headers, &empty_payload_hash, &date, &datetime); + + let mut req = self.client.delete(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req.send().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + if resp.status().is_success() || resp.status().as_u16() == 204 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] S3 DELETE failed {}: {}", resp.status(), body.chars().take(200).collect::())) + } + } +} +``` + +**Note:** The S3 signing implementation above uses `url`, `chrono`, `hex` crates. Add these to `Cargo.toml` if not already present. `url` and `chrono` are already transitively available. Add `hex` and `vec1`: + +In `src-tauri/Cargo.toml`, add under `[dependencies]`: +```toml +hex = "0.4" +``` + +Replace the `vec1` usage with a simpler `Vec` approach in `sign_request` to avoid an extra dependency. The header list doesn't need `vec1` — start with an empty `Vec` and push all headers including the initial ones. + +Simplified approach — change `sign_request` to use `Vec<(String, String)>` and the callers to initialize with at least one header: + +```rust + fn sign_request( + &self, + method: &str, + url: &url::Url, + headers: &mut Vec<(String, String)>, + payload_hash: &str, + date: &str, + datetime: &str, + ) { +``` + +And callers initialize with: +```rust + let mut headers = vec![("Content-Type".to_string(), "application/octet-stream".to_string())]; +``` + +- [ ] **Step 2: Uncomment s3 module + update mod.rs** + +In `src-tauri/src/sync/mod.rs`: + +```rust +pub mod crypto; +// pub mod manager; +pub mod provider; +pub mod s3; +// pub mod webdav; +``` + +- [ ] **Step 3: Add hex dependency to Cargo.toml** + +In `src-tauri/Cargo.toml`, add under `[dependencies]`: + +```toml +hex = "0.4" +``` + +- [ ] **Step 4: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/sync/s3.rs src-tauri/src/sync/mod.rs src-tauri/Cargo.toml +git commit -m "feat(sync): add S3 provider with AWS Signature V4" +``` + +--- + +## Task 5: WebDAV Provider + +**Files:** +- Create: `src-tauri/src/sync/webdav.rs` +- Modify: `src-tauri/src/sync/mod.rs` (uncomment webdav module) + +- [ ] **Step 1: Create WebDAV provider** + +Create `src-tauri/src/sync/webdav.rs`: + +```rust +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; +use reqwest::Client; + +pub struct WebdavProvider { + server_url: String, + username: String, + password: String, + client: Client, +} + +impl WebdavProvider { + pub fn new(server_url: String, username: String, password: String) -> Self { + let server_url = if server_url.ends_with('/') { + server_url + } else { + format!("{}/", server_url) + }; + Self { + server_url, + username, + password, + client: Client::new(), + } + } + + fn object_url(&self, key: &str) -> String { + format!("{}{}", self.server_url, key) + } +} + +#[async_trait] +impl SyncProvider for WebdavProvider { + async fn test_connection(&self) -> Result<(), String> { + let url = self.object_url(""); + let resp = self.client + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) + .basic_auth(&self.username, Some(&self.password)) + .header("Depth", "0") + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + let status = resp.status(); + if status.is_success() || status.as_u16() == 207 { + Ok(()) + } else { + Err(format!("[SYNC_CONNECTION_ERROR] WebDAV returned {}", status)) + } + } + + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String> { + let url = self.object_url(key); + let resp = self.client + .put(&url) + .basic_auth(&self.username, Some(&self.password)) + .body(data.to_vec()) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + if resp.status().is_success() || resp.status().as_u16() == 201 || resp.status().as_u16() == 204 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] WebDAV PUT failed {}: {}", resp.status(), body.chars().take(200).collect::())) + } + } + + async fn get_object(&self, key: &str) -> Result>, String> { + let url = self.object_url(key); + let resp = self.client + .get(&url) + .basic_auth(&self.username, Some(&self.password)) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + if resp.status().is_success() { + let bytes = resp.bytes().await.map_err(|e| format!("[SYNC_CONNECTION_ERROR] Read body: {e}"))?; + Ok(Some(bytes.to_vec())) + } else { + Err(format!("[SYNC_CONNECTION_ERROR] WebDAV GET failed {}", resp.status())) + } + } + + async fn delete_object(&self, key: &str) -> Result<(), String> { + let url = self.object_url(key); + let resp = self.client + .delete(&url) + .basic_auth(&self.username, Some(&self.password)) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + if resp.status().is_success() || resp.status().as_u16() == 204 || resp.status().as_u16() == 404 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!("[SYNC_CONNECTION_ERROR] WebDAV DELETE failed {}: {}", resp.status(), body.chars().take(200).collect::())) + } + } +} +``` + +- [ ] **Step 2: Uncomment all modules in mod.rs** + +In `src-tauri/src/sync/mod.rs`: + +```rust +pub mod crypto; +// pub mod manager; +pub mod provider; +pub mod s3; +pub mod webdav; +``` + +- [ ] **Step 3: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/sync/webdav.rs src-tauri/src/sync/mod.rs +git commit -m "feat(sync): add WebDAV provider" +``` + +--- + +## Task 6: SyncManager — Core Logic + +**Files:** +- Create: `src-tauri/src/sync/manager.rs` +- Modify: `src-tauri/src/sync/mod.rs` (uncomment manager) + +- [ ] **Step 1: Create SyncManager** + +Create `src-tauri/src/sync/manager.rs`: + +```rust +use crate::db::local::LocalDb; +use crate::sync::crypto; +use crate::sync::provider::{ + build_provider, SyncConfig, SyncResult, SyncSnapshot, SyncSnapshotData, SyncStatus, + ProviderType, +}; +use crate::sync::provider::SyncProvider; +use std::sync::Arc; +use tokio::sync::Mutex; + +const SNAPSHOT_KEY: &str = "sync_snapshot.enc"; + +pub struct SyncManager { + local_db: Arc>>>, +} + +impl SyncManager { + pub fn new(local_db: Arc>>>) -> Self { + Self { local_db } + } + + async fn get_db(&self) -> Result, String> { + let lock = self.local_db.lock().await; + lock.clone().ok_or_else(|| "[SYNC_CONFIG_ERROR] Local DB not initialized".to_string()) + } + + /// Test connection to the remote provider. + pub async fn test_connection(&self, config: &SyncConfig) -> Result<(), String> { + let provider = build_provider(config)?; + provider.test_connection().await + } + + /// Get current sync status. + pub async fn get_status(&self) -> Result { + let db = self.get_db().await?; + + let enabled = db.get_sync_state("sync_enabled").await? + .unwrap_or_else(|| "false".to_string()); + let provider_type_str = db.get_sync_state("provider_type").await?; + let endpoint = db.get_sync_state("endpoint").await?; + let last_sync_at = db.get_sync_state("last_sync_at").await?; + let last_sync_result = db.get_sync_state("last_sync_result").await?; + let device_id = db.get_sync_state("device_id").await?; + + Ok(SyncStatus { + enabled: enabled == "true", + provider_type: provider_type_str.and_then(|s| match s.as_str() { + "S3" => Some(ProviderType::S3), + "WebDAV" => Some(ProviderType::WebDAV), + _ => None, + }), + endpoint, + last_sync_at, + last_sync_result, + device_id, + }) + } + + /// Configure and enable sync. Saves config, generates device_id, does first upload. + pub async fn configure(&self, config: &SyncConfig, sync_password: &str) -> Result<(), String> { + let db = self.get_db().await?; + + // Validate connection first + let provider = build_provider(config)?; + provider.test_connection().await?; + + // Generate device_id if not exists + let device_id = match db.get_sync_state("device_id").await? { + Some(id) => id, + None => { + let id = uuid::Uuid::new_v4().to_string(); + db.set_sync_state("device_id", &id).await?; + id + } + }; + + // Save config (encrypt sensitive fields) + let config_json = serde_json::to_string(config) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize config: {e}"))?; + + // Store non-sensitive config fields + db.set_sync_state("sync_config", &config_json).await?; + db.set_sync_state("provider_type", &format!("{:?}", config.provider_type)).await?; + + // Store endpoint for display (masked) + let display_endpoint = match config.provider_type { + ProviderType::S3 => config.endpoint.clone().unwrap_or_default(), + ProviderType::WebDAV => config.server_url.clone().unwrap_or_default(), + }; + db.set_sync_state("endpoint", &display_endpoint).await?; + + // Store sync password hash for verification (not the password itself) + let pw_hash = crypto::snapshot_hash(sync_password.as_bytes()); + db.set_sync_state("sync_password_hash", &pw_hash).await?; + + // Export and upload initial snapshot + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize snapshot: {e}"))?; + let encrypted = crypto::encrypt(sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + // Update state + db.set_sync_state("sync_enabled", "true").await?; + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash).await?; + db.set_sync_state("last_sync_at", &chrono::Utc::now().to_rfc3339()).await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Disable sync (keep config for re-enable). + pub async fn disable(&self) -> Result<(), String> { + let db = self.get_db().await?; + db.set_sync_state("sync_enabled", "false").await?; + Ok(()) + } + + /// Sync now: pull remote, then push local if changed. + pub async fn sync_now(&self, sync_password: &str) -> Result { + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + + // Pull remote + let remote_result = provider.get_object(SNAPSHOT_KEY).await?; + let local_device_id = self.get_device_id(&db).await?; + let now = chrono::Utc::now().to_rfc3339(); + + if let Some(remote_encrypted) = remote_result { + let remote_plaintext = crypto::decrypt(sync_password, &remote_encrypted)?; + let remote_snapshot: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Invalid remote snapshot: {e}"))?; + + // Verify remote hash integrity + let data_json = serde_json::to_vec(&remote_snapshot.data) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Serialize: {e}"))?; + let computed_hash = crypto::snapshot_hash(&data_json); + if computed_hash != remote_snapshot.snapshot_hash { + return Err("[SYNC_CRYPTO_ERROR] Remote snapshot hash mismatch (corrupted data?)".to_string()); + } + + // Import remote data if it's from a different device and newer + if remote_snapshot.device_id != local_device_id { + self.import_snapshot(&db, &remote_snapshot).await?; + db.set_sync_state("last_sync_result", "success").await?; + db.set_sync_state("last_sync_at", &now).await?; + + return Ok(SyncResult { + action: "pulled".to_string(), + timestamp: now, + remote_device_id: Some(remote_snapshot.device_id), + }); + } + } + + // No remote or same device — push local + let snapshot = self.export_snapshot(&db, &local_device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash).await?; + db.set_sync_state("last_sync_result", "success").await?; + db.set_sync_state("last_sync_at", &now).await?; + + Ok(SyncResult { + action: "pushed".to_string(), + timestamp: now, + remote_device_id: None, + }) + } + + /// Force push: upload local data, overwriting remote. + pub async fn force_push(&self, sync_password: &str) -> Result<(), String> { + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + let device_id = self.get_device_id(&db).await?; + + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash).await?; + db.set_sync_state("last_sync_at", &chrono::Utc::now().to_rfc3339()).await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Force pull: download remote data, overwriting local. + pub async fn force_pull(&self, sync_password: &str) -> Result<(), String> { + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + + let remote_encrypted = provider.get_object(SNAPSHOT_KEY).await? + .ok_or_else(|| "[SYNC_CONNECTION_ERROR] No remote snapshot found".to_string())?; + + let remote_plaintext = crypto::decrypt(sync_password, &remote_encrypted)?; + let remote_snapshot: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Invalid remote snapshot: {e}"))?; + + // Verify hash + let data_json = serde_json::to_vec(&remote_snapshot.data) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Serialize: {e}"))?; + let computed_hash = crypto::snapshot_hash(&data_json); + if computed_hash != remote_snapshot.snapshot_hash { + return Err("[SYNC_CRYPTO_ERROR] Remote snapshot hash mismatch".to_string()); + } + + self.import_snapshot(&db, &remote_snapshot).await?; + db.set_sync_state("last_synced_hash", &computed_hash).await?; + db.set_sync_state("last_sync_at", &chrono::Utc::now().to_rfc3339()).await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Update sync password (re-encrypt and re-upload). + pub async fn update_password(&self, old_password: &str, new_password: &str) -> Result<(), String> { + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + let device_id = self.get_device_id(&db).await?; + + // Download with old password + let remote_encrypted = provider.get_object(SNAPSHOT_KEY).await? + .ok_or_else(|| "[SYNC_CONNECTION_ERROR] No remote snapshot found".to_string())?; + + let remote_plaintext = crypto::decrypt(old_password, &remote_encrypted)?; + // Verify it's valid JSON + let _: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_PASSWORD_ERROR] Old password incorrect: {e}"))?; + + // Re-encrypt with new password and upload + let encrypted = crypto::encrypt(new_password, &remote_plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + // Update stored password hash + let pw_hash = crypto::snapshot_hash(new_password.as_bytes()); + db.set_sync_state("sync_password_hash", &pw_hash).await?; + + Ok(()) + } + + /// Check if local data has changed since last sync. + pub async fn has_local_changes(&self) -> Result { + let db = self.get_db().await?; + let last_hash = db.get_sync_state("last_synced_hash").await?; + if last_hash.is_none() { + return Ok(true); + } + + let device_id = self.get_device_id(&db).await?; + let snapshot = self.export_snapshot(&db, &device_id).await?; + Ok(Some(snapshot.snapshot_hash) != last_hash) + } + + /// Auto-sync push if local has changes. + pub async fn auto_sync_push(&self, sync_password: &str) -> Result<(), String> { + if !self.has_local_changes().await? { + return Ok(()); + } + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + let device_id = self.get_device_id(&db).await?; + + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash).await?; + db.set_sync_state("last_sync_at", &chrono::Utc::now().to_rfc3339()).await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + // ---- Private helpers ---- + + async fn load_config(&self, db: &LocalDb) -> Result { + let config_json = db.get_sync_state("sync_config").await? + .ok_or_else(|| "[SYNC_CONFIG_ERROR] Sync not configured".to_string())?; + serde_json::from_str(&config_json) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Parse config: {e}")) + } + + async fn get_device_id(&self, db: &LocalDb) -> Result { + db.get_sync_state("device_id").await? + .ok_or_else(|| "[SYNC_CONFIG_ERROR] Device ID not set".to_string()) + } + + /// Export current local data to a SyncSnapshot. + async fn export_snapshot(&self, db: &LocalDb, device_id: &str) -> Result { + let connections = db.list_connections().await?; + let saved_queries = db.list_saved_queries().await?; + let ai_providers = db.list_ai_providers().await?; + + // Decrypt AI API keys for transport (will be re-encrypted on import) + let ai_providers_json: Vec = ai_providers.iter().map(|p| { + let mut val = serde_json::to_value(p).unwrap_or_default(); + if let Some(api_key) = val.get("apiKey").and_then(|v| v.as_str()) { + if db.has_encrypted_ai_api_key(api_key) { + if let Ok(decrypted) = db.decrypt_ai_api_key(api_key) { + val["apiKey"] = serde_json::Value::String(decrypted); + } + } + } + val + }).collect(); + + let data = SyncSnapshotData { + connections: serde_json::to_value(&connections) + .unwrap_or_default() + .as_array() + .cloned() + .unwrap_or_default(), + saved_queries: serde_json::to_value(&saved_queries) + .unwrap_or_default() + .as_array() + .cloned() + .unwrap_or_default(), + ai_providers: ai_providers_json, + settings: serde_json::Value::Object(serde_json::Map::new()), // TODO: read from settings.json if needed + }; + + let data_json = serde_json::to_vec(&data) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize data: {e}"))?; + let hash = crypto::snapshot_hash(&data_json); + + Ok(SyncSnapshot { + version: 1, + device_id: device_id.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + snapshot_hash: hash, + data, + }) + } + + /// Import a remote snapshot, overwriting local data. + async fn import_snapshot(&self, db: &LocalDb, snapshot: &SyncSnapshot) -> Result<(), String> { + // Clear and re-import connections + let existing_connections = db.list_connections().await?; + for conn in &existing_connections { + db.delete_connection(conn.id).await?; + } + for conn_val in &snapshot.data.connections { + let form: crate::models::ConnectionForm = serde_json::from_value(conn_val.clone()) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Parse connection: {e}"))?; + db.create_connection(form).await?; + } + + // Clear and re-import saved queries + let existing_queries = db.list_saved_queries().await?; + for q in &existing_queries { + db.delete_saved_query(q.id).await?; + } + for q_val in &snapshot.data.saved_queries { + let name = q_val.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let query = q_val.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let description = q_val.get("description").and_then(|v| v.as_str()).map(String::from); + let connection_id = q_val.get("connectionId").or_else(|| q_val.get("connection_id")).and_then(|v| v.as_i64()); + let database = q_val.get("database").and_then(|v| v.as_str()).map(String::from); + db.create_saved_query(name, query, description, connection_id, database).await?; + } + + // Clear and re-import AI providers + let existing_providers = db.list_ai_providers().await?; + for p in &existing_providers { + db.delete_ai_provider(p.id).await?; + } + for p_val in &snapshot.data.ai_providers { + let mut form: crate::models::AiProviderForm = serde_json::from_value(p_val.clone()) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Parse AI provider: {e}"))?; + // API key is plaintext from snapshot, will be encrypted by create_ai_provider + db.create_ai_provider(form).await?; + } + + // Settings: if non-empty, would update tauri-plugin-store + // For now, settings sync is deferred — the infrastructure is here + + Ok(()) + } +} +``` + +- [ ] **Step 2: Uncomment manager module** + +In `src-tauri/src/sync/mod.rs`: + +```rust +pub mod crypto; +pub mod manager; +pub mod provider; +pub mod s3; +pub mod webdav; +``` + +- [ ] **Step 3: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/sync/manager.rs src-tauri/src/sync/mod.rs +git commit -m "feat(sync): add SyncManager with export/import/merge logic" +``` + +--- + +## Task 7: Tauri Commands + Wire Up AppState + +**Files:** +- Create: `src-tauri/src/commands/sync.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/state.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Create sync commands** + +Create `src-tauri/src/commands/sync.rs`: + +```rust +use crate::state::AppState; +use crate::sync::manager::SyncManager; +use crate::sync::provider::{SyncConfig, SyncResult, SyncStatus}; +use tauri::State; + +#[tauri::command] +pub async fn sync_test_connection(config: SyncConfig) -> Result<(), String> { + let manager = SyncManager::new(State::<'_, AppState>::inner_state(&State::<'_, AppState>::from( + // We can't access state here without it being injected, so create a temporary manager + // Actually, test_connection doesn't need state, so let's call build_provider directly + ))); + // Simplified: test connection doesn't need local DB + crate::sync::provider::build_provider(&config)?.test_connection().await +} + +#[tauri::command] +pub async fn sync_configure( + state: State<'_, AppState>, + config: SyncConfig, + sync_password: String, +) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.configure(&config, &sync_password).await +} + +#[tauri::command] +pub async fn sync_get_status(state: State<'_, AppState>) -> Result { + let manager = SyncManager::new(state.local_db.clone()); + manager.get_status().await +} + +#[tauri::command] +pub async fn sync_now(state: State<'_, AppState>, sync_password: String) -> Result { + let manager = SyncManager::new(state.local_db.clone()); + manager.sync_now(&sync_password).await +} + +#[tauri::command] +pub async fn sync_force_push(state: State<'_, AppState>, sync_password: String) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.force_push(&sync_password).await +} + +#[tauri::command] +pub async fn sync_force_pull(state: State<'_, AppState>, sync_password: String) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.force_pull(&sync_password).await +} + +#[tauri::command] +pub async fn sync_disable(state: State<'_, AppState>) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.disable().await +} + +#[tauri::command] +pub async fn sync_update_password( + state: State<'_, AppState>, + old_password: String, + new_password: String, +) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.update_password(&old_password, &new_password).await +} +``` + +**Note:** The `sync_test_connection` command needs a simpler implementation since it doesn't need state. Fix: + +```rust +#[tauri::command] +pub async fn sync_test_connection(config: SyncConfig) -> Result<(), String> { + let provider = crate::sync::provider::build_provider(&config)?; + provider.test_connection().await +} +``` + +- [ ] **Step 2: Add sync module to commands/mod.rs** + +In `src-tauri/src/commands/mod.rs`, add `pub mod sync;` to the module declarations: + +```rust +pub mod ai; +pub mod config; +pub mod connection; +pub mod elasticsearch; +pub mod metadata; +pub mod mongodb; +pub mod query; +pub mod redis; +pub mod storage; +pub mod sync; +pub mod system; +pub mod transfer; +``` + +- [ ] **Step 3: Update AppState** + +In `src-tauri/src/state.rs`, no changes needed yet — SyncManager is created on-demand per command call using `state.local_db.clone()`. This avoids lifetime and initialization ordering issues. + +- [ ] **Step 4: Register commands in lib.rs** + +In `src-tauri/src/lib.rs`, add to the `invoke_handler` macro after `commands::system::list_system_fonts,`: + +```rust + commands::sync::sync_test_connection, + commands::sync::sync_configure, + commands::sync::sync_get_status, + commands::sync::sync_now, + commands::sync::sync_force_push, + commands::sync::sync_force_pull, + commands::sync::sync_disable, + commands::sync::sync_update_password, +``` + +- [ ] **Step 5: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/commands/sync.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs +git commit -m "feat(sync): add Tauri commands and register in invoke handler" +``` + +--- + +## Task 8: Frontend API Layer + +**Files:** +- Modify: `src/services/api.ts` + +- [ ] **Step 1: Add sync types and API methods** + +Find the end of the `api` object in `src/services/api.ts`. Before the final closing of the api object, add a `sync` namespace. Also add the type definitions at the top of the file (or near other type definitions). + +First, find where types are defined in `api.ts`. Add these types near the existing type definitions: + +```typescript +export type SyncProviderType = "S3" | "WebDAV"; + +export interface SyncConfig { + providerType: SyncProviderType; + endpoint?: string; + region?: string; + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + pathPrefix?: string; + serverUrl?: string; + username?: string; + password?: string; +} + +export interface SyncStatus { + enabled: boolean; + providerType?: SyncProviderType; + endpoint?: string; + lastSyncAt?: string; + lastSyncResult?: string; + deviceId?: string; +} + +export interface SyncResult { + action: string; + timestamp: string; + remoteDeviceId?: string; +} +``` + +Then add the `sync` namespace inside the `api` object (find the pattern of other namespaces like `api.ai` and follow it): + +```typescript + sync: { + testConnection: (config: SyncConfig): Promise => + invoke("sync_test_connection", { config }), + configure: (config: SyncConfig, syncPassword: string): Promise => + invoke("sync_configure", { config, syncPassword }), + getStatus: (): Promise => + invoke("sync_get_status"), + syncNow: (syncPassword: string): Promise => + invoke("sync_now", { syncPassword }), + forcePush: (syncPassword: string): Promise => + invoke("sync_force_push", { syncPassword }), + forcePull: (syncPassword: string): Promise => + invoke("sync_force_pull", { syncPassword }), + disable: (): Promise => + invoke("sync_disable"), + updatePassword: (oldPassword: string, newPassword: string): Promise => + invoke("sync_update_password", { oldPassword, newPassword }), + }, +``` + +- [ ] **Step 2: Run typecheck** + +Run: `bun run typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/services/api.ts +git commit -m "feat(sync): add sync API types and invoke wrappers" +``` + +--- + +## Task 9: Frontend SyncSettings Component + +**Files:** +- Create: `src/components/settings/SyncSettings.tsx` +- Modify: `src/components/settings/SettingsDialog.tsx` + +- [ ] **Step 1: Create SyncSettings component** + +Create `src/components/settings/SyncSettings.tsx`: + +```tsx +import { useState, useEffect, useCallback } from "react"; +import { api, SyncConfig, SyncProviderType, SyncStatus } from "@/services/api"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Cloud, Upload, Download, RefreshCw, CloudOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +export function SyncSettings() { + const { t } = useTranslation(); + const [providerType, setProviderType] = useState("S3"); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + + // S3 fields + const [endpoint, setEndpoint] = useState(""); + const [region, setRegion] = useState("us-east-1"); + const [bucket, setBucket] = useState(""); + const [accessKeyId, setAccessKeyId] = useState(""); + const [secretAccessKey, setSecretAccessKey] = useState(""); + const [pathPrefix, setPathPrefix] = useState("dbpaw/"); + + // WebDAV fields + const [serverUrl, setServerUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + // Sync password + const [syncPassword, setSyncPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const loadStatus = useCallback(async () => { + try { + const s = await api.sync.getStatus(); + setStatus(s); + } catch (e) { + console.error("Failed to load sync status:", e); + } + }, []); + + useEffect(() => { + loadStatus(); + }, [loadStatus]); + + const buildConfig = (): SyncConfig => { + if (providerType === "S3") { + return { + providerType: "S3", + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + pathPrefix, + }; + } + return { + providerType: "WebDAV", + serverUrl, + username, + password, + }; + }; + + const handleTestConnection = async () => { + setLoading(true); + try { + await api.sync.testConnection(buildConfig()); + toast.success(t("settings.sync.testSuccess", { defaultValue: "Connection successful" })); + } catch (e) { + toast.error(t("settings.sync.testFailed", { defaultValue: "Connection failed" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleConfigure = async () => { + if (!syncPassword || syncPassword.length < 6) { + toast.error(t("settings.sync.passwordTooShort", { defaultValue: "Password must be at least 6 characters" })); + return; + } + if (syncPassword !== confirmPassword) { + toast.error(t("settings.sync.passwordMismatch", { defaultValue: "Passwords do not match" })); + return; + } + setLoading(true); + try { + await api.sync.configure(buildConfig(), syncPassword); + toast.success(t("settings.sync.configured", { defaultValue: "Sync configured and enabled" })); + loadStatus(); + } catch (e) { + toast.error(t("settings.sync.configureFailed", { defaultValue: "Failed to configure sync" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleSyncNow = async () => { + if (!syncPassword) { + toast.error(t("settings.sync.enterPassword", { defaultValue: "Enter your sync password" })); + return; + } + setLoading(true); + try { + const result = await api.sync.syncNow(syncPassword); + toast.success(t("settings.sync.synced", { defaultValue: `Sync: ${result.action}` })); + loadStatus(); + } catch (e) { + toast.error(t("settings.sync.syncFailed", { defaultValue: "Sync failed" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleForcePush = async () => { + if (!syncPassword) { + toast.error(t("settings.sync.enterPassword", { defaultValue: "Enter your sync password" })); + return; + } + setLoading(true); + try { + await api.sync.forcePush(syncPassword); + toast.success(t("settings.sync.forcePushed", { defaultValue: "Force pushed to remote" })); + loadStatus(); + } catch (e) { + toast.error(t("settings.sync.forcePushFailed", { defaultValue: "Force push failed" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleForcePull = async () => { + if (!syncPassword) { + toast.error(t("settings.sync.enterPassword", { defaultValue: "Enter your sync password" })); + return; + } + setLoading(true); + try { + await api.sync.forcePull(syncPassword); + toast.success(t("settings.sync.forcePulled", { defaultValue: "Force pulled from remote" })); + loadStatus(); + } catch (e) { + toast.error(t("settings.sync.forcePullFailed", { defaultValue: "Force pull failed" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleDisable = async () => { + setLoading(true); + try { + await api.sync.disable(); + toast.success(t("settings.sync.disabled", { defaultValue: "Sync disabled" })); + loadStatus(); + } catch (e) { + toast.error(t("settings.sync.disableFailed", { defaultValue: "Failed to disable sync" }), { + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ {t("settings.sync.title", { defaultValue: "Config Sync" })} +

+ + {/* Provider Configuration */} +
+ + + + {providerType === "S3" ? ( +
+ setEndpoint(e.target.value)} /> + setRegion(e.target.value)} /> + setBucket(e.target.value)} /> + setAccessKeyId(e.target.value)} /> + setSecretAccessKey(e.target.value)} /> + setPathPrefix(e.target.value)} /> +
+ ) : ( +
+ setServerUrl(e.target.value)} /> + setUsername(e.target.value)} /> + setPassword(e.target.value)} /> +
+ )} + + + + + setSyncPassword(e.target.value)} /> + setConfirmPassword(e.target.value)} /> + +
+ + + {status?.enabled && ( + + )} +
+
+ + {/* Sync Status */} + {status && ( +
+
+ {t("settings.sync.status", { defaultValue: "Sync Status" })} +
+ {status.deviceId && ( +
Device ID: {status.deviceId.slice(0, 8)}...
+ )} + {status.lastSyncAt && ( +
+ {t("settings.sync.lastSync", { defaultValue: "Last sync" })}:{" "} + {new Date(status.lastSyncAt).toLocaleString()} + {status.lastSyncResult === "success" ? " ✓" : ` ✗ ${status.lastSyncResult}`} +
+ )} + {status.enabled && ( +
+ + + +
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Add Sync tab to SettingsDialog** + +In `src/components/settings/SettingsDialog.tsx`: + +1. Add import at the top: +```typescript +import { Cloud } from "lucide-react"; +import { SyncSettings } from "./SyncSettings"; +``` + +2. Update the `SettingsSection` type: +```typescript +type SettingsSection = "general" | "layout" | "ai" | "shortcuts" | "sync" | "about"; +``` + +3. Add the Sync nav button after the "shortcuts" button and before the "about" button: +```tsx + +``` + +4. Add the Sync section panel, after the shortcuts section and before the about section: +```tsx + {activeSection === "sync" && ( + + )} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/settings/SyncSettings.tsx src/components/settings/SettingsDialog.tsx +git commit -m "feat(sync): add SyncSettings component and Settings tab" +``` + +--- + +## Task 10: Integration Test and Smoke Test + +**Files:** +- No new files — run existing test suite + +- [ ] **Step 1: Run Rust unit tests** + +Run: `cargo test --manifest-path src-tauri/Cargo.toml --lib` +Expected: All tests PASS (including new crypto tests) + +- [ ] **Step 2: Run cargo check** + +Run: `cargo check --manifest-path src-tauri/Cargo.toml` +Expected: PASS + +- [ ] **Step 3: Run frontend typecheck** + +Run: `bun run typecheck` +Expected: PASS + +- [ ] **Step 4: Run lint** + +Run: `bun run lint` +Expected: PASS + +- [ ] **Step 5: Run full smoke test** + +Run: `bun run test:smoke` +Expected: PASS + +- [ ] **Step 6: Final commit (if any fixes needed)** + +If any fixes were needed during testing: +```bash +git add -A +git commit -m "fix(sync): address test failures" +``` + +--- + +## Task Dependency Graph + +``` +Task 1 (deps + migration) + └── Task 2 (SyncProvider trait) + ├── Task 3 (crypto) + ├── Task 4 (S3 provider) + └── Task 5 (WebDAV provider) + └── Task 6 (SyncManager) + └── Task 7 (Tauri commands) + └── Task 8 (Frontend API) + └── Task 9 (Frontend UI) + └── Task 10 (Tests) +``` diff --git a/docs/superpowers/specs/2026-06-02-config-sync-design.md b/docs/superpowers/specs/2026-06-02-config-sync-design.md new file mode 100644 index 00000000..92c2c371 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-config-sync-design.md @@ -0,0 +1,245 @@ +# Config Sync Design — DbPaw + +## Summary + +Add configuration synchronization across devices via S3 or WebDAV, with end-to-end encryption. Supports manual and automatic sync modes with Last-Write-Wins conflict resolution. + +## Scope + +### Synced Data +- Database connection configurations (connections table) +- Saved queries (saved_queries table) +- AI provider configurations (ai_providers table, excluding conversations/messages) +- User settings (settings.json via tauri-plugin-store) +- Keyboard shortcuts (stored in settings) + +### NOT Synced +- AI conversations and messages — device-local, high volume +- SQL/Redis execution logs — device-local, transient +- AI master key — per-device encryption key +- Connection pool state — runtime-only + +## Architecture + +``` +Frontend (React) + SettingsDialog → Sync Tab (SyncSettings.tsx) + │ + ▼ invoke() +Backend (Rust) + SyncManager + ├── CryptoEngine (PBKDF2 + AES-256-GCM) + ├── Snapshot export/import + ├── Change detection (SHA-256 hash) + └── Auto-sync timer (tokio interval) + │ + ▼ SyncProvider trait + ┌────────┬──────────┐ + │ S3 │ WebDAV │ + └────────┴──────────┘ +``` + +## SyncProvider Trait + +```rust +#[async_trait] +pub trait SyncProvider: Send + Sync { + async fn test_connection(&self) -> Result<(), String>; + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String>; + async fn get_object(&self, key: &str) -> Result>, String>; + async fn delete_object(&self, key: &str) -> Result<(), String>; +} +``` + +### S3 Provider +- Config: endpoint, region, bucket, access_key_id, secret_access_key, path_prefix +- Uses `reqwest` + manual AWS Signature V4 (no heavy AWS SDK dependency) +- Remote path: `s3://{bucket}/{path_prefix}sync_snapshot.enc` + +### WebDAV Provider +- Config: server_url, username, password +- Uses `reqwest` with standard HTTP PUT/GET/DELETE +- Remote path: `{server_url}/sync_snapshot.enc` + +## Encryption + +``` +User password → PBKDF2-SHA256 (600k iterations, random 16-byte salt) → AES-256-GCM key +Plaintext snapshot JSON → AES-256-GCM (random 12-byte nonce) → ciphertext + +File format: [16 bytes salt][12 bytes nonce][ciphertext + GCM tag] +``` + +- Snapshot includes a `snapshot_hash` (SHA-256 of plaintext) for integrity verification after decryption +- Wrong password → GCM tag verification fails → user-friendly error + +## Snapshot Format + +```json +{ + "version": 1, + "device_id": "uuid", + "timestamp": "2026-06-02T10:30:00Z", + "snapshot_hash": "sha256-of-plaintext", + "data": { + "connections": [...], + "saved_queries": [...], + "ai_providers": [...], + "settings": { "key": "value" } + } +} +``` + +### Sensitive Field Handling +- Connection passwords: plaintext inside encrypted snapshot (E2E encryption protects in transit/at rest) +- AI API Keys: plaintext inside snapshot; on import, re-encrypted with local `ai_master.key` +- SSH key paths: synced as-is (users may need to verify paths on different OS) +- Provider credentials (S3 secret key / WebDAV password): stored locally encrypted with `ai_master.key` + +## Sync Mode: Hybrid + +### Auto Sync (default) +- App startup → 30s delay (wait for LocalDb init) → first pull +- Every 5 minutes → hash comparison → push if changed +- Configurable interval +- Silent failure on network errors, recorded in `last_sync_result` + +### Manual Sync +- "Sync Now" → pull + push +- "Force Push" → local overwrites remote +- "Force Pull" → remote overwrites local + +## Conflict Resolution: Last-Write-Wins + +``` +Pull remote → compare timestamps: + - remote.timestamp > local.timestamp AND remote.device_id != local.device_id → apply remote + - otherwise → skip (local is newer or same device) +``` + +Applying remote data: +1. Clear local tables (connections, saved_queries, ai_providers) +2. Insert remote data +3. Update settings.json keys +4. Re-encrypt AI API keys with local `ai_master.key` +5. Update `last_synced_hash` + +## Change Detection + +``` +local data → export to JSON → SHA-256 hash → compare with last_synced_hash + - different → local has changes → push + - same → no changes → skip +``` + +`last_synced_hash` stored in `sync_state` table in SQLite. + +## Database: sync_state Table + +```sql +CREATE TABLE IF NOT EXISTS sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +Keys: `device_id`, `sync_config` (JSON, provider params without passwords), `sync_enabled`, `last_synced_hash`, `last_sync_at`, `last_sync_result`, `sync_password_hash` (for verification without storing plaintext) + +## Tauri Commands + +| Command | Purpose | +|---------|---------| +| `sync_test_connection(config)` | Validate provider connectivity | +| `sync_configure(config, sync_password)` | Save config + first upload | +| `sync_get_status()` | Return current sync state | +| `sync_now()` | Manual pull + push | +| `sync_force_push()` | Local overwrites remote | +| `sync_force_pull()` | Remote overwrites local | +| `sync_disable()` | Turn off sync, keep config | +| `sync_update_password(old, new)` | Re-encrypt with new password | + +## Frontend + +### New Files +- `src/components/settings/SyncSettings.tsx` — Sync tab in Settings dialog +- Type definitions for SyncConfig, SyncStatus, SyncResult + +### Modified Files +- `src/services/api.ts` — Add `syncApi` namespace +- `src/components/settings/SettingsDialog.tsx` — Add Sync tab + +### UI Layout +- Provider selector dropdown (S3 / WebDAV) +- Dynamic form fields based on provider +- Sync password + confirmation inputs +- Test Connection button with status indicator +- Auto-sync toggle + interval selector +- Sync status display (device ID, last sync time, result) +- Action buttons: Sync Now, Force Push, Force Pull, Disable + +## Backend Files + +### New Files +| File | Purpose | +|------|---------| +| `src-tauri/src/sync/mod.rs` | Module entry | +| `src-tauri/src/sync/provider.rs` | SyncProvider trait | +| `src-tauri/src/sync/crypto.rs` | PBKDF2 + AES-256-GCM | +| `src-tauri/src/sync/manager.rs` | SyncManager (export/import/timer/hash) | +| `src-tauri/src/sync/s3.rs` | S3 implementation (reqwest + Sig V4) | +| `src-tauri/src/sync/webdav.rs` | WebDAV implementation (reqwest) | +| `src-tauri/src/commands/sync.rs` | Tauri command handlers | +| `src-tauri/migrations/017_sync_state.sql` | sync_state table migration | + +### Modified Files +| File | Change | +|------|--------| +| `src-tauri/src/lib.rs` | Register sync commands, start/stop auto-sync on app lifecycle | +| `src-tauri/src/state.rs` | Add `sync_manager` to AppState | +| `src-tauri/src/db/local.rs` | Add sync_state CRUD methods | +| `src-tauri/Cargo.toml` | Add `sha2`, `hmac`, `pbkdf2` dependencies | + +## New Rust Dependencies + +```toml +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = "0.12" +# aes-gcm, reqwest, serde_json, chrono — already present +``` + +## Error Prefixes + +- `[SYNC_CONFIG_ERROR]` — Invalid configuration +- `[SYNC_CONNECTION_ERROR]` — Remote connection failure +- `[SYNC_CRYPTO_ERROR]` — Encryption/decryption failure +- `[SYNC_MERGE_ERROR]` — Data merge failure +- `[SYNC_PASSWORD_ERROR]` — Wrong sync password + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| Remote has no snapshot | First sync, push local data | +| Fresh install (no local data) | Pull remote data | +| Wrong sync password | GCM tag verification fails, show error | +| Remote unreachable | Auto-sync silent fail, record error, no impact on normal usage | +| App exit during sync | Cancel in-progress sync, don't block exit | +| Concurrent sync (two windows) | Mutex ensures single operation at a time | +| Schema version mismatch | Snapshot `version` field check on import | +| Rapid multi-device edits | Last-Write-Wins may lose intermediate changes (user-accepted tradeoff) | + +## Auto-Sync Lifecycle + +``` +App startup → LocalDb init complete + → SyncManager::new(state) + → Read sync_state: enabled? + → Yes: delay 30s → pull → start interval timer (5min, configurable) + → No: idle + +App exit (RunEvent::Exit): + → Cancel timer + → Don't wait for in-progress sync +``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb6ff7db..af09a066 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2096,10 +2096,13 @@ dependencies = [ "duckdb", "fontique", "futures-util", + "hex", + "hmac 0.12.1", "libsqlite3-sys", "mongodb", "odbc-api", "oracle", + "pbkdf2", "quick-xml 0.37.5", "rand 0.9.4", "redis", @@ -2108,6 +2111,7 @@ dependencies = [ "scylla", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "ssh2", "tauri", @@ -2125,6 +2129,7 @@ dependencies = [ "tokio-util", "tower", "tower-http", + "url", "urlencoding", "uuid", ] @@ -5267,6 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", + "hmac 0.12.1", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d38216e..655a2ade 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,11 @@ axum = "0.8" tower = "0.5" tower-http = "0.6" async-stream = "0.3" +sha2 = "0.10" +hmac = "0.12" +pbkdf2 = "0.12" +hex = "0.4" +url = "2" [target.'cfg(windows)'.dependencies] tiberius = { version = "0.12", features = ["winauth"] } diff --git a/src-tauri/migrations/017_sync_state.sql b/src-tauri/migrations/017_sync_state.sql new file mode 100644 index 00000000..54288218 --- /dev/null +++ b/src-tauri/migrations/017_sync_state.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 0830916d..d93d4194 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -170,6 +170,7 @@ pub async fn ai_create_provider( normalize_provider_form(&mut config, Some("openai"))?; let db = get_db(&state).await?; let created = db.create_ai_provider(config).await?; + state.sync_scheduler.notify_data_changed(); db.get_ai_provider_public_by_id(created.id).await } @@ -192,6 +193,7 @@ pub async fn ai_update_provider( normalize_provider_form(&mut config, None)?; let db = get_db(&state).await?; let updated = db.update_ai_provider(id, config).await?; + state.sync_scheduler.notify_data_changed(); db.get_ai_provider_public_by_id(updated.id).await } @@ -209,7 +211,9 @@ pub async fn ai_update_provider_direct( #[tauri::command] pub async fn ai_delete_provider(state: State<'_, AppState>, id: i64) -> Result<(), String> { let db = get_db(&state).await?; - db.delete_ai_provider(id).await + let result = db.delete_ai_provider(id).await; + state.sync_scheduler.notify_data_changed(); + result } pub async fn ai_delete_provider_direct(state: &AppState, id: i64) -> Result<(), String> { @@ -220,7 +224,9 @@ pub async fn ai_delete_provider_direct(state: &AppState, id: i64) -> Result<(), #[tauri::command] pub async fn ai_set_default_provider(state: State<'_, AppState>, id: i64) -> Result<(), String> { let db = get_db(&state).await?; - db.set_default_ai_provider(id).await + let result = db.set_default_ai_provider(id).await; + state.sync_scheduler.notify_data_changed(); + result } pub async fn ai_set_default_provider_direct(state: &AppState, id: i64) -> Result<(), String> { @@ -235,7 +241,9 @@ pub async fn ai_clear_provider_api_key( ) -> Result<(), String> { let provider_type = normalize_provider_type(&provider_type)?; let db = get_db(&state).await?; - db.clear_ai_provider_api_key(&provider_type).await + let result = db.clear_ai_provider_api_key(&provider_type).await; + state.sync_scheduler.notify_data_changed(); + result } pub async fn ai_clear_provider_api_key_direct( diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 5afb1ed7..2d229e3f 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -647,7 +647,9 @@ pub async fn create_connection( lock.clone() }; if let Some(db) = local_db { - db.create_connection(form).await + let result = db.create_connection(form).await; + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } @@ -684,7 +686,9 @@ pub async fn update_connection( // If connection is updated, we should remove it from pool so next usage reconnects with new config state.pool_manager.remove_by_prefix(&id.to_string()).await; - db.update_connection(id, form).await + let result = db.update_connection(id, form).await; + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } @@ -710,7 +714,9 @@ pub async fn update_connection_direct( #[tauri::command] pub async fn delete_connection(state: State<'_, AppState>, id: i64) -> Result<(), String> { - delete_connection_direct(&state, id).await + let result = delete_connection_direct(&state, id).await; + state.sync_scheduler.notify_data_changed(); + result } pub async fn delete_connection_direct(state: &AppState, id: i64) -> Result<(), String> { @@ -1036,7 +1042,9 @@ pub async fn import_connections( lock.clone() }; if let Some(db) = local_db { - crate::import::import_from_file(&file_path, &db).await + let result = crate::import::import_from_file(&file_path, &db).await; + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 01cb9fcd..8a64cbca 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod mongodb; pub mod query; pub mod redis; pub mod storage; +pub mod sync; pub mod system; pub mod transfer; diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index 39ec88f7..e59ea51c 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -13,8 +13,12 @@ pub async fn save_query( ) -> Result { let local_db = state.local_db.lock().await; if let Some(db) = local_db.as_ref() { - db.create_saved_query(name, query, description, connection_id, database) - .await + let result = db + .create_saved_query(name, query, description, connection_id, database) + .await; + drop(local_db); + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } @@ -49,8 +53,12 @@ pub async fn update_saved_query( ) -> Result { let local_db = state.local_db.lock().await; if let Some(db) = local_db.as_ref() { - db.update_saved_query(id, name, query, description, connection_id, database) - .await + let result = db + .update_saved_query(id, name, query, description, connection_id, database) + .await; + drop(local_db); + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } @@ -78,7 +86,10 @@ pub async fn update_saved_query_direct( pub async fn delete_saved_query(state: State<'_, AppState>, id: i64) -> Result<(), String> { let local_db = state.local_db.lock().await; if let Some(db) = local_db.as_ref() { - db.delete_saved_query(id).await + let result = db.delete_saved_query(id).await; + drop(local_db); + state.sync_scheduler.notify_data_changed(); + result } else { Err("Local DB not initialized".to_string()) } diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs new file mode 100644 index 00000000..6f56c865 --- /dev/null +++ b/src-tauri/src/commands/sync.rs @@ -0,0 +1,66 @@ +use crate::state::AppState; +use crate::sync::manager::SyncManager; +use crate::sync::provider::{SyncConfig, SyncResult, SyncStatus}; +use tauri::State; + +#[tauri::command] +pub async fn sync_test_connection(config: SyncConfig) -> Result<(), String> { + let provider = crate::sync::provider::build_provider(&config)?; + provider.test_connection().await +} + +#[tauri::command] +pub async fn sync_configure( + state: State<'_, AppState>, + config: SyncConfig, + sync_password: String, +) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.configure(&config, &sync_password).await +} + +#[tauri::command] +pub async fn sync_get_status(state: State<'_, AppState>) -> Result { + let manager = SyncManager::new(state.local_db.clone()); + manager.get_status().await +} + +#[tauri::command] +pub async fn sync_get_config(state: State<'_, AppState>) -> Result, String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.get_config().await +} + +#[tauri::command] +pub async fn sync_now(state: State<'_, AppState>) -> Result { + let manager = SyncManager::new(state.local_db.clone()); + manager.sync_now().await +} + +#[tauri::command] +pub async fn sync_force_push(state: State<'_, AppState>) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.force_push().await +} + +#[tauri::command] +pub async fn sync_force_pull(state: State<'_, AppState>) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.force_pull().await +} + +#[tauri::command] +pub async fn sync_disable(state: State<'_, AppState>) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.disable().await +} + +#[tauri::command] +pub async fn sync_update_password( + state: State<'_, AppState>, + old_password: String, + new_password: String, +) -> Result<(), String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.update_password(&old_password, &new_password).await +} diff --git a/src-tauri/src/db/local.rs b/src-tauri/src/db/local.rs index e5cd98cd..48fda379 100644 --- a/src-tauri/src/db/local.rs +++ b/src-tauri/src/db/local.rs @@ -281,6 +281,20 @@ impl LocalDb { .map_err(|e| format!("[MIGRATION_016_ERROR] {e}"))?; } + let has_sync_state: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='sync_state')", + ) + .fetch_one(&pool) + .await + .map_err(|e| format!("[MIGRATION_017_CHECK_ERROR] {e}"))?; + + if !has_sync_state { + sqlx::query(include_str!("../../migrations/017_sync_state.sql")) + .execute(&pool) + .await + .map_err(|e| format!("[MIGRATION_017_ERROR] {e}"))?; + } + Ok(Self { pool, ai_master_key, @@ -300,6 +314,16 @@ impl LocalDb { trimmed.starts_with(Self::AI_KEY_PREFIX) && trimmed.len() > Self::AI_KEY_PREFIX.len() } + /// Encrypt the sync password using the AI master key for local storage. + pub fn encrypt_sync_password(&self, password: &str) -> Result { + Self::encrypt_ai_api_key_raw(&self.ai_master_key, password) + } + + /// Decrypt the sync password that was stored locally. + pub fn decrypt_sync_password(&self, encrypted: &str) -> Result { + Self::decrypt_ai_api_key_raw(&self.ai_master_key, encrypted) + } + fn load_or_create_ai_master_key(app_dir: &Path) -> Result<[u8; 32], String> { let key_path = app_dir.join("ai_master.key"); if key_path.exists() { @@ -794,6 +818,42 @@ impl LocalDb { .map_err(|e| format!("[LIST_REDIS_COMMAND_LOGS_ERROR] {e}")) } + // ── sync_state CRUD ────────────────────────────────────── + + pub async fn get_sync_state(&self, key: &str) -> Result, String> { + let row = sqlx::query_as::<_, (String,)>( + "SELECT value FROM sync_state WHERE key = ?", + ) + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|e| format!("[GET_SYNC_STATE_ERROR] {e}"))?; + + Ok(row.map(|(v,)| v)) + } + + pub async fn set_sync_state(&self, key: &str, value: &str) -> Result<(), String> { + sqlx::query( + "INSERT INTO sync_state (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .execute(&self.pool) + .await + .map_err(|e| format!("[SET_SYNC_STATE_ERROR] {e}"))?; + Ok(()) + } + + pub async fn delete_sync_state(&self, key: &str) -> Result<(), String> { + sqlx::query("DELETE FROM sync_state WHERE key = ?") + .bind(key) + .execute(&self.pool) + .await + .map_err(|e| format!("[DELETE_SYNC_STATE_ERROR] {e}"))?; + Ok(()) + } + pub async fn list_ai_providers(&self) -> Result, String> { sqlx::query_as::<_, AiProvider>( "SELECT id, name, provider_type, base_url, model, api_key, is_default, enabled, extra_json, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, updated_at DESC", @@ -1186,6 +1246,7 @@ mod tests { include_str!("../../migrations/014_add_sentinel_fields.sql"), include_str!("../../migrations/015_add_mongodb_auth_source.sql"), include_str!("../../migrations/016_redis_command_logs.sql"), + include_str!("../../migrations/017_sync_state.sql"), ] { sqlx::query(migration) .execute(&pool) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e587c64e..57e540fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -128,6 +128,7 @@ pub fn run() { } // Initialize local database (blocking to avoid race conditions) + let handle_for_sync = handle.clone(); tauri::async_runtime::block_on(async move { let state = handle.state::(); match LocalDb::init(&handle).await { @@ -138,10 +139,16 @@ pub fn run() { } Err(e) => { eprintln!("Failed to initialize local DB: {}", e); - // Make the error visible in the frontend if possible, or at least easier to debug } } }); + + // Start the sync scheduler for periodic + event-driven auto-sync + { + let state = handle_for_sync.state::(); + state.sync_scheduler.start(); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -270,6 +277,15 @@ pub fn run() { commands::mongodb::mongodb_list_databases, commands::mongodb::mongodb_list_collections, commands::system::list_system_fonts, + commands::sync::sync_test_connection, + commands::sync::sync_configure, + commands::sync::sync_get_status, + commands::sync::sync_get_config, + commands::sync::sync_now, + commands::sync::sync_force_push, + commands::sync::sync_force_pull, + commands::sync::sync_disable, + commands::sync::sync_update_password, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); @@ -278,6 +294,7 @@ pub fn run() { tauri::RunEvent::Exit => { let _ = app_handle.save_window_state(StateFlags::all()); let state = app_handle.state::(); + state.sync_scheduler.stop(); tauri::async_runtime::block_on(async { state.pool_manager.close_all().await; }); @@ -298,6 +315,7 @@ pub mod mcp; pub mod models; pub mod ssh; pub mod state; +pub mod sync; pub mod utils; /// Initialize Oracle Instant Client library path from bundled resources. diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5f31c555..eb04f7a8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,21 +1,27 @@ use crate::datasources::redis::RedisConnectionCache; use crate::db::local::LocalDb; use crate::db::pool_manager::PoolManager; +use crate::sync::scheduler::SyncScheduler; use std::sync::Arc; use tokio::sync::Mutex; pub struct AppState { - pub local_db: Mutex>>, + pub local_db: Arc>>>, pub pool_manager: Arc, pub redis_cache: Mutex, + pub sync_scheduler: SyncScheduler, } impl AppState { pub fn new() -> Self { + let local_db: Arc>>> = + Arc::new(Mutex::new(None)); + let sync_scheduler = SyncScheduler::new(local_db.clone()); Self { - local_db: Mutex::new(None), + local_db, pool_manager: Arc::new(PoolManager::new()), redis_cache: Mutex::new(RedisConnectionCache::new()), + sync_scheduler, } } } diff --git a/src-tauri/src/sync/crypto.rs b/src-tauri/src/sync/crypto.rs new file mode 100644 index 00000000..456665ff --- /dev/null +++ b/src-tauri/src/sync/crypto.rs @@ -0,0 +1,168 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::{engine::general_purpose, Engine as _}; +use pbkdf2::pbkdf2_hmac; +use rand::RngCore; +use sha2::Sha256; + +const PBKDF2_ITERATIONS: u32 = 600_000; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; + +/// Derive a 32-byte AES key from a user password and salt using PBKDF2-SHA256. +fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { + let mut key = [0u8; 32]; + pbkdf2_hmac::(password.as_bytes(), salt, PBKDF2_ITERATIONS, &mut key); + key +} + +/// Compute SHA-256 hash of the given data, returned as hex string. +pub fn snapshot_hash(data: &[u8]) -> String { + use sha2::Digest; + let mut hasher = sha2::Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +/// Encrypt plaintext bytes with a user password. +/// Format: [16 bytes salt][12 bytes nonce][ciphertext + GCM tag] +pub fn encrypt(password: &str, plaintext: &[u8]) -> Result, String> { + let mut salt = [0u8; SALT_LEN]; + rand::rng().fill_bytes(&mut salt); + + let key = derive_key(password, &salt); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Encryption failed: {e}"))?; + + let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); + output.extend_from_slice(&salt); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +/// Decrypt data encrypted by `encrypt`. Returns plaintext bytes. +pub fn decrypt(password: &str, data: &[u8]) -> Result, String> { + if data.len() < SALT_LEN + NONCE_LEN + 16 { + return Err("[SYNC_CRYPTO_ERROR] Data too short".to_string()); + } + + let (salt, rest) = data.split_at(SALT_LEN); + let (nonce_bytes, ciphertext) = rest.split_at(NONCE_LEN); + + let key = derive_key(password, salt); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + + let nonce = Nonce::from_slice(nonce_bytes); + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| { + format!( + "[SYNC_PASSWORD_ERROR] Decryption failed (wrong password?): {e}" + ) + }) +} + +/// Encrypt a string value for local storage using the given key material. +/// Used for encrypting provider credentials before saving to sync_state. +pub fn encrypt_with_key(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Encryption failed: {e}"))?; + + let mut payload = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + payload.extend_from_slice(&nonce_bytes); + payload.extend_from_slice(&ciphertext); + Ok(format!( + "enc:sync:{}", + general_purpose::STANDARD.encode(payload) + )) +} + +/// Decrypt a string value that was encrypted with `encrypt_with_key`. +pub fn decrypt_with_key(key: &[u8; 32], encrypted: &str) -> Result { + let prefix = "enc:sync:"; + if !encrypted.starts_with(prefix) { + return Err("[SYNC_CRYPTO_ERROR] Invalid encrypted format".to_string()); + } + let b64 = &encrypted[prefix.len()..]; + let payload = general_purpose::STANDARD + .decode(b64) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Base64 decode: {e}"))?; + if payload.len() < NONCE_LEN + 16 { + return Err("[SYNC_CRYPTO_ERROR] Payload too short".to_string()); + } + let (nonce_bytes, ciphertext) = payload.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Cipher init: {e}"))?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("[SYNC_CRYPTO_ERROR] Decryption failed: {e}"))?; + String::from_utf8(plaintext).map_err(|e| format!("[SYNC_CRYPTO_ERROR] UTF-8: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_round_trip() { + let password = "test-sync-password-123"; + let plaintext = br#"{"version":1,"data":{"connections":[]}}"#; + let encrypted = encrypt(password, plaintext).unwrap(); + let decrypted = decrypt(password, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_with_wrong_password_fails() { + let encrypted = encrypt("correct-password", b"secret data").unwrap(); + let result = decrypt("wrong-password", &encrypted); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("[SYNC_PASSWORD_ERROR]")); + } + + #[test] + fn snapshot_hash_is_deterministic() { + let data = b"hello world"; + let h1 = snapshot_hash(data); + let h2 = snapshot_hash(data); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); // SHA-256 hex + } + + #[test] + fn encrypt_decrypt_with_key_round_trip() { + let mut key = [0u8; 32]; + rand::rng().fill_bytes(&mut key); + let encrypted = encrypt_with_key(&key, "secret-value").unwrap(); + let decrypted = decrypt_with_key(&key, &encrypted).unwrap(); + assert_eq!(decrypted, "secret-value"); + } + + #[test] + fn decrypt_with_wrong_key_fails() { + let mut key1 = [0u8; 32]; + let mut key2 = [0u8; 32]; + rand::rng().fill_bytes(&mut key1); + rand::rng().fill_bytes(&mut key2); + let encrypted = encrypt_with_key(&key1, "secret").unwrap(); + let result = decrypt_with_key(&key2, &encrypted); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs new file mode 100644 index 00000000..4d4d7d30 --- /dev/null +++ b/src-tauri/src/sync/manager.rs @@ -0,0 +1,512 @@ +use crate::db::local::LocalDb; +use crate::sync::crypto; +use crate::sync::provider::{ + build_provider, ProviderType, SyncConfig, SyncResult, SyncSnapshot, SyncSnapshotData, + SyncStatus, +}; +use std::sync::Arc; +use tokio::sync::Mutex; + +const SNAPSHOT_KEY: &str = "sync_snapshot.enc"; + +pub struct SyncManager { + local_db: Arc>>>, +} + +impl SyncManager { + pub fn new(local_db: Arc>>>) -> Self { + Self { local_db } + } + + async fn get_db(&self) -> Result, String> { + let lock = self.local_db.lock().await; + lock.clone() + .ok_or_else(|| "[SYNC_CONFIG_ERROR] Local DB not initialized".to_string()) + } + + /// Test connection to the remote provider. + pub async fn test_connection(&self, config: &SyncConfig) -> Result<(), String> { + let provider = build_provider(config)?; + provider.test_connection().await + } + + /// Get current sync status. + pub async fn get_status(&self) -> Result { + let db = self.get_db().await?; + + let enabled = db + .get_sync_state("sync_enabled") + .await? + .unwrap_or_else(|| "false".to_string()); + let provider_type_str = db.get_sync_state("provider_type").await?; + let endpoint = db.get_sync_state("endpoint").await?; + let last_sync_at = db.get_sync_state("last_sync_at").await?; + let last_sync_result = db.get_sync_state("last_sync_result").await?; + let device_id = db.get_sync_state("device_id").await?; + let password_stored = db.get_sync_state("sync_password_enc").await?.is_some(); + let sync_interval_minutes = db + .get_sync_state("sync_interval_minutes") + .await? + .and_then(|s| s.parse::().ok()) + .unwrap_or(5); + + Ok(SyncStatus { + enabled: enabled == "true", + provider_type: provider_type_str.and_then(|s| match s.as_str() { + "S3" => Some(ProviderType::S3), + "WebDAV" => Some(ProviderType::WebDAV), + _ => None, + }), + endpoint, + last_sync_at, + last_sync_result, + device_id, + password_stored, + sync_interval_minutes, + }) + } + + /// Configure and enable sync. Saves config, generates device_id, does first upload. + pub async fn configure( + &self, + config: &SyncConfig, + sync_password: &str, + ) -> Result<(), String> { + let db = self.get_db().await?; + + // Validate connection first + let provider = build_provider(config)?; + provider.test_connection().await?; + + // Generate device_id if not exists + let device_id = match db.get_sync_state("device_id").await? { + Some(id) => id, + None => { + let id = uuid::Uuid::new_v4().to_string(); + db.set_sync_state("device_id", &id).await?; + id + } + }; + + // Save config + let config_json = serde_json::to_string(config) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize config: {e}"))?; + + db.set_sync_state("sync_config", &config_json).await?; + db.set_sync_state( + "provider_type", + &format!("{:?}", config.provider_type), + ) + .await?; + + // Store endpoint for display + let display_endpoint = match config.provider_type { + ProviderType::S3 => config.endpoint.clone().unwrap_or_default(), + ProviderType::WebDAV => config.server_url.clone().unwrap_or_default(), + }; + db.set_sync_state("endpoint", &display_endpoint).await?; + + // Store sync interval + let interval = config.sync_interval_minutes.unwrap_or(5); + db.set_sync_state("sync_interval_minutes", &interval.to_string()) + .await?; + + // Store sync password encrypted with master key for automatic sync + let encrypted_pw = db.encrypt_sync_password(sync_password)?; + db.set_sync_state("sync_password_enc", &encrypted_pw).await?; + + // Export and upload initial snapshot + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize snapshot: {e}"))?; + let encrypted = crypto::encrypt(sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + // Update state + db.set_sync_state("sync_enabled", "true").await?; + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) + .await?; + db.set_sync_state( + "last_sync_at", + &chrono::Utc::now().to_rfc3339(), + ) + .await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Disable sync (keep config for re-enable). + pub async fn disable(&self) -> Result<(), String> { + let db = self.get_db().await?; + db.set_sync_state("sync_enabled", "false").await?; + Ok(()) + } + + /// Get saved sync config (for form echo-back). + pub async fn get_config(&self) -> Result, String> { + let db = self.get_db().await?; + match db.get_sync_state("sync_config").await? { + Some(json) => { + let config: SyncConfig = serde_json::from_str(&json) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Parse config: {e}"))?; + Ok(Some(config)) + } + None => Ok(None), + } + } + + /// Sync now: pull remote, then push local if changed. + pub async fn sync_now(&self) -> Result { + let db = self.get_db().await?; + let sync_password = self.get_sync_password(&db).await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + + let local_device_id = self.get_device_id(&db).await?; + let now = chrono::Utc::now().to_rfc3339(); + + // Pull remote + let remote_result = provider.get_object(SNAPSHOT_KEY).await?; + if let Some(remote_encrypted) = remote_result { + let remote_plaintext = crypto::decrypt(&sync_password, &remote_encrypted)?; + let remote_snapshot: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Invalid remote snapshot: {e}"))?; + + // Verify remote hash integrity + let data_json = serde_json::to_vec(&remote_snapshot.data) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Serialize: {e}"))?; + let computed_hash = crypto::snapshot_hash(&data_json); + if computed_hash != remote_snapshot.snapshot_hash { + return Err( + "[SYNC_CRYPTO_ERROR] Remote snapshot hash mismatch (corrupted data?)" + .to_string(), + ); + } + + // Import remote data if it's from a different device + if remote_snapshot.device_id != local_device_id { + self.import_snapshot(&db, &remote_snapshot).await?; + db.set_sync_state("last_sync_result", "success").await?; + db.set_sync_state("last_sync_at", &now).await?; + + return Ok(SyncResult { + action: "pulled".to_string(), + timestamp: now, + remote_device_id: Some(remote_snapshot.device_id), + }); + } + } + + // No remote or same device — push local + let snapshot = self.export_snapshot(&db, &local_device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) + .await?; + db.set_sync_state("last_sync_result", "success").await?; + db.set_sync_state("last_sync_at", &now).await?; + + Ok(SyncResult { + action: "pushed".to_string(), + timestamp: now, + remote_device_id: None, + }) + } + + /// Force push: upload local data, overwriting remote. + pub async fn force_push(&self) -> Result<(), String> { + let db = self.get_db().await?; + let sync_password = self.get_sync_password(&db).await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + let device_id = self.get_device_id(&db).await?; + + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) + .await?; + db.set_sync_state( + "last_sync_at", + &chrono::Utc::now().to_rfc3339(), + ) + .await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Force pull: download remote data, overwriting local. + pub async fn force_pull(&self) -> Result<(), String> { + let db = self.get_db().await?; + let sync_password = self.get_sync_password(&db).await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + + let remote_encrypted = provider + .get_object(SNAPSHOT_KEY) + .await? + .ok_or_else(|| "[SYNC_CONNECTION_ERROR] No remote snapshot found".to_string())?; + + let remote_plaintext = crypto::decrypt(&sync_password, &remote_encrypted)?; + let remote_snapshot: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Invalid remote snapshot: {e}"))?; + + // Verify hash + let data_json = serde_json::to_vec(&remote_snapshot.data) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Serialize: {e}"))?; + let computed_hash = crypto::snapshot_hash(&data_json); + if computed_hash != remote_snapshot.snapshot_hash { + return Err("[SYNC_CRYPTO_ERROR] Remote snapshot hash mismatch".to_string()); + } + + self.import_snapshot(&db, &remote_snapshot).await?; + db.set_sync_state("last_synced_hash", &computed_hash).await?; + db.set_sync_state( + "last_sync_at", + &chrono::Utc::now().to_rfc3339(), + ) + .await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + /// Update sync password (re-encrypt and re-upload). + pub async fn update_password( + &self, + old_password: &str, + new_password: &str, + ) -> Result<(), String> { + let db = self.get_db().await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + + // Download with old password + let remote_encrypted = provider + .get_object(SNAPSHOT_KEY) + .await? + .ok_or_else(|| "[SYNC_CONNECTION_ERROR] No remote snapshot found".to_string())?; + + let remote_plaintext = crypto::decrypt(old_password, &remote_encrypted)?; + let _: SyncSnapshot = serde_json::from_slice(&remote_plaintext) + .map_err(|e| format!("[SYNC_PASSWORD_ERROR] Old password incorrect: {e}"))?; + + // Re-encrypt with new password and upload + let encrypted = crypto::encrypt(new_password, &remote_plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + // Update stored encrypted password + let encrypted_pw = db.encrypt_sync_password(new_password)?; + db.set_sync_state("sync_password_enc", &encrypted_pw).await?; + + Ok(()) + } + + /// Check if local data has changed since last sync. + pub async fn has_local_changes(&self) -> Result { + let db = self.get_db().await?; + let last_hash = db.get_sync_state("last_synced_hash").await?; + if last_hash.is_none() { + return Ok(true); + } + + let device_id = self.get_device_id(&db).await?; + let snapshot = self.export_snapshot(&db, &device_id).await?; + Ok(Some(snapshot.snapshot_hash) != last_hash) + } + + /// Auto-sync push if local has changes. + pub async fn auto_sync_push(&self) -> Result<(), String> { + if !self.has_local_changes().await? { + return Ok(()); + } + let db = self.get_db().await?; + let sync_password = self.get_sync_password(&db).await?; + let config = self.load_config(&db).await?; + let provider = build_provider(&config)?; + let device_id = self.get_device_id(&db).await?; + + let snapshot = self.export_snapshot(&db, &device_id).await?; + let plaintext = serde_json::to_vec(&snapshot) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize: {e}"))?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; + provider.put_object(SNAPSHOT_KEY, &encrypted).await?; + + db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) + .await?; + db.set_sync_state( + "last_sync_at", + &chrono::Utc::now().to_rfc3339(), + ) + .await?; + db.set_sync_state("last_sync_result", "success").await?; + + Ok(()) + } + + // ── Private helpers ────────────────────────────────── + + /// Retrieve the stored sync password (decrypted). + async fn get_sync_password(&self, db: &LocalDb) -> Result { + let encrypted = db + .get_sync_state("sync_password_enc") + .await? + .ok_or_else(|| { + "[SYNC_CONFIG_ERROR] Sync password not stored. Please reconfigure sync." + .to_string() + })?; + db.decrypt_sync_password(&encrypted) + } + + async fn load_config(&self, db: &LocalDb) -> Result { + let config_json = db + .get_sync_state("sync_config") + .await? + .ok_or_else(|| "[SYNC_CONFIG_ERROR] Sync not configured".to_string())?; + serde_json::from_str(&config_json) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Parse config: {e}")) + } + + async fn get_device_id(&self, db: &LocalDb) -> Result { + db.get_sync_state("device_id") + .await? + .ok_or_else(|| "[SYNC_CONFIG_ERROR] Device ID not set".to_string()) + } + + /// Export current local data to a SyncSnapshot. + async fn export_snapshot( + &self, + db: &LocalDb, + device_id: &str, + ) -> Result { + let connections = db.list_connections().await?; + let saved_queries = db.list_saved_queries().await?; + let ai_providers = db.list_ai_providers().await?; + + // Decrypt AI API keys for transport (will be re-encrypted on import) + let ai_providers_json: Vec = ai_providers + .iter() + .map(|p| { + let mut val = serde_json::to_value(p).unwrap_or_default(); + if let Some(api_key) = val.get("apiKey").and_then(|v| v.as_str()) { + if LocalDb::has_encrypted_ai_api_key(api_key) { + if let Ok(decrypted) = db.decrypt_ai_api_key(api_key) { + val["apiKey"] = serde_json::Value::String(decrypted); + } + } + } + val + }) + .collect(); + + let data = SyncSnapshotData { + connections: serde_json::to_value(&connections) + .unwrap_or_default() + .as_array() + .cloned() + .unwrap_or_default(), + saved_queries: serde_json::to_value(&saved_queries) + .unwrap_or_default() + .as_array() + .cloned() + .unwrap_or_default(), + ai_providers: ai_providers_json, + settings: serde_json::Value::Object(serde_json::Map::new()), + }; + + let data_json = serde_json::to_vec(&data) + .map_err(|e| format!("[SYNC_CONFIG_ERROR] Serialize data: {e}"))?; + let hash = crypto::snapshot_hash(&data_json); + + Ok(SyncSnapshot { + version: 1, + device_id: device_id.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + snapshot_hash: hash, + data, + }) + } + + /// Import a remote snapshot, overwriting local data. + async fn import_snapshot( + &self, + db: &LocalDb, + snapshot: &SyncSnapshot, + ) -> Result<(), String> { + // Clear and re-import connections + let existing_connections = db.list_connections().await?; + for conn in &existing_connections { + db.delete_connection(conn.id).await?; + } + for conn_val in &snapshot.data.connections { + // Snapshot stores Connection objects (dbType), but create_connection + // expects ConnectionForm (driver). Map the field name. + let mut form_val = conn_val.clone(); + if let Some(db_type) = form_val.get("dbType").cloned() { + form_val.as_object_mut().map(|m| { + m.insert("driver".to_string(), db_type); + m + }); + } + let form: crate::models::ConnectionForm = + serde_json::from_value(form_val) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Parse connection: {e}"))?; + db.create_connection(form).await?; + } + + // Clear and re-import saved queries + let existing_queries = db.list_saved_queries().await?; + for q in &existing_queries { + db.delete_saved_query(q.id).await?; + } + for q_val in &snapshot.data.saved_queries { + let name = q_val + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let query = q_val + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = q_val + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + let connection_id = q_val + .get("connectionId") + .or_else(|| q_val.get("connection_id")) + .and_then(|v| v.as_i64()); + let database = q_val + .get("database") + .and_then(|v| v.as_str()) + .map(String::from); + db.create_saved_query(name, query, description, connection_id, database) + .await?; + } + + // Clear and re-import AI providers + let existing_providers = db.list_ai_providers().await?; + for p in &existing_providers { + db.delete_ai_provider(p.id).await?; + } + for p_val in &snapshot.data.ai_providers { + let form: crate::models::AiProviderForm = + serde_json::from_value(p_val.clone()) + .map_err(|e| format!("[SYNC_MERGE_ERROR] Parse AI provider: {e}"))?; + // API key is plaintext from snapshot, will be encrypted by create_ai_provider + db.create_ai_provider(form).await?; + } + + Ok(()) + } +} diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs new file mode 100644 index 00000000..a5a7bcb8 --- /dev/null +++ b/src-tauri/src/sync/mod.rs @@ -0,0 +1,6 @@ +pub mod crypto; +pub mod manager; +pub mod provider; +pub mod s3; +pub mod scheduler; +pub mod webdav; diff --git a/src-tauri/src/sync/provider.rs b/src-tauri/src/sync/provider.rs new file mode 100644 index 00000000..ec395813 --- /dev/null +++ b/src-tauri/src/sync/provider.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ProviderType { + #[serde(rename = "S3")] + S3, + #[serde(rename = "WebDAV")] + WebDAV, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncConfig { + pub provider_type: ProviderType, + // S3 fields + pub endpoint: Option, + pub region: Option, + pub bucket: Option, + pub access_key_id: Option, + pub secret_access_key: Option, + pub path_prefix: Option, + // WebDAV fields + pub server_url: Option, + pub username: Option, + pub password: Option, + // Auto-sync interval in minutes (default: 5) + pub sync_interval_minutes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncStatus { + pub enabled: bool, + pub provider_type: Option, + pub endpoint: Option, + pub last_sync_at: Option, + pub last_sync_result: Option, + pub device_id: Option, + pub password_stored: bool, + pub sync_interval_minutes: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncResult { + pub action: String, + pub timestamp: String, + pub remote_device_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncSnapshot { + pub version: u32, + pub device_id: String, + pub timestamp: String, + pub snapshot_hash: String, + pub data: SyncSnapshotData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SyncSnapshotData { + pub connections: Vec, + pub saved_queries: Vec, + pub ai_providers: Vec, + pub settings: serde_json::Value, +} + +#[async_trait] +pub trait SyncProvider: Send + Sync { + async fn test_connection(&self) -> Result<(), String>; + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String>; + async fn get_object(&self, key: &str) -> Result>, String>; + async fn delete_object(&self, key: &str) -> Result<(), String>; +} + +/// Build a SyncProvider from a SyncConfig. +pub fn build_provider(config: &SyncConfig) -> Result, String> { + match config.provider_type { + ProviderType::S3 => { + let endpoint = config.endpoint.as_deref().unwrap_or("").trim(); + let region = config.region.as_deref().unwrap_or("").trim(); + let bucket = config.bucket.as_deref().unwrap_or("").trim(); + let access_key_id = config.access_key_id.as_deref().unwrap_or("").trim(); + let secret_access_key = config.secret_access_key.as_deref().unwrap_or("").trim(); + let path_prefix = config.path_prefix.as_deref().unwrap_or("dbpaw/").trim(); + + if endpoint.is_empty() + || bucket.is_empty() + || access_key_id.is_empty() + || secret_access_key.is_empty() + { + return Err( + "[SYNC_CONFIG_ERROR] S3 endpoint, bucket, accessKeyId and secretAccessKey are required" + .to_string(), + ); + } + + Ok(Box::new(crate::sync::s3::S3Provider::new( + endpoint.to_string(), + region.to_string(), + bucket.to_string(), + access_key_id.to_string(), + secret_access_key.to_string(), + path_prefix.to_string(), + ))) + } + ProviderType::WebDAV => { + let server_url = config.server_url.as_deref().unwrap_or("").trim(); + let username = config.username.as_deref().unwrap_or("").trim(); + let password = config.password.as_deref().unwrap_or("").trim(); + + if server_url.is_empty() || username.is_empty() || password.is_empty() { + return Err( + "[SYNC_CONFIG_ERROR] WebDAV serverUrl, username and password are required" + .to_string(), + ); + } + + Ok(Box::new(crate::sync::webdav::WebdavProvider::new( + server_url.to_string(), + username.to_string(), + password.to_string(), + ))) + } + } +} diff --git a/src-tauri/src/sync/s3.rs b/src-tauri/src/sync/s3.rs new file mode 100644 index 00000000..e8915a6e --- /dev/null +++ b/src-tauri/src/sync/s3.rs @@ -0,0 +1,319 @@ +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +pub struct S3Provider { + endpoint: String, + region: String, + bucket: String, + access_key_id: String, + secret_access_key: String, + path_prefix: String, + client: Client, +} + +impl S3Provider { + pub fn new( + endpoint: String, + region: String, + bucket: String, + access_key_id: String, + secret_access_key: String, + path_prefix: String, + ) -> Self { + Self { + endpoint: endpoint.trim_end_matches('/').to_string(), + region: if region.is_empty() { + "us-east-1".to_string() + } else { + region + }, + bucket, + access_key_id, + secret_access_key, + path_prefix: if path_prefix.is_empty() { + "dbpaw/".to_string() + } else { + path_prefix + }, + client: Client::new(), + } + } + + fn object_url(&self, key: &str) -> String { + format!( + "{}/{}/{}{}", + self.endpoint, self.bucket, self.path_prefix, key + ) + } + + fn sign_request( + &self, + method: &str, + url: &url::Url, + headers: &mut Vec<(String, String)>, + payload_hash: &str, + date: &str, + datetime: &str, + ) { + let host = url.host_str().unwrap_or(""); + let path = url.path(); + let query = url.query().unwrap_or(""); + + headers.push(("host".to_string(), host.to_string())); + headers.push(( + "x-amz-content-sha256".to_string(), + payload_hash.to_string(), + )); + headers.push(("x-amz-date".to_string(), datetime.to_string())); + headers.sort_by(|a, b| a.0.cmp(&b.0)); + + let signed_headers: String = headers + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(";"); + let canonical_headers: String = headers + .iter() + .map(|(k, v)| format!("{}:{}", k.to_lowercase(), v.trim())) + .collect::>() + .join("\n"); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n\n{}\n{}", + method, path, query, canonical_headers, signed_headers, payload_hash + ); + + let credential_scope = format!("{}/{}/s3/aws4_request", self.region, date); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + datetime, + credential_scope, + hex::encode(sha256(canonical_request.as_bytes())) + ); + + let signing_key = self.derive_signing_key(date); + let signature = hex::encode(hmac_sha256_bytes(&signing_key, string_to_sign.as_bytes())); + + headers.push(( + "Authorization".to_string(), + format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key_id, credential_scope, signed_headers, signature + ), + )); + } + + fn derive_signing_key(&self, date: &str) -> Vec { + let k_date = hmac_sha256_bytes( + format!("AWS4{}", self.secret_access_key).as_bytes(), + date.as_bytes(), + ); + let k_region = hmac_sha256_bytes(&k_date, self.region.as_bytes()); + let k_service = hmac_sha256_bytes(&k_region, b"s3"); + hmac_sha256_bytes(&k_service, b"aws4_request") + } + + fn now_timestamps() -> (String, String) { + let now = chrono::Utc::now(); + let date = now.format("%Y%m%d").to_string(); + let datetime = now.format("%Y%m%dT%H%M%SZ").to_string(); + (date, datetime) + } +} + +fn sha256(data: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().to_vec() +} + +fn hmac_sha256_bytes(key: &[u8], data: &[u8]) -> Vec { + let mut mac = + HmacSha256::new_from_slice(key).expect("HMAC key length is valid"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +#[async_trait] +impl SyncProvider for S3Provider { + async fn test_connection(&self) -> Result<(), String> { + let url: url::Url = format!("{}/{}/", self.endpoint, self.bucket) + .parse() + .map_err(|e: url::ParseError| { + format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}") + })?; + + let empty_payload_hash = hex::encode(sha256(b"")); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec![( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + )]; + self.sign_request("GET", &url, &mut headers, &empty_payload_hash, &date, &datetime); + + let mut req = self.client.get(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + let status = resp.status(); + if status.is_success() { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] S3 returned {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } + + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String> { + let url: url::Url = self + .object_url(key) + .parse() + .map_err(|e: url::ParseError| { + format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}") + })?; + + let payload_hash = hex::encode(sha256(data)); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec![( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + )]; + self.sign_request("PUT", &url, &mut headers, &payload_hash, &date, &datetime); + + let mut req = self.client.put(url.as_str()).body(data.to_vec()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + let status = resp.status(); + if status.is_success() { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] S3 PUT failed {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } + + async fn get_object(&self, key: &str) -> Result>, String> { + let url: url::Url = self + .object_url(key) + .parse() + .map_err(|e: url::ParseError| { + format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}") + })?; + + let empty_payload_hash = hex::encode(sha256(b"")); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec![( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + )]; + self.sign_request( + "GET", + &url, + &mut headers, + &empty_payload_hash, + &date, + &datetime, + ); + + let mut req = self.client.get(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + let status = resp.status(); + if status.as_u16() == 404 { + return Ok(None); + } + if status.is_success() { + let bytes = resp + .bytes() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] Read body: {e}"))?; + Ok(Some(bytes.to_vec())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] S3 GET failed {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } + + async fn delete_object(&self, key: &str) -> Result<(), String> { + let url: url::Url = self + .object_url(key) + .parse() + .map_err(|e: url::ParseError| { + format!("[SYNC_CONNECTION_ERROR] Invalid URL: {e}") + })?; + + let empty_payload_hash = hex::encode(sha256(b"")); + let (date, datetime) = Self::now_timestamps(); + + let mut headers = vec![( + "Content-Type".to_string(), + "application/octet-stream".to_string(), + )]; + self.sign_request( + "DELETE", + &url, + &mut headers, + &empty_payload_hash, + &date, + &datetime, + ); + + let mut req = self.client.delete(url.as_str()); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = req + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + let status = resp.status(); + if status.is_success() || status.as_u16() == 204 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] S3 DELETE failed {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } +} diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs new file mode 100644 index 00000000..0d327f15 --- /dev/null +++ b/src-tauri/src/sync/scheduler.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{watch, Mutex}; + +const DEFAULT_INTERVAL_MINUTES: u32 = 5; + +/// Background sync scheduler that runs periodic auto-sync and +/// responds to data-change events with debounced push. +pub struct SyncScheduler { + local_db: Arc>>>, + shutdown_tx: watch::Sender, + shutdown_rx: watch::Receiver, +} + +impl SyncScheduler { + pub fn new(local_db: Arc>>>) -> Self { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + Self { + local_db, + shutdown_tx, + shutdown_rx, + } + } + + /// Start the periodic sync loop. Call once during app startup. + pub fn start(&self) { + let local_db = self.local_db.clone(); + let mut shutdown = self.shutdown_rx.clone(); + + tauri::async_runtime::spawn(async move { + loop { + // Read configured interval from DB + let interval_secs = Self::get_sync_interval_secs(&local_db).await; + + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(interval_secs)) => { + let _ = Self::try_auto_sync(&local_db).await; + } + _ = shutdown.changed() => { + if *shutdown.borrow() { + println!("[SYNC_SCHEDULER] Shutting down"); + return; + } + } + } + } + }); + + println!("[SYNC_SCHEDULER] Started"); + } + + /// Notify the scheduler that local data has changed. + /// Triggers a debounced push after a short delay. + pub fn notify_data_changed(&self) { + let local_db = self.local_db.clone(); + + tauri::async_runtime::spawn(async move { + // Debounce: wait 3 seconds before syncing + tokio::time::sleep(Duration::from_secs(3)).await; + let _ = Self::try_auto_sync(&local_db).await; + }); + } + + /// Shut down the scheduler. + pub fn stop(&self) { + let _ = self.shutdown_tx.send(true); + } + + /// Read configured sync interval from DB (in seconds). + async fn get_sync_interval_secs( + local_db: &Arc>>>, + ) -> u64 { + let lock = local_db.lock().await; + if let Some(db) = lock.as_ref() { + if let Ok(Some(val)) = db.get_sync_state("sync_interval_minutes").await { + if let Ok(mins) = val.parse::() { + return (mins.max(1) as u64) * 60; + } + } + } + (DEFAULT_INTERVAL_MINUTES as u64) * 60 + } + + /// Attempt auto-sync if conditions are met (enabled + password stored). + async fn try_auto_sync( + local_db: &Arc>>>, + ) -> Result<(), String> { + let manager = crate::sync::manager::SyncManager::new(local_db.clone()); + + // Check if sync is enabled + let status = manager.get_status().await?; + if !status.enabled || !status.password_stored { + return Ok(()); + } + + // Check if local data has changed + if !manager.has_local_changes().await? { + return Ok(()); + } + + match manager.auto_sync_push().await { + Ok(()) => { + println!("[SYNC_SCHEDULER] Auto-sync push succeeded"); + Ok(()) + } + Err(e) => { + eprintln!("[SYNC_SCHEDULER] Auto-sync failed: {}", e); + Err(e) + } + } + } +} diff --git a/src-tauri/src/sync/webdav.rs b/src-tauri/src/sync/webdav.rs new file mode 100644 index 00000000..5450cb8e --- /dev/null +++ b/src-tauri/src/sync/webdav.rs @@ -0,0 +1,133 @@ +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; +use reqwest::Client; + +pub struct WebdavProvider { + server_url: String, + username: String, + password: String, + client: Client, +} + +impl WebdavProvider { + pub fn new(server_url: String, username: String, password: String) -> Self { + let server_url = if server_url.ends_with('/') { + server_url + } else { + format!("{}/", server_url) + }; + Self { + server_url, + username, + password, + client: Client::new(), + } + } + + fn object_url(&self, key: &str) -> String { + format!("{}{}", self.server_url, key) + } +} + +#[async_trait] +impl SyncProvider for WebdavProvider { + async fn test_connection(&self) -> Result<(), String> { + let url = self.object_url(""); + let resp = self + .client + .request( + reqwest::Method::from_bytes(b"PROPFIND").unwrap(), + &url, + ) + .basic_auth(&self.username, Some(&self.password)) + .header("Depth", "0") + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + let status = resp.status(); + if status.is_success() || status.as_u16() == 207 { + Ok(()) + } else { + Err(format!( + "[SYNC_CONNECTION_ERROR] WebDAV returned {}", + status + )) + } + } + + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), String> { + let url = self.object_url(key); + let resp = self + .client + .put(&url) + .basic_auth(&self.username, Some(&self.password)) + .body(data.to_vec()) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + let status = resp.status(); + if status.is_success() || status.as_u16() == 201 || status.as_u16() == 204 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] WebDAV PUT failed {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } + + async fn get_object(&self, key: &str) -> Result>, String> { + let url = self.object_url(key); + let resp = self + .client + .get(&url) + .basic_auth(&self.username, Some(&self.password)) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + let status = resp.status(); + if status.as_u16() == 404 { + return Ok(None); + } + if status.is_success() { + let bytes = resp + .bytes() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] Read body: {e}"))?; + Ok(Some(bytes.to_vec())) + } else { + Err(format!( + "[SYNC_CONNECTION_ERROR] WebDAV GET failed {}", + status + )) + } + } + + async fn delete_object(&self, key: &str) -> Result<(), String> { + let url = self.object_url(key); + let resp = self + .client + .delete(&url) + .basic_auth(&self.username, Some(&self.password)) + .send() + .await + .map_err(|e| format!("[SYNC_CONNECTION_ERROR] {e}"))?; + + let status = resp.status(); + if status.is_success() || status.as_u16() == 204 || status.as_u16() == 404 { + Ok(()) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(format!( + "[SYNC_CONNECTION_ERROR] WebDAV DELETE failed {}: {}", + status, + body.chars().take(200).collect::() + )) + } + } +} diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index 2314077c..9c8baeda 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -54,6 +54,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Cloud } from "lucide-react"; +import { SyncSettings } from "./SyncSettings"; import packageJson from "../../../package.json"; import { LanguageSelector } from "./LanguageSelector"; import { useTranslation } from "react-i18next"; @@ -75,7 +77,7 @@ interface SettingsDialogProps { onShowZebraStripesChange?: (v: boolean) => void; } -type SettingsSection = "general" | "layout" | "ai" | "shortcuts" | "about"; +type SettingsSection = "general" | "layout" | "ai" | "shortcuts" | "sync" | "about"; type AIProviderPreset = { type: AIProviderType; label: string; @@ -548,6 +550,17 @@ export function SettingsDialog({ {t("settings.sections.shortcuts")} + + {!status?.enabled ? ( + + ) : !status?.passwordStored ? ( + + ) : null} + {status?.enabled && ( + + )} + + + + {/* Sync Status */} + {status && ( +
+
+ {t("settings.sync.status")} +
+ {status.deviceId && ( +
Device ID: {status.deviceId.slice(0, 8)}...
+ )} +
+ {t("settings.sync.syncInterval")}: {status.syncIntervalMinutes} min +
+ {status.lastSyncAt ? ( +
+ {t("settings.sync.lastSync")}:{" "} + {new Date(status.lastSyncAt).toLocaleString()} + {status.lastSyncResult === "success" + ? " ✓" + : ` ✗ ${status.lastSyncResult}`} +
+ ) : ( +
{t("settings.sync.noSyncYet")}
+ )} + {status.enabled && status.passwordStored && ( +
+ + + +
+ )} +
+ )} + + ); +} diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f0b5cdc2..fe603152 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -83,6 +83,7 @@ export const en = { layout: "Layout", ai: "AI", shortcuts: "Shortcuts", + sync: "Sync", about: "About", }, language: { @@ -113,9 +114,11 @@ export const en = { showColumnCommentsDescription: "Display column comments in small text below the column name in table headers", showRowNumbers: "Show Row Numbers", - showRowNumbersDescription: "Display row number column on the left side of the table", + showRowNumbersDescription: + "Display row number column on the left side of the table", showZebraStripes: "Show Zebra Stripes", - showZebraStripesDescription: "Alternate row background colors for better readability", + showZebraStripesDescription: + "Alternate row background colors for better readability", filter: { title: "Filter", }, @@ -183,8 +186,7 @@ export const en = { }, shortcuts: { title: "Shortcuts", - hint: - "Click Record, then press the new keys. Modifiers (Cmd / Ctrl / Alt / Shift) are required for new bindings.", + hint: "Click Record, then press the new keys. Modifiers (Cmd / Ctrl / Alt / Shift) are required for new bindings.", loading: "Loading shortcuts…", record: "Record", recording: "Press keys…", @@ -198,7 +200,8 @@ export const en = { enable: "Enable", disabled: "Disabled", errorNoModifier: "At least one modifier is required", - conflictPrompt: "This shortcut is already used by “{{other}}”. Replace it?", + conflictPrompt: + "This shortcut is already used by “{{other}}”. Replace it?", confirmReplace: "Replace", group: { global: "Global", @@ -233,6 +236,37 @@ export const en = { license: "License", platforms: "Platforms", }, + sync: { + title: "Config Sync", + provider: "Sync Provider", + syncPassword: "Sync Password", + testConnection: "Test Connection", + saveAndEnable: "Save & Enable", + disable: "Disable", + testSuccess: "Connection successful", + testFailed: "Connection failed", + passwordTooShort: "Password must be at least 6 characters", + passwordMismatch: "Passwords do not match", + configured: "Sync configured and enabled", + configureFailed: "Failed to configure sync", + enterPassword: "Enter your sync password", + synced: "Sync: {{action}}", + syncFailed: "Sync failed", + syncNow: "Sync Now", + forcePush: "Force Push", + forcePull: "Force Pull", + forcePushed: "Force pushed to remote", + forcePushFailed: "Force push failed", + forcePulled: "Force pulled from remote", + forcePullFailed: "Force pull failed", + disabled: "Sync disabled", + disableFailed: "Failed to disable sync", + status: "Sync Status", + lastSync: "Last sync", + noSyncYet: "Not synced yet", + syncInterval: "Auto-sync Interval", + passwordNotStored: "Sync password not stored. Please reconfigure sync.", + }, }, connection: { title: "Connections", diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index f237f139..ee9125cc 100644 --- a/src/lib/i18n/locales/ja.ts +++ b/src/lib/i18n/locales/ja.ts @@ -85,6 +85,7 @@ export const ja: Translations = { layout: "レイアウト", ai: "AI", shortcuts: "ショートカット", + sync: "同期", about: "情報", }, language: { @@ -117,7 +118,8 @@ export const ja: Translations = { showRowNumbers: "行番号を表示", showRowNumbersDescription: "テーブルの左側に行番号列を表示します", showZebraStripes: "ゼブラストライプを表示", - showZebraStripesDescription: "奇数行と偶数行で背景色を変えて読みやすくします", + showZebraStripesDescription: + "奇数行と偶数行で背景色を変えて読みやすくします", filter: { title: "フィルター", }, @@ -199,7 +201,8 @@ export const ja: Translations = { enable: "有効化", disabled: "無効", errorNoModifier: "修飾キーが1つ以上必要です", - conflictPrompt: "このショートカットは「{{other}}」で既に使われています。置き換えますか?", + conflictPrompt: + "このショートカットは「{{other}}」で既に使われています。置き換えますか?", confirmReplace: "置き換え", group: { global: "グローバル", @@ -234,6 +237,38 @@ export const ja: Translations = { license: "ライセンス", platforms: "対応プラットフォーム", }, + sync: { + title: "設定同期", + provider: "同期プロバイダー", + syncPassword: "同期パスワード", + testConnection: "接続テスト", + saveAndEnable: "保存して有効化", + disable: "無効化", + testSuccess: "接続に成功しました", + testFailed: "接続に失敗しました", + passwordTooShort: "パスワードは6文字以上必要です", + passwordMismatch: "パスワードが一致しません", + configured: "同期が設定され有効になりました", + configureFailed: "同期の設定に失敗しました", + enterPassword: "同期パスワードを入力してください", + synced: "同期完了:{{action}}", + syncFailed: "同期に失敗しました", + syncNow: "今すぐ同期", + forcePush: "強制アップロード", + forcePull: "強制ダウンロード", + forcePushed: "リモートに強制アップロードしました", + forcePushFailed: "強制アップロードに失敗しました", + forcePulled: "リモートから強制ダウンロードしました", + forcePullFailed: "強制ダウンロードに失敗しました", + disabled: "同期を無効にしました", + disableFailed: "同期の無効化に失敗しました", + status: "同期ステータス", + lastSync: "最終同期", + noSyncYet: "未同期", + syncInterval: "自動同期間隔", + passwordNotStored: + "同期パスワードが保存されていません。同期を再設定してください。", + }, }, connection: { title: "接続", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 9b4fcce7..69ee271a 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -84,6 +84,7 @@ export const zh: Translations = { layout: "布局", ai: "AI", shortcuts: "快捷键", + sync: "同步", about: "关于", }, language: { @@ -229,6 +230,37 @@ export const zh: Translations = { license: "许可证", platforms: "支持平台", }, + sync: { + title: "配置同步", + provider: "同步服务", + syncPassword: "同步密码", + testConnection: "测试连接", + saveAndEnable: "保存并启用", + disable: "禁用", + testSuccess: "连接成功", + testFailed: "连接失败", + passwordTooShort: "密码至少需要6个字符", + passwordMismatch: "两次输入的密码不一致", + configured: "同步已配置并启用", + configureFailed: "配置同步失败", + enterPassword: "请输入同步密码", + synced: "同步完成:{{action}}", + syncFailed: "同步失败", + syncNow: "立即同步", + forcePush: "强制上传", + forcePull: "强制下载", + forcePushed: "已强制上传到远程", + forcePushFailed: "强制上传失败", + forcePulled: "已从远程强制下载", + forcePullFailed: "强制下载失败", + disabled: "同步已禁用", + disableFailed: "禁用同步失败", + status: "同步状态", + lastSync: "上次同步", + noSyncYet: "尚未同步", + syncInterval: "自动同步间隔", + passwordNotStored: "同步密码未存储,请重新配置同步。", + }, }, connection: { title: "连接", diff --git a/src/services/api.ts b/src/services/api.ts index 2d5eddc0..7ee4054b 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -446,6 +446,42 @@ export const getImportDriverCapability = ( const config = DRIVER_REGISTRY.find((d) => d.id === normalized); return config?.importCapability ?? "unsupported"; }; + +// ── Sync types ──────────────────────────────────────────── + +export type SyncProviderType = "S3" | "WebDAV"; + +export interface SyncConfig { + providerType: SyncProviderType; + endpoint?: string; + region?: string; + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + pathPrefix?: string; + serverUrl?: string; + username?: string; + password?: string; + syncIntervalMinutes?: number; +} + +export interface SyncStatus { + enabled: boolean; + providerType?: SyncProviderType; + endpoint?: string; + lastSyncAt?: string; + lastSyncResult?: string; + deviceId?: string; + passwordStored: boolean; + syncIntervalMinutes: number; +} + +export interface SyncResult { + action: string; + timestamp: string; + remoteDeviceId?: string; +} + export interface ConnectionForm { driver: Driver; name?: string; @@ -917,7 +953,11 @@ export const api = { getSchemaOverview: (id: number, database?: string, schema?: string) => invoke("get_schema_overview", { id, database, schema }), getSchemaForeignKeys: (id: number, database?: string, schema?: string) => - invoke("get_schema_foreign_keys", { id, database, schema }), + invoke("get_schema_foreign_keys", { + id, + database, + schema, + }), listEvents: (connectionId: string, database: string) => invoke("list_events", { connectionId, database }), listSequences: (connectionId: string, database: string) => @@ -1733,4 +1773,18 @@ export const api = { system: { listFonts: () => invoke("list_system_fonts"), }, + sync: { + testConnection: (config: SyncConfig): Promise => + invoke("sync_test_connection", { config }), + configure: (config: SyncConfig, syncPassword: string): Promise => + invoke("sync_configure", { config, syncPassword }), + getStatus: (): Promise => invoke("sync_get_status"), + getConfig: (): Promise => invoke("sync_get_config"), + syncNow: (): Promise => invoke("sync_now"), + forcePush: (): Promise => invoke("sync_force_push"), + forcePull: (): Promise => invoke("sync_force_pull"), + disable: (): Promise => invoke("sync_disable"), + updatePassword: (oldPassword: string, newPassword: string): Promise => + invoke("sync_update_password", { oldPassword, newPassword }), + }, };