From 7a519238a0e85a8c942c31b4e068a8d68a1da892 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:08:42 +0800 Subject: [PATCH 01/21] docs: add config sync design spec Design for cross-device configuration synchronization supporting S3 and WebDAV backends with end-to-end encryption (PBKDF2 + AES-256-GCM), hybrid auto/manual sync mode, and Last-Write-Wins conflict resolution. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-06-02-config-sync-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-config-sync-design.md 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 0000000..92c2c37 --- /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 +``` From 4d450adb909dc399373a5146716e7c3e0b5acc8e Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:18:35 +0800 Subject: [PATCH 02/21] docs: add config sync implementation plan 10-task plan covering Rust backend (crypto, S3, WebDAV providers, SyncManager, Tauri commands) and React frontend (sync settings panel, API layer). Follows TDD with bite-sized commits. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-06-02-config-sync.md | 2029 +++++++++++++++++ 1 file changed, 2029 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-config-sync.md 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 0000000..4fb39bf --- /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) +``` From 0560e5d7ba074899dc47d7373626b6166ea04739 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:23:38 +0800 Subject: [PATCH 03/21] feat(sync): add sync_state migration and LocalDb CRUD methods Add sha2, hmac, pbkdf2, hex dependencies for sync crypto. Create sync_state table migration for storing sync configuration, device ID, and sync metadata. Add get/set/delete CRUD methods. Co-Authored-By: Claude Opus 4.7 --- src-tauri/Cargo.toml | 4 ++ src-tauri/migrations/017_sync_state.sql | 5 +++ src-tauri/src/db/local.rs | 51 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src-tauri/migrations/017_sync_state.sql diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d38216..e373a55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,10 @@ 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" [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 0000000..5428821 --- /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/db/local.rs b/src-tauri/src/db/local.rs index e5cd98c..e8ab2b2 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, @@ -794,6 +808,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 +1236,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) From 61611fa78e17d4162f41d514bd86e7f494d9eb8c Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:27:49 +0800 Subject: [PATCH 04/21] feat(sync): add SyncProvider trait and config types Define SyncProvider trait with test_connection/put/get/delete. Add SyncConfig, SyncStatus, SyncResult, SyncSnapshot types. Include build_provider factory and stub S3/WebDAV implementations. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/lib.rs | 1 + src-tauri/src/sync/crypto.rs | 1 + src-tauri/src/sync/manager.rs | 1 + src-tauri/src/sync/mod.rs | 5 ++ src-tauri/src/sync/provider.rs | 124 +++++++++++++++++++++++++++++++++ src-tauri/src/sync/s3.rs | 26 +++++++ src-tauri/src/sync/webdav.rs | 19 +++++ 7 files changed, 177 insertions(+) create mode 100644 src-tauri/src/sync/crypto.rs create mode 100644 src-tauri/src/sync/manager.rs create mode 100644 src-tauri/src/sync/mod.rs create mode 100644 src-tauri/src/sync/provider.rs create mode 100644 src-tauri/src/sync/s3.rs create mode 100644 src-tauri/src/sync/webdav.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e587c64..022a3ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -298,6 +298,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/sync/crypto.rs b/src-tauri/src/sync/crypto.rs new file mode 100644 index 0000000..43aeb5b --- /dev/null +++ b/src-tauri/src/sync/crypto.rs @@ -0,0 +1 @@ +// Stub — will be implemented in Task 3 diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs new file mode 100644 index 0000000..61472bb --- /dev/null +++ b/src-tauri/src/sync/manager.rs @@ -0,0 +1 @@ +// Stub — will be implemented in Task 6 diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs new file mode 100644 index 0000000..a5b3ec6 --- /dev/null +++ b/src-tauri/src/sync/mod.rs @@ -0,0 +1,5 @@ +pub mod crypto; +pub mod manager; +pub mod provider; +pub mod s3; +pub mod webdav; diff --git a/src-tauri/src/sync/provider.rs b/src-tauri/src/sync/provider.rs new file mode 100644 index 0000000..db4625d --- /dev/null +++ b/src-tauri/src/sync/provider.rs @@ -0,0 +1,124 @@ +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(), + ))) + } + } +} diff --git a/src-tauri/src/sync/s3.rs b/src-tauri/src/sync/s3.rs new file mode 100644 index 0000000..4ad8f65 --- /dev/null +++ b/src-tauri/src/sync/s3.rs @@ -0,0 +1,26 @@ +// Stub — will be implemented in Task 4 +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; + +pub struct S3Provider; + +impl S3Provider { + pub fn new( + _endpoint: String, + _region: String, + _bucket: String, + _access_key_id: String, + _secret_access_key: String, + _path_prefix: String, + ) -> Self { + Self + } +} + +#[async_trait] +impl SyncProvider for S3Provider { + async fn test_connection(&self) -> Result<(), String> { todo!() } + async fn put_object(&self, _key: &str, _data: &[u8]) -> Result<(), String> { todo!() } + async fn get_object(&self, _key: &str) -> Result>, String> { todo!() } + async fn delete_object(&self, _key: &str) -> Result<(), String> { todo!() } +} diff --git a/src-tauri/src/sync/webdav.rs b/src-tauri/src/sync/webdav.rs new file mode 100644 index 0000000..afeedb5 --- /dev/null +++ b/src-tauri/src/sync/webdav.rs @@ -0,0 +1,19 @@ +// Stub — will be implemented in Task 5 +use crate::sync::provider::SyncProvider; +use async_trait::async_trait; + +pub struct WebdavProvider; + +impl WebdavProvider { + pub fn new(_server_url: String, _username: String, _password: String) -> Self { + Self + } +} + +#[async_trait] +impl SyncProvider for WebdavProvider { + async fn test_connection(&self) -> Result<(), String> { todo!() } + async fn put_object(&self, _key: &str, _data: &[u8]) -> Result<(), String> { todo!() } + async fn get_object(&self, _key: &str) -> Result>, String> { todo!() } + async fn delete_object(&self, _key: &str) -> Result<(), String> { todo!() } +} From f4b535f0b6784c6bb3d6c51285c5fcec3830091f Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:30:55 +0800 Subject: [PATCH 05/21] feat(sync): add crypto engine with PBKDF2 + AES-256-GCM PBKDF2-SHA256 key derivation (600k iterations) with AES-256-GCM encryption for sync snapshots. Includes snapshot_hash for change detection and encrypt_with_key/decrypt_with_key for local credential storage. All 5 unit tests passing. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/sync/crypto.rs | 169 ++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/sync/crypto.rs b/src-tauri/src/sync/crypto.rs index 43aeb5b..456665f 100644 --- a/src-tauri/src/sync/crypto.rs +++ b/src-tauri/src/sync/crypto.rs @@ -1 +1,168 @@ -// Stub — will be implemented in Task 3 +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()); + } +} From c84be606da4d64174ad3df4479bd07be8140675b Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:35:34 +0800 Subject: [PATCH 06/21] feat(sync): add S3 provider with AWS Signature V4 Implements SyncProvider for S3-compatible storage using reqwest with manual AWS Signature V4 signing. Supports any S3-compatible endpoint (AWS, MinIO, OSS, etc.). Added url crate dependency. Co-Authored-By: Claude Opus 4.7 --- src-tauri/Cargo.toml | 1 + src-tauri/src/sync/s3.rs | 319 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 307 insertions(+), 13 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e373a55..655a2ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -72,6 +72,7 @@ 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/src/sync/s3.rs b/src-tauri/src/sync/s3.rs index 4ad8f65..e8915a6 100644 --- a/src-tauri/src/sync/s3.rs +++ b/src-tauri/src/sync/s3.rs @@ -1,26 +1,319 @@ -// Stub — will be implemented in Task 4 use crate::sync::provider::SyncProvider; use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sha2::{Digest, Sha256}; -pub struct S3Provider; +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, + endpoint: String, + region: String, + bucket: String, + access_key_id: String, + secret_access_key: String, + path_prefix: String, ) -> Self { - 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> { todo!() } - async fn put_object(&self, _key: &str, _data: &[u8]) -> Result<(), String> { todo!() } - async fn get_object(&self, _key: &str) -> Result>, String> { todo!() } - async fn delete_object(&self, _key: &str) -> Result<(), String> { todo!() } + 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::() + )) + } + } } From ff777bff28589d8fedc2d3e3531ea5eaa24b4215 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:36:22 +0800 Subject: [PATCH 07/21] feat(sync): add WebDAV provider Implements SyncProvider for WebDAV servers using reqwest with basic auth. Supports PROPFIND for connection testing and standard HTTP PUT/GET/DELETE for object operations. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/sync/webdav.rs | 130 ++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/sync/webdav.rs b/src-tauri/src/sync/webdav.rs index afeedb5..5450cb8 100644 --- a/src-tauri/src/sync/webdav.rs +++ b/src-tauri/src/sync/webdav.rs @@ -1,19 +1,133 @@ -// Stub — will be implemented in Task 5 use crate::sync::provider::SyncProvider; use async_trait::async_trait; +use reqwest::Client; -pub struct WebdavProvider; +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 { - Self + 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> { todo!() } - async fn put_object(&self, _key: &str, _data: &[u8]) -> Result<(), String> { todo!() } - async fn get_object(&self, _key: &str) -> Result>, String> { todo!() } - async fn delete_object(&self, _key: &str) -> Result<(), String> { todo!() } + 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::() + )) + } + } } From 79cbcde4a4b129720750a65617575db69ecb27b4 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:37:50 +0800 Subject: [PATCH 08/21] feat(sync): add SyncManager with export/import/merge logic Core sync orchestration: export_snapshot serializes local data (connections, queries, AI providers) to encrypted snapshots. import_snapshot clears local tables and re-imports from remote. Supports configure, sync_now, force_push, force_pull, and auto_sync_push with change detection via SHA-256 hash comparison. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/sync/manager.rs | 462 +++++++++++++++++++++++++++++++++- 1 file changed, 461 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs index 61472bb..efa3e6f 100644 --- a/src-tauri/src/sync/manager.rs +++ b/src-tauri/src/sync/manager.rs @@ -1 +1,461 @@ -// Stub — will be implemented in Task 6 +use crate::db::local::LocalDb; +use crate::sync::crypto; +use crate::sync::provider::{ + build_provider, ProviderType, SyncConfig, SyncResult, SyncSnapshot, SyncSnapshotData, + SyncStatus, +}; +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 + 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 password hash for verification + 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)?; + + 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, 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)?; + + // 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 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 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 { + 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 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(()) + } +} From 8fbe2519dc83df56224062c44405d4f28dbcc966 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:40:18 +0800 Subject: [PATCH 09/21] feat(sync): add Tauri commands and register in invoke handler Add 8 sync commands: test_connection, configure, get_status, sync_now, force_push, force_pull, disable, update_password. Wrap local_db in Arc in AppState for SyncManager access. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sync.rs | 69 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 8 ++++ src-tauri/src/state.rs | 4 +- 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/sync.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 01cb9fc..8a64cbc 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/sync.rs b/src-tauri/src/commands/sync.rs new file mode 100644 index 0000000..cadf279 --- /dev/null +++ b/src-tauri/src/commands/sync.rs @@ -0,0 +1,69 @@ +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_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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 022a3ea..9e78689 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -270,6 +270,14 @@ 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_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"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5f31c55..7369d5f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -5,7 +5,7 @@ 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, } @@ -13,7 +13,7 @@ pub struct AppState { impl AppState { pub fn new() -> Self { Self { - local_db: Mutex::new(None), + local_db: Arc::new(Mutex::new(None)), pool_manager: Arc::new(PoolManager::new()), redis_cache: Mutex::new(RedisConnectionCache::new()), } From 6bd1909932bb77805554ada8fb9b0539633c238a Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:43:56 +0800 Subject: [PATCH 10/21] feat(sync): add sync API types and invoke wrappers Add SyncConfig, SyncStatus, SyncResult types and syncApi namespace with testConnection, configure, getStatus, syncNow, forcePush, forcePull, disable, and updatePassword methods. Co-Authored-By: Claude Opus 4.7 --- src/services/api.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/services/api.ts b/src/services/api.ts index 2d5eddc..ea92236 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -446,6 +446,39 @@ 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; +} + +export interface SyncStatus { + enabled: boolean; + providerType?: SyncProviderType; + endpoint?: string; + lastSyncAt?: string; + lastSyncResult?: string; + deviceId?: string; +} + +export interface SyncResult { + action: string; + timestamp: string; + remoteDeviceId?: string; +} + export interface ConnectionForm { driver: Driver; name?: string; @@ -1733,4 +1766,22 @@ 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"), + 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 }), + }, }; From f8133991b97c55f4adbdb35f6222e3c14fb6f73e Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:46:25 +0800 Subject: [PATCH 11/21] feat(sync): add SyncSettings component and Settings tab Add SyncSettings panel with provider configuration (S3/WebDAV), sync password setup, test connection, and sync status display. Integrate as new "Sync" tab in SettingsDialog with Cloud icon. Co-Authored-By: Claude Opus 4.7 --- src/components/settings/SettingsDialog.tsx | 19 +- src/components/settings/SyncSettings.tsx | 440 +++++++++++++++++++++ 2 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/SyncSettings.tsx diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index 2314077..9c8baed 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 && ( + + )} + + + + {/* 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 && ( +
+ + + +
+ )} +
+ )} + + ); +} From adde35b7c84ff73179c6eea5645e956567c22e5b Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 09:48:45 +0800 Subject: [PATCH 12/21] style(sync): format with prettier Co-Authored-By: Claude Opus 4.7 --- src/components/settings/SyncSettings.tsx | 4 +--- src/services/api.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/settings/SyncSettings.tsx b/src/components/settings/SyncSettings.tsx index 4531059..044d774 100644 --- a/src/components/settings/SyncSettings.tsx +++ b/src/components/settings/SyncSettings.tsx @@ -385,9 +385,7 @@ export function SyncSettings() { {t("settings.sync.status", { defaultValue: "Sync Status" })} {status.deviceId && ( -
- Device ID: {status.deviceId.slice(0, 8)}... -
+
Device ID: {status.deviceId.slice(0, 8)}...
)} {status.lastSyncAt && (
diff --git a/src/services/api.ts b/src/services/api.ts index ea92236..5e13792 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -950,7 +950,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) => @@ -1771,16 +1775,14 @@ export const api = { invoke("sync_test_connection", { config }), configure: (config: SyncConfig, syncPassword: string): Promise => invoke("sync_configure", { config, syncPassword }), - getStatus: (): Promise => - invoke("sync_get_status"), + 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"), + disable: (): Promise => invoke("sync_disable"), updatePassword: (oldPassword: string, newPassword: string): Promise => invoke("sync_update_password", { oldPassword, newPassword }), }, From b5c0f800b1e0fef7ae142564c900e6c8aeaf3724 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 10:44:16 +0800 Subject: [PATCH 13/21] fix(sync): remove unused SyncProvider import in manager Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/sync/manager.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs index efa3e6f..df5d303 100644 --- a/src-tauri/src/sync/manager.rs +++ b/src-tauri/src/sync/manager.rs @@ -4,7 +4,6 @@ use crate::sync::provider::{ build_provider, ProviderType, SyncConfig, SyncResult, SyncSnapshot, SyncSnapshotData, SyncStatus, }; -use crate::sync::provider::SyncProvider; use std::sync::Arc; use tokio::sync::Mutex; From de7acb1543a79af2cd09e2a9622b321918e29e7b Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 10:46:17 +0800 Subject: [PATCH 14/21] fix(sync): use explicit serde rename for ProviderType enum The rename_all="camelCase" converted WebDAV to "webDAV" which didn't match the frontend's "WebDAV". Use explicit #[serde(rename)] to ensure exact string match: "S3" and "WebDAV". Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/sync/provider.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/sync/provider.rs b/src-tauri/src/sync/provider.rs index db4625d..b8fac56 100644 --- a/src-tauri/src/sync/provider.rs +++ b/src-tauri/src/sync/provider.rs @@ -2,9 +2,10 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] pub enum ProviderType { + #[serde(rename = "S3")] S3, + #[serde(rename = "WebDAV")] WebDAV, } From 62e2eaadac628d29dcaeeffd43d99a9243954734 Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 10:59:24 +0800 Subject: [PATCH 15/21] fix(sync): add i18n support and config echo-back for sync settings Add proper internationalization keys for all sync UI text in en/zh/ja locales, replacing defaultValue fallbacks. Add sync_get_config backend command to retrieve saved config so form fields are populated when reopening the Sync settings tab. Co-Authored-By: Claude Opus 4.7 --- src-tauri/Cargo.lock | 6 + src-tauri/src/commands/sync.rs | 6 + src-tauri/src/lib.rs | 1 + src-tauri/src/sync/manager.rs | 13 ++ src/components/settings/SyncSettings.tsx | 196 ++++++++--------------- src/lib/i18n/locales/en.ts | 42 ++++- src/lib/i18n/locales/ja.ts | 36 ++++- src/lib/i18n/locales/zh.ts | 30 ++++ src/services/api.ts | 1 + 9 files changed, 197 insertions(+), 134 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb6ff7d..af09a06 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/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index cadf279..835bcb1 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -25,6 +25,12 @@ pub async fn sync_get_status(state: State<'_, AppState>) -> Result) -> Result, String> { + let manager = SyncManager::new(state.local_db.clone()); + manager.get_config().await +} + #[tauri::command] pub async fn sync_now( state: State<'_, AppState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e78689..d180745 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,7 @@ pub fn run() { 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, diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs index df5d303..3b2b36d 100644 --- a/src-tauri/src/sync/manager.rs +++ b/src-tauri/src/sync/manager.rs @@ -130,6 +130,19 @@ impl SyncManager { 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, sync_password: &str) -> Result { let db = self.get_db().await?; diff --git a/src/components/settings/SyncSettings.tsx b/src/components/settings/SyncSettings.tsx index 044d774..14848ff 100644 --- a/src/components/settings/SyncSettings.tsx +++ b/src/components/settings/SyncSettings.tsx @@ -47,9 +47,34 @@ export function SyncSettings() { } }, []); + // Load saved config and populate form fields + const loadConfig = useCallback(async () => { + try { + const config = await api.sync.getConfig(); + if (config) { + setProviderType(config.providerType); + if (config.providerType === "S3") { + setEndpoint(config.endpoint ?? ""); + setRegion(config.region ?? "us-east-1"); + setBucket(config.bucket ?? ""); + setAccessKeyId(config.accessKeyId ?? ""); + setSecretAccessKey(config.secretAccessKey ?? ""); + setPathPrefix(config.pathPrefix ?? "dbpaw/"); + } else { + setServerUrl(config.serverUrl ?? ""); + setUsername(config.username ?? ""); + setPassword(config.password ?? ""); + } + } + } catch (e) { + console.error("Failed to load sync config:", e); + } + }, []); + useEffect(() => { loadStatus(); - }, [loadStatus]); + loadConfig(); + }, [loadStatus, loadConfig]); const buildConfig = (): SyncConfig => { if (providerType === "S3") { @@ -75,18 +100,11 @@ export function SyncSettings() { setLoading(true); try { await api.sync.testConnection(buildConfig()); - toast.success( - t("settings.sync.testSuccess", { - defaultValue: "Connection successful", - }), - ); + toast.success(t("settings.sync.testSuccess")); } catch (e) { - toast.error( - t("settings.sync.testFailed", { defaultValue: "Connection failed" }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.testFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -94,39 +112,22 @@ export function SyncSettings() { const handleConfigure = async () => { if (!syncPassword || syncPassword.length < 6) { - toast.error( - t("settings.sync.passwordTooShort", { - defaultValue: "Password must be at least 6 characters", - }), - ); + toast.error(t("settings.sync.passwordTooShort")); return; } if (syncPassword !== confirmPassword) { - toast.error( - t("settings.sync.passwordMismatch", { - defaultValue: "Passwords do not match", - }), - ); + toast.error(t("settings.sync.passwordMismatch")); return; } setLoading(true); try { await api.sync.configure(buildConfig(), syncPassword); - toast.success( - t("settings.sync.configured", { - defaultValue: "Sync configured and enabled", - }), - ); + toast.success(t("settings.sync.configured")); loadStatus(); } catch (e) { - toast.error( - t("settings.sync.configureFailed", { - defaultValue: "Failed to configure sync", - }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.configureFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -134,29 +135,18 @@ export function SyncSettings() { const handleSyncNow = async () => { if (!syncPassword) { - toast.error( - t("settings.sync.enterPassword", { - defaultValue: "Enter your sync password", - }), - ); + toast.error(t("settings.sync.enterPassword")); return; } setLoading(true); try { const result = await api.sync.syncNow(syncPassword); - toast.success( - t("settings.sync.synced", { - defaultValue: `Sync: ${result.action}`, - }), - ); + toast.success(t("settings.sync.synced", { action: result.action })); loadStatus(); } catch (e) { - toast.error( - t("settings.sync.syncFailed", { defaultValue: "Sync failed" }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.syncFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -164,31 +154,18 @@ export function SyncSettings() { const handleForcePush = async () => { if (!syncPassword) { - toast.error( - t("settings.sync.enterPassword", { - defaultValue: "Enter your sync password", - }), - ); + toast.error(t("settings.sync.enterPassword")); return; } setLoading(true); try { await api.sync.forcePush(syncPassword); - toast.success( - t("settings.sync.forcePushed", { - defaultValue: "Force pushed to remote", - }), - ); + toast.success(t("settings.sync.forcePushed")); loadStatus(); } catch (e) { - toast.error( - t("settings.sync.forcePushFailed", { - defaultValue: "Force push failed", - }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.forcePushFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -196,31 +173,18 @@ export function SyncSettings() { const handleForcePull = async () => { if (!syncPassword) { - toast.error( - t("settings.sync.enterPassword", { - defaultValue: "Enter your sync password", - }), - ); + toast.error(t("settings.sync.enterPassword")); return; } setLoading(true); try { await api.sync.forcePull(syncPassword); - toast.success( - t("settings.sync.forcePulled", { - defaultValue: "Force pulled from remote", - }), - ); + toast.success(t("settings.sync.forcePulled")); loadStatus(); } catch (e) { - toast.error( - t("settings.sync.forcePullFailed", { - defaultValue: "Force pull failed", - }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.forcePullFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -230,19 +194,12 @@ export function SyncSettings() { setLoading(true); try { await api.sync.disable(); - toast.success( - t("settings.sync.disabled", { defaultValue: "Sync disabled" }), - ); + toast.success(t("settings.sync.disabled")); loadStatus(); } catch (e) { - toast.error( - t("settings.sync.disableFailed", { - defaultValue: "Failed to disable sync", - }), - { - description: e instanceof Error ? e.message : String(e), - }, - ); + toast.error(t("settings.sync.disableFailed"), { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoading(false); } @@ -251,17 +208,12 @@ export function SyncSettings() { return (

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

{/* Provider Configuration */}
- + - {t("settings.sync.testConnection", { - defaultValue: "Test Connection", - })} + {t("settings.sync.testConnection")} {status?.enabled && ( )}
@@ -382,19 +326,21 @@ export function SyncSettings() { {status && (
- {t("settings.sync.status", { defaultValue: "Sync Status" })} + {t("settings.sync.status")}
{status.deviceId && (
Device ID: {status.deviceId.slice(0, 8)}...
)} - {status.lastSyncAt && ( + {status.lastSyncAt ? (
- {t("settings.sync.lastSync", { defaultValue: "Last sync" })}:{" "} + {t("settings.sync.lastSync")}:{" "} {new Date(status.lastSyncAt).toLocaleString()} {status.lastSyncResult === "success" ? " ✓" : ` ✗ ${status.lastSyncResult}`}
+ ) : ( +
{t("settings.sync.noSyncYet")}
)} {status.enabled && (
@@ -405,7 +351,7 @@ export function SyncSettings() { disabled={loading} > - {t("settings.sync.syncNow", { defaultValue: "Sync Now" })} + {t("settings.sync.syncNow")}
)} diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f0b5cdc..bbf0cdb 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,35 @@ 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", + }, }, connection: { title: "Connections", diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index f237f13..efd6e86 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,35 @@ 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: "未同期", + }, }, connection: { title: "接続", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 9b4fcce..2c1a5a8 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,35 @@ 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: "尚未同步", + }, }, connection: { title: "连接", diff --git a/src/services/api.ts b/src/services/api.ts index 5e13792..7a4779b 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1776,6 +1776,7 @@ export const api = { configure: (config: SyncConfig, syncPassword: string): Promise => invoke("sync_configure", { config, syncPassword }), getStatus: (): Promise => invoke("sync_get_status"), + getConfig: (): Promise => invoke("sync_get_config"), syncNow: (syncPassword: string): Promise => invoke("sync_now", { syncPassword }), forcePush: (syncPassword: string): Promise => From cc2edcdfb8de4d6e156e69e558e4b82ec8d5d4ab Mon Sep 17 00:00:00 2001 From: mrhua Date: Tue, 2 Jun 2026 11:20:50 +0800 Subject: [PATCH 16/21] fix(sync): persist sync password locally so users don't need to re-enter it Encrypt the sync password using the existing ai_master_key and store it in sync_state table. Sync Now, Force Push, and Force Pull operations now automatically retrieve the stored password instead of requiring manual input each time. Password fields are hidden from the UI when sync is already enabled. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/commands/sync.rs | 21 +++------- src-tauri/src/db/local.rs | 10 +++++ src-tauri/src/sync/manager.rs | 46 ++++++++++++++------- src/components/settings/SyncSettings.tsx | 52 +++++++++++------------- src/lib/i18n/locales/en.ts | 1 + src/lib/i18n/locales/ja.ts | 2 + src/lib/i18n/locales/zh.ts | 1 + src/services/api.ts | 9 ++-- 8 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 835bcb1..6f56c86 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -32,30 +32,21 @@ pub async fn sync_get_config(state: State<'_, AppState>) -> Result, - sync_password: String, -) -> Result { +pub async fn sync_now(state: State<'_, AppState>) -> Result { let manager = SyncManager::new(state.local_db.clone()); - manager.sync_now(&sync_password).await + manager.sync_now().await } #[tauri::command] -pub async fn sync_force_push( - state: State<'_, AppState>, - sync_password: String, -) -> Result<(), String> { +pub async fn sync_force_push(state: State<'_, AppState>) -> Result<(), String> { let manager = SyncManager::new(state.local_db.clone()); - manager.force_push(&sync_password).await + manager.force_push().await } #[tauri::command] -pub async fn sync_force_pull( - state: State<'_, AppState>, - sync_password: String, -) -> Result<(), String> { +pub async fn sync_force_pull(state: State<'_, AppState>) -> Result<(), String> { let manager = SyncManager::new(state.local_db.clone()); - manager.force_pull(&sync_password).await + manager.force_pull().await } #[tauri::command] diff --git a/src-tauri/src/db/local.rs b/src-tauri/src/db/local.rs index e8ab2b2..48fda37 100644 --- a/src-tauri/src/db/local.rs +++ b/src-tauri/src/db/local.rs @@ -314,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() { diff --git a/src-tauri/src/sync/manager.rs b/src-tauri/src/sync/manager.rs index 3b2b36d..e49dc7d 100644 --- a/src-tauri/src/sync/manager.rs +++ b/src-tauri/src/sync/manager.rs @@ -98,9 +98,9 @@ impl SyncManager { }; db.set_sync_state("endpoint", &display_endpoint).await?; - // Store sync password hash for verification - let pw_hash = crypto::snapshot_hash(sync_password.as_bytes()); - db.set_sync_state("sync_password_hash", &pw_hash).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?; @@ -144,8 +144,9 @@ impl SyncManager { } /// Sync now: pull remote, then push local if changed. - pub async fn sync_now(&self, sync_password: &str) -> Result { + 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)?; @@ -155,7 +156,7 @@ impl SyncManager { // 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_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}"))?; @@ -188,7 +189,7 @@ impl SyncManager { 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)?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; provider.put_object(SNAPSHOT_KEY, &encrypted).await?; db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) @@ -204,8 +205,9 @@ impl SyncManager { } /// Force push: upload local data, overwriting remote. - pub async fn force_push(&self, sync_password: &str) -> Result<(), String> { + 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?; @@ -213,7 +215,7 @@ impl SyncManager { 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)?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; provider.put_object(SNAPSHOT_KEY, &encrypted).await?; db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) @@ -229,8 +231,9 @@ impl SyncManager { } /// Force pull: download remote data, overwriting local. - pub async fn force_pull(&self, sync_password: &str) -> Result<(), String> { + 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)?; @@ -239,7 +242,7 @@ impl SyncManager { .await? .ok_or_else(|| "[SYNC_CONNECTION_ERROR] No remote snapshot found".to_string())?; - let remote_plaintext = crypto::decrypt(sync_password, &remote_encrypted)?; + 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}"))?; @@ -287,9 +290,9 @@ impl SyncManager { 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?; + // Update stored encrypted password + let encrypted_pw = db.encrypt_sync_password(new_password)?; + db.set_sync_state("sync_password_enc", &encrypted_pw).await?; Ok(()) } @@ -308,11 +311,12 @@ impl SyncManager { } /// Auto-sync push if local has changes. - pub async fn auto_sync_push(&self, sync_password: &str) -> Result<(), String> { + 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?; @@ -320,7 +324,7 @@ impl SyncManager { 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)?; + let encrypted = crypto::encrypt(&sync_password, &plaintext)?; provider.put_object(SNAPSHOT_KEY, &encrypted).await?; db.set_sync_state("last_synced_hash", &snapshot.snapshot_hash) @@ -337,6 +341,18 @@ impl SyncManager { // ── 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") diff --git a/src/components/settings/SyncSettings.tsx b/src/components/settings/SyncSettings.tsx index 14848ff..3e7bf0c 100644 --- a/src/components/settings/SyncSettings.tsx +++ b/src/components/settings/SyncSettings.tsx @@ -34,7 +34,7 @@ export function SyncSettings() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - // Sync password + // Sync password (only needed for initial configuration) const [syncPassword, setSyncPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -134,13 +134,9 @@ export function SyncSettings() { }; const handleSyncNow = async () => { - if (!syncPassword) { - toast.error(t("settings.sync.enterPassword")); - return; - } setLoading(true); try { - const result = await api.sync.syncNow(syncPassword); + const result = await api.sync.syncNow(); toast.success(t("settings.sync.synced", { action: result.action })); loadStatus(); } catch (e) { @@ -153,13 +149,9 @@ export function SyncSettings() { }; const handleForcePush = async () => { - if (!syncPassword) { - toast.error(t("settings.sync.enterPassword")); - return; - } setLoading(true); try { - await api.sync.forcePush(syncPassword); + await api.sync.forcePush(); toast.success(t("settings.sync.forcePushed")); loadStatus(); } catch (e) { @@ -172,13 +164,9 @@ export function SyncSettings() { }; const handleForcePull = async () => { - if (!syncPassword) { - toast.error(t("settings.sync.enterPassword")); - return; - } setLoading(true); try { - await api.sync.forcePull(syncPassword); + await api.sync.forcePull(); toast.success(t("settings.sync.forcePulled")); loadStatus(); } catch (e) { @@ -284,19 +272,25 @@ export function SyncSettings() { - - setSyncPassword(e.target.value)} - /> - setConfirmPassword(e.target.value)} - /> + {!status?.enabled && ( + <> + + setSyncPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + + )}
- + {!status?.enabled ? ( + + ) : !status?.passwordStored ? ( + + ) : null} {status?.enabled && (
)} - {status.enabled && ( + {status.enabled && status.passwordStored && (