mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-27 10:32:24 +02:00
feat: full ui refresh
This commit is contained in:
@@ -4,10 +4,40 @@ use aes_gcm::{
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
|
||||
const E2E_FILE_VERSION: u8 = 1;
|
||||
|
||||
/// Argon2id is intentionally expensive (~80–150 ms per call). During an
|
||||
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
|
||||
/// extension group, profile metadata) goes through `derive_profile_key`,
|
||||
/// which without caching means hundreds of sequential 100 ms derivations.
|
||||
///
|
||||
/// Cache the derived key keyed on (sha256(password), salt). Entries are
|
||||
/// evicted on `set_e2e_password` / `delete_e2e_password` so a password
|
||||
/// change cannot use stale keys.
|
||||
type DerivedKeyCache = HashMap<([u8; 32], String), [u8; 32]>;
|
||||
static KEY_CACHE: std::sync::LazyLock<Mutex<DerivedKeyCache>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn password_fingerprint(pwd: &str) -> [u8; 32] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pwd.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&result);
|
||||
out
|
||||
}
|
||||
|
||||
fn invalidate_key_cache() {
|
||||
if let Ok(mut cache) = KEY_CACHE.lock() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_e2e_password_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("e2e_password.dat")
|
||||
}
|
||||
@@ -17,6 +47,7 @@ fn get_vault_password() -> String {
|
||||
}
|
||||
|
||||
pub fn store_e2e_password(password: &str) -> Result<(), String> {
|
||||
invalidate_key_cache();
|
||||
let file_path = get_e2e_password_path();
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
|
||||
}
|
||||
|
||||
pub fn remove_e2e_password() -> Result<(), String> {
|
||||
invalidate_key_cache();
|
||||
let file_path = get_e2e_password_path();
|
||||
if file_path.exists() {
|
||||
std::fs::remove_file(&file_path)
|
||||
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a per-profile encryption key using Argon2id
|
||||
/// Derive a per-profile encryption key using Argon2id, with an in-process
|
||||
/// cache keyed on `(sha256(password), salt)`. Repeated calls with the same
|
||||
/// password+salt are O(1); a password change calls `invalidate_key_cache`
|
||||
/// to drop stale entries.
|
||||
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
|
||||
let pwd_fp = password_fingerprint(user_password);
|
||||
let cache_key = (pwd_fp, profile_salt.to_string());
|
||||
|
||||
if let Ok(cache) = KEY_CACHE.lock() {
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
return Ok(*cached);
|
||||
}
|
||||
}
|
||||
|
||||
let salt_bytes = BASE64
|
||||
.decode(profile_salt)
|
||||
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
|
||||
@@ -175,6 +219,11 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes[..32]);
|
||||
|
||||
if let Ok(mut cache) = KEY_CACHE.lock() {
|
||||
cache.insert(cache_key, key);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -220,13 +269,75 @@ pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String
|
||||
.map_err(|e| format!("Decryption failed: {e}"))
|
||||
}
|
||||
|
||||
/// Versioned encryption envelope used for non-profile entities (proxies,
|
||||
/// VPNs, groups, extensions, extension groups). Each upload has its own
|
||||
/// random per-entity salt so the bucket can't be rainbow-table-attacked
|
||||
/// even with a shared password across many entities.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncryptedEnvelope {
|
||||
/// Format version. Increment when changing how `ct` is structured.
|
||||
pub v: u32,
|
||||
/// Base64 of the per-entity salt. Plaintext on the wire — salts are public.
|
||||
pub salt: String,
|
||||
/// Base64 of `nonce(12B) || AES-256-GCM ciphertext` (output of `encrypt_bytes`).
|
||||
pub ct: String,
|
||||
}
|
||||
|
||||
/// Wrap a plaintext JSON byte slice into an encrypted envelope if the user
|
||||
/// has E2E enabled. Returns `(payload_bytes, content_type)` ready to upload.
|
||||
/// On no-password, returns the original JSON unchanged.
|
||||
pub fn maybe_seal_for_upload(json: &[u8]) -> Result<(Vec<u8>, &'static str), String> {
|
||||
let pwd = match load_e2e_password()? {
|
||||
Some(p) => p,
|
||||
None => return Ok((json.to_vec(), "application/json")),
|
||||
};
|
||||
let salt = generate_salt();
|
||||
let key = derive_profile_key(&pwd, &salt)?;
|
||||
let ct = encrypt_bytes(&key, json)?;
|
||||
let envelope = EncryptedEnvelope {
|
||||
v: 1,
|
||||
salt,
|
||||
ct: BASE64.encode(&ct),
|
||||
};
|
||||
let payload =
|
||||
serde_json::to_vec(&envelope).map_err(|e| format!("Failed to serialize envelope: {e}"))?;
|
||||
Ok((payload, "application/json"))
|
||||
}
|
||||
|
||||
/// Reverse of `maybe_seal_for_upload`. Returns the inner plaintext JSON
|
||||
/// bytes regardless of whether `raw` was an envelope or legacy plaintext.
|
||||
///
|
||||
/// Distinguishes three cases:
|
||||
/// - `raw` is plaintext JSON, no password set → returns `raw` unchanged.
|
||||
/// - `raw` is an envelope, password set → decrypts and returns plaintext.
|
||||
/// - `raw` is an envelope, no password set → returns `Err(EncryptedEnvelope)`
|
||||
/// so callers (subscription / startup probe) can show "enter password to
|
||||
/// continue syncing" UI.
|
||||
pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
// Try parsing as envelope first; envelopes are JSON objects with a "v" field.
|
||||
if let Ok(env) = serde_json::from_slice::<EncryptedEnvelope>(raw) {
|
||||
if env.v != 1 {
|
||||
return Err(format!("Unsupported envelope version: {}", env.v));
|
||||
}
|
||||
let pwd = load_e2e_password()?.ok_or_else(|| "ENCRYPTION_PASSWORD_REQUIRED".to_string())?;
|
||||
let key = derive_profile_key(&pwd, &env.salt)?;
|
||||
let ct = BASE64
|
||||
.decode(&env.ct)
|
||||
.map_err(|e| format!("Invalid envelope ciphertext: {e}"))?;
|
||||
return decrypt_bytes(&key, &ct);
|
||||
}
|
||||
// Not an envelope — legacy plaintext. Caller will JSON-parse it directly.
|
||||
Ok(raw.to_vec())
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
pub async fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters".to_string());
|
||||
}
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
store_e2e_password(&password)
|
||||
}
|
||||
|
||||
@@ -236,10 +347,23 @@ pub fn check_has_e2e_password() -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_e2e_password() -> Result<(), String> {
|
||||
pub async fn delete_e2e_password() -> Result<(), String> {
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
remove_e2e_password()
|
||||
}
|
||||
|
||||
/// On Team plans, only the team owner is allowed to flip the E2E password
|
||||
/// state — otherwise members could lock each other out by changing the key.
|
||||
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
if let Some(state) = CLOUD_AUTH.get_user().await {
|
||||
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
|
||||
return Err("TEAM_OWNER_ONLY".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+245
-27
@@ -716,7 +716,9 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
let presign = self.client.presign_download(key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal profile metadata: {e}")))?;
|
||||
let profile: BrowserProfile = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
|
||||
|
||||
@@ -794,15 +796,18 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&sanitized)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
|
||||
|
||||
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1392,17 +1397,20 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_proxy)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
|
||||
|
||||
let remote_key = format!("proxies/{}.json", proxy.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local proxy with new last_sync
|
||||
// Update local proxy with new last_sync (always write plaintext locally)
|
||||
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
|
||||
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
|
||||
fs::write(&proxy_file, &json).map_err(|e| {
|
||||
@@ -1423,7 +1431,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("proxies/{}.json", proxy_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal proxy: {e}")))?;
|
||||
|
||||
let mut proxy: crate::proxy_manager::StoredProxy = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse proxy JSON: {e}")))?;
|
||||
@@ -1534,14 +1545,17 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_group)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
|
||||
|
||||
let remote_key = format!("groups/{}.json", group.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
@@ -1563,7 +1577,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("groups/{}.json", group_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal group: {e}")))?;
|
||||
|
||||
let mut group: crate::group_manager::ProfileGroup = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse group JSON: {e}")))?;
|
||||
@@ -1738,14 +1755,17 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_vpn)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
|
||||
|
||||
let remote_key = format!("vpns/{}.json", vpn.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local VPN with new last_sync
|
||||
@@ -1767,7 +1787,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("vpns/{}.json", vpn_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal VPN: {e}")))?;
|
||||
|
||||
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
|
||||
@@ -1883,17 +1906,21 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_ext)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
|
||||
|
||||
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
|
||||
|
||||
let remote_key = format!("extensions/{}.json", ext.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(meta_content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
|
||||
.await?;
|
||||
|
||||
// Also upload the extension file data
|
||||
// Also upload the extension file data — encrypted as a sealed envelope
|
||||
// when E2E is on (the binary is the secret here, not just the metadata).
|
||||
let file_path = {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let file_dir = manager.get_file_dir_public(&ext.id);
|
||||
@@ -1908,18 +1935,17 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
let (file_payload, file_content_type) = encryption::maybe_seal_for_upload(&file_data)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension file: {e}")))?;
|
||||
|
||||
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
|
||||
let file_presign = self
|
||||
.client
|
||||
.presign_upload(&file_remote_key, Some("application/octet-stream"))
|
||||
.presign_upload(&file_remote_key, Some(file_content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(
|
||||
&file_presign.url,
|
||||
&file_data,
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.upload_bytes(&file_presign.url, &file_payload, Some(file_content_type))
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1942,7 +1968,9 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("extensions/{}.json", ext_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension: {e}")))?;
|
||||
|
||||
let mut ext: crate::extension_manager::Extension = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse extension JSON: {e}")))?;
|
||||
@@ -1960,7 +1988,9 @@ impl SyncEngine {
|
||||
let file_stat = self.client.stat(&file_remote_key).await?;
|
||||
if file_stat.exists {
|
||||
let file_presign = self.client.presign_download(&file_remote_key).await?;
|
||||
let file_data = self.client.download_bytes(&file_presign.url).await?;
|
||||
let file_raw = self.client.download_bytes(&file_presign.url).await?;
|
||||
let file_data = encryption::maybe_unseal_after_download(&file_raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension file: {e}")))?;
|
||||
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let file_dir = manager.get_file_dir_public(&ext.id);
|
||||
@@ -2085,14 +2115,17 @@ impl SyncEngine {
|
||||
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
|
||||
})?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
|
||||
|
||||
let remote_key = format!("extension_groups/{}.json", group.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
@@ -2114,7 +2147,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("extension_groups/{}.json", group_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension group: {e}")))?;
|
||||
|
||||
let mut group: crate::extension_manager::ExtensionGroup = serde_json::from_slice(&data)
|
||||
.map_err(|e| {
|
||||
@@ -3689,6 +3725,188 @@ pub async fn set_extension_group_sync_enabled(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-upload every sync-enabled entity under the current encryption state.
|
||||
/// Called after the user sets, changes, or clears their E2E password —
|
||||
/// existing remote bytes are still in the prior state, so without this they'd
|
||||
/// remain plaintext (or worse, undecryptable) until the next per-entity edit.
|
||||
///
|
||||
/// Order: profiles first (so the user can resume work as soon as profile sync
|
||||
/// completes), then proxies, groups, VPNs, extensions, extension groups.
|
||||
/// Running profiles' associated entities are deferred by 5s so the active
|
||||
/// browser session isn't disrupted mid-keystroke.
|
||||
///
|
||||
/// Progress is emitted via `e2e-rollover-progress` events with `{ stage, done, total }`.
|
||||
#[tauri::command]
|
||||
pub async fn rollover_encryption_for_all_entities(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let _ = events::emit("e2e-rollover-started", ());
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let synced_profiles: Vec<_> = profiles
|
||||
.iter()
|
||||
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
||||
.collect();
|
||||
|
||||
let total_profiles = synced_profiles.len();
|
||||
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
for (i, profile) in synced_profiles.iter().enumerate() {
|
||||
if profile.process_id.is_some() {
|
||||
running_profile_ids.insert(profile.id);
|
||||
}
|
||||
let id_str = profile.id.to_string();
|
||||
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
|
||||
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({
|
||||
"stage": "profiles",
|
||||
"done": i + 1,
|
||||
"total": total_profiles,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which entity ids are referenced by running profiles, so we can
|
||||
// defer their re-upload (changing their files mid-session would cause the
|
||||
// running browser to see a different proxy/extension config than what it
|
||||
// launched with).
|
||||
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for p in &profiles {
|
||||
if running_profile_ids.contains(&p.id) {
|
||||
if let Some(id) = &p.proxy_id {
|
||||
deferred_proxy_ids.insert(id.clone());
|
||||
}
|
||||
if let Some(id) = &p.vpn_id {
|
||||
deferred_vpn_ids.insert(id.clone());
|
||||
}
|
||||
if let Some(id) = &p.group_id {
|
||||
deferred_group_ids.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
||||
let total_proxies = synced_proxies.len();
|
||||
let mut deferred = Vec::new();
|
||||
for (i, proxy) in synced_proxies.iter().enumerate() {
|
||||
if deferred_proxy_ids.contains(&proxy.id) {
|
||||
deferred.push(proxy.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_proxy_sync(proxy.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
|
||||
);
|
||||
}
|
||||
|
||||
let groups = {
|
||||
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
gm.get_all_groups()
|
||||
.map_err(|e| format!("Failed to get groups: {e}"))?
|
||||
};
|
||||
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
|
||||
let total_groups = synced_groups.len();
|
||||
let mut deferred_groups = Vec::new();
|
||||
for (i, group) in synced_groups.iter().enumerate() {
|
||||
if deferred_group_ids.contains(&group.id) {
|
||||
deferred_groups.push(group.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_group_sync(group.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
|
||||
);
|
||||
}
|
||||
|
||||
let vpns = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage
|
||||
.list_configs()
|
||||
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
|
||||
};
|
||||
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
|
||||
let total_vpns = synced_vpns.len();
|
||||
let mut deferred_vpns = Vec::new();
|
||||
for (i, config) in synced_vpns.iter().enumerate() {
|
||||
if deferred_vpn_ids.contains(&config.id) {
|
||||
deferred_vpns.push(config.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_vpn_sync(config.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
|
||||
);
|
||||
}
|
||||
|
||||
let extensions = {
|
||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
em.list_extensions()
|
||||
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
||||
};
|
||||
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
|
||||
let total_exts = synced_exts.len();
|
||||
for (i, ext) in synced_exts.iter().enumerate() {
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_extension_sync(ext.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
|
||||
);
|
||||
}
|
||||
|
||||
let ext_groups = {
|
||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
em.list_groups()
|
||||
.map_err(|e| format!("Failed to list extension groups: {e}"))?
|
||||
};
|
||||
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
|
||||
let total_eg = synced_ext_groups.len();
|
||||
for (i, group) in synced_ext_groups.iter().enumerate() {
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_extension_group_sync(group.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
|
||||
);
|
||||
}
|
||||
|
||||
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
for id in deferred {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
}
|
||||
for id in deferred_groups {
|
||||
scheduler.queue_group_sync(id).await;
|
||||
}
|
||||
for id in deferred_vpns {
|
||||
scheduler.queue_vpn_sync(id).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _ = events::emit("e2e-rollover-completed", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,9 +14,9 @@ pub use engine::{
|
||||
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, SyncEngine,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
|
||||
Reference in New Issue
Block a user