mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-02 21:31:35 +02:00
feat: full ui refresh
This commit is contained in:
@@ -108,6 +108,17 @@ pub fn dns_blocklist_dir() -> PathBuf {
|
||||
cache_dir().join("dns_blocklists")
|
||||
}
|
||||
|
||||
/// Resolve the directory that tauri-plugin-log writes to. Mirrors the
|
||||
/// `LogDir` target used in the plugin builder so the path matches what's
|
||||
/// actually on disk for this OS.
|
||||
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
|
||||
use tauri::Manager;
|
||||
handle
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
|
||||
@@ -83,32 +83,73 @@ impl BrowserRunner {
|
||||
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
|
||||
}
|
||||
|
||||
async fn resolve_launch_hook_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let Some(url) = profile.launch_hook.as_deref() else {
|
||||
return Ok(None);
|
||||
fn fire_launch_hook(profile: &BrowserProfile) {
|
||||
let Some(raw_url) = profile.launch_hook.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = match url::Url::parse(trimmed) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping launch hook for profile {} (ID: {}): invalid URL: {e}",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Calling launch hook for profile {} (ID: {})",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
log::warn!(
|
||||
"Skipping launch hook for profile {} (ID: {}): URL must be http or https",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
PROXY_MANAGER
|
||||
.fetch_proxy_from_url(url, Duration::from_millis(500))
|
||||
.await
|
||||
let url = parsed.to_string();
|
||||
let profile_name = profile.name.clone();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("Launch hook client build failed for {url}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
log::info!(
|
||||
"Launch hook {url} for profile {profile_name} returned status {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
|
||||
return Ok(Some(proxy_settings));
|
||||
}
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::BrowserProfile;
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
/// Lightweight cookie metadata for the profile-info dialog. Computed without
|
||||
/// decrypting any cookie values, so it stays cheap even for multi-MB Chromium
|
||||
/// cookie stores and never blocks the runtime for noticeable time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieStats {
|
||||
pub profile_id: String,
|
||||
pub browser_type: String,
|
||||
pub total_count: usize,
|
||||
/// Every domain the profile has cookies for, sorted by cookie count desc.
|
||||
pub domains: Vec<DomainCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainCount {
|
||||
pub domain: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Request to copy specific cookies
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieCopyRequest {
|
||||
@@ -694,6 +712,135 @@ impl CookieManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the cookie SQLite database read-only without acquiring any lock.
|
||||
///
|
||||
/// `immutable=1` tells SQLite the file will not change during the read,
|
||||
/// which causes it to skip all locking. That lets us read metadata even
|
||||
/// while the browser holds an exclusive lock on the cookies database —
|
||||
/// the trade-off is that we may see a slightly stale snapshot, which is
|
||||
/// acceptable for the badge/preview use cases this powers.
|
||||
fn open_cookie_db_readonly(db_path: &Path) -> Result<Connection, String> {
|
||||
let path_str = db_path.to_string_lossy();
|
||||
if path_str.contains('?') || path_str.contains('#') {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": "profile path contains a reserved URI character" }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let uri = format!("file:{path_str}?mode=ro&immutable=1");
|
||||
Connection::open_with_flags(
|
||||
&uri,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
| OpenFlags::SQLITE_OPEN_URI
|
||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
let code = if e.to_string().to_lowercase().contains("locked") {
|
||||
"COOKIE_DB_LOCKED"
|
||||
} else {
|
||||
"COOKIE_DB_UNAVAILABLE"
|
||||
};
|
||||
serde_json::json!({
|
||||
"code": code,
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Public API: read lightweight stats (total count + top 5 domains) for a
|
||||
/// profile's cookie store. Reads from a snapshot view of the SQLite file
|
||||
/// without holding a lock, so this works while the browser is running.
|
||||
pub fn read_stats(profile_id: &str) -> Result<CookieStats, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager.list_profiles().map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir).map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let conn = Self::open_cookie_db_readonly(&db_path)?;
|
||||
|
||||
let (count_sql, domain_sql) = match profile.browser.as_str() {
|
||||
"camoufox" => (
|
||||
"SELECT COUNT(*) FROM moz_cookies",
|
||||
"SELECT host, COUNT(*) FROM moz_cookies GROUP BY host ORDER BY COUNT(*) DESC, host ASC",
|
||||
),
|
||||
"wayfern" => (
|
||||
"SELECT COUNT(*) FROM cookies",
|
||||
"SELECT host_key, COUNT(*) FROM cookies GROUP BY host_key ORDER BY COUNT(*) DESC, host_key ASC",
|
||||
),
|
||||
_ => {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": format!("unsupported browser: {}", profile.browser) }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let total_count: usize = conn
|
||||
.query_row(count_sql, [], |row| row.get::<_, i64>(0))
|
||||
.map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = conn.prepare(domain_sql).map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
let domains: Vec<DomainCount> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(DomainCount {
|
||||
domain: row.get::<_, String>(0)?,
|
||||
count: row.get::<_, i64>(1)? as usize,
|
||||
})
|
||||
})
|
||||
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok(CookieStats {
|
||||
profile_id: profile_id.to_string(),
|
||||
browser_type: profile.browser.clone(),
|
||||
total_count,
|
||||
domains,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public API: Copy cookies between profiles
|
||||
pub async fn copy_cookies(
|
||||
app_handle: &AppHandle,
|
||||
|
||||
+58
-10
@@ -93,16 +93,16 @@ use downloader::{cancel_download, download_browser};
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
||||
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
|
||||
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,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_e2e_password, 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,
|
||||
};
|
||||
|
||||
use tag_manager::get_all_tags;
|
||||
@@ -310,8 +310,21 @@ async fn import_proxies_from_parsed(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
cookie_manager::CookieManager::read_cookies(&profile_id)
|
||||
async fn read_profile_cookies(
|
||||
profile_id: String,
|
||||
) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_cookies(&profile_id))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read profile cookies: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profile_cookie_stats(
|
||||
profile_id: String,
|
||||
) -> Result<cookie_manager::CookieStats, String> {
|
||||
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_stats(&profile_id))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read profile cookie stats: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -753,6 +766,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
|
||||
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profile_traffic_snapshot(
|
||||
profile_id: String,
|
||||
) -> Result<Option<crate::traffic_stats::TrafficSnapshot>, String> {
|
||||
Ok(crate::traffic_stats::get_traffic_snapshot_for_profile(
|
||||
&profile_id,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn clear_all_traffic_stats() -> Result<(), String> {
|
||||
crate::traffic_stats::clear_all_traffic_stats()
|
||||
@@ -1186,7 +1208,11 @@ pub fn run() {
|
||||
.target(Target::new(TargetKind::LogDir {
|
||||
file_name: Some(log_file_name.to_string()),
|
||||
}))
|
||||
.max_file_size(100_000) // 100KB
|
||||
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
||||
// truncated useful context in customer support reports; 50 MB
|
||||
// turned out to be excessive disk pressure.
|
||||
.max_file_size(5 * 1024 * 1024)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
||||
.level(log::LevelFilter::Info)
|
||||
.format(|out, message, record| {
|
||||
use chrono::Local;
|
||||
@@ -1222,6 +1248,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.setup(|app| {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
@@ -1244,7 +1271,7 @@ pub fn run() {
|
||||
#[allow(unused_variables)]
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(840.0, 500.0)
|
||||
.inner_size(880.0, 500.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
@@ -1735,7 +1762,23 @@ pub fn run() {
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
}
|
||||
|
||||
for profile in profiles {
|
||||
// Only walk profiles that either have a stored PID or that we last
|
||||
// saw as running — for users with hundreds of idle profiles this
|
||||
// turns an O(N) sysinfo scan into an O(running) scan. The Rust
|
||||
// launch path always emits profile-running-changed when a profile
|
||||
// STARTS, so newly-running profiles still get tracked here.
|
||||
let profiles_to_check: Vec<_> = profiles
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.process_id.is_some()
|
||||
|| last_running_states
|
||||
.get(&p.id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for profile in profiles_to_check {
|
||||
// Check browser status and track changes
|
||||
match runner
|
||||
.check_browser_status(app_handle_status.clone(), &profile)
|
||||
@@ -1974,6 +2017,8 @@ pub fn run() {
|
||||
rename_profile,
|
||||
get_app_settings,
|
||||
save_app_settings,
|
||||
read_log_files,
|
||||
open_log_directory,
|
||||
should_show_launch_on_login_prompt,
|
||||
enable_launch_on_login,
|
||||
decline_launch_on_login,
|
||||
@@ -2041,6 +2086,7 @@ pub fn run() {
|
||||
stop_api_server,
|
||||
get_api_server_status,
|
||||
get_all_traffic_snapshots,
|
||||
get_profile_traffic_snapshot,
|
||||
clear_all_traffic_stats,
|
||||
get_traffic_stats_for_period,
|
||||
get_sync_settings,
|
||||
@@ -2060,7 +2106,9 @@ pub fn run() {
|
||||
set_e2e_password,
|
||||
check_has_e2e_password,
|
||||
delete_e2e_password,
|
||||
rollover_encryption_for_all_entities,
|
||||
read_profile_cookies,
|
||||
get_profile_cookie_stats,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
export_profile_cookies,
|
||||
|
||||
@@ -2082,6 +2082,38 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("http or https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_accepts_https_url() {
|
||||
let result = super::validate_launch_hook(Some("https://example.com/track")).unwrap();
|
||||
assert_eq!(result.as_deref(), Some("https://example.com/track"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_rejects_garbage_with_code() {
|
||||
let err = super::validate_launch_hook(Some("not a url")).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
|
||||
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_rejects_non_http_scheme_with_code() {
|
||||
let err = super::validate_launch_hook(Some("ftp://example.com/hook")).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
|
||||
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_empty_clears_hook() {
|
||||
let result = super::validate_launch_hook(Some("")).unwrap();
|
||||
assert!(result.is_none());
|
||||
|
||||
let result_ws = super::validate_launch_hook(Some(" ")).unwrap();
|
||||
assert!(result_ws.is_none());
|
||||
|
||||
let result_none = super::validate_launch_hook(None).unwrap();
|
||||
assert!(result_none.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2180,12 +2212,34 @@ pub fn update_profile_note(
|
||||
.map_err(|e| format!("Failed to update profile note: {e}"))
|
||||
}
|
||||
|
||||
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
|
||||
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
|
||||
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
|
||||
pub(crate) fn validate_launch_hook(launch_hook: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(raw) = launch_hook else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ok = url::Url::parse(trimmed)
|
||||
.ok()
|
||||
.map(|u| matches!(u.scheme(), "http" | "https"))
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return Err(serde_json::json!({ "code": "INVALID_LAUNCH_HOOK_URL" }).to_string());
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_launch_hook(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
validate_launch_hook(launch_hook.as_deref())?;
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
|
||||
|
||||
@@ -1115,149 +1115,6 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
pub async fn fetch_proxy_from_url(
|
||||
&self,
|
||||
url: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Launch hook returned status {}", response.status()));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
|
||||
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("Launch hook returned empty response".to_string());
|
||||
}
|
||||
|
||||
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
match Self::parse_dynamic_proxy_text(body) {
|
||||
Ok(settings) => Ok(Some(settings)),
|
||||
Err(text_error) => Err(format!(
|
||||
"Failed to parse launch hook response: {text_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
|
||||
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON response is not an object".to_string())?;
|
||||
|
||||
let raw_host = obj
|
||||
.get("ip")
|
||||
.or_else(|| obj.get("host"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
|
||||
|
||||
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
|
||||
// and extract the proxy type from it if no explicit type field is provided
|
||||
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
|
||||
(rest.to_string(), None)
|
||||
} else if let Some((proto, rest)) = raw_host.split_once("://") {
|
||||
(rest.to_string(), Some(proto.to_lowercase()))
|
||||
} else {
|
||||
(raw_host.to_string(), None)
|
||||
};
|
||||
|
||||
let port = obj
|
||||
.get("port")
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
|
||||
as u16;
|
||||
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.or_else(|| obj.get("protocol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.or(protocol_from_host)
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
.or_else(|| obj.get("user"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let password = obj
|
||||
.get("password")
|
||||
.or_else(|| obj.get("pass"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(ProxySettings {
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse plain text proxy payload using the same logic as proxy import
|
||||
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
|
||||
let line = body
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
return Err("Empty text response".to_string());
|
||||
}
|
||||
|
||||
match Self::parse_single_proxy_line(line) {
|
||||
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
}),
|
||||
ProxyParseResult::Ambiguous {
|
||||
possible_formats, ..
|
||||
} => Err(format!(
|
||||
"Ambiguous proxy format. Could be: {}",
|
||||
possible_formats.join(" or ")
|
||||
)),
|
||||
ProxyParseResult::Invalid { reason, .. } => {
|
||||
Err(format!("Failed to parse proxy response: {reason}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -2317,8 +2174,6 @@ mod tests {
|
||||
use hyper::Response;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// Helper function to build donut-proxy binary for testing
|
||||
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
@@ -3587,263 +3442,4 @@ mod tests {
|
||||
|
||||
delete_proxy_config(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_standard_format() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_host_alias() {
|
||||
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
|
||||
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.username.as_deref(), Some("u"));
|
||||
assert_eq!(result.password.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_port_as_string() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.port, 9090);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_with_proxy_type() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "socks4");
|
||||
|
||||
// "protocol" field alias
|
||||
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_normalizes_case() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
|
||||
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
|
||||
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.port, 1080);
|
||||
|
||||
// Protocol in host should be used as proxy_type when no explicit type field
|
||||
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.host, "10.0.0.1");
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
|
||||
// Explicit type field takes precedence over protocol in host
|
||||
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.host, "10.0.0.1");
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_ip() {
|
||||
let body = r#"{"port": 8080}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("ip") || err.contains("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_port() {
|
||||
let body = r#"{"ip": "1.2.3.4"}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_invalid_json() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
|
||||
assert!(err.contains("Invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_not_object() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
|
||||
assert!(err.contains("not an object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
|
||||
let body = "proxy.example.com:8080:user1:pass1";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_protocol_url_format() {
|
||||
let body = "http://user:pass@proxy.example.com:3128";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_with_whitespace() {
|
||||
let body = " \n proxy.example.com:8080:user:pass \n ";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_empty() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_whitespace_only() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_parses_json_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_parses_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 1080);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_respects_timeout() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let err = pm
|
||||
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Failed to fetch launch hook"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,6 +820,105 @@ pub async fn save_app_settings(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Read the most recent N log files concatenated into a single string,
|
||||
/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the
|
||||
/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to
|
||||
/// keep clipboard payloads sane.
|
||||
#[tauri::command]
|
||||
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
let dir = crate::app_dirs::log_dir(&app_handle);
|
||||
if !dir.exists() {
|
||||
return Err("Log directory does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| format!("Failed to read log dir: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if p.is_file() && (ext == "log" || ext == "txt") {
|
||||
Some((p, m))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|(_, m)| *m);
|
||||
|
||||
const MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||
let mut out = String::with_capacity(64 * 1024);
|
||||
for (path, _) in entries.iter().rev() {
|
||||
let header = format!("===== {} =====\n", path.display());
|
||||
if out.len() + header.len() >= MAX_BYTES {
|
||||
break;
|
||||
}
|
||||
out.push_str(&header);
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
let take = MAX_BYTES.saturating_sub(out.len());
|
||||
if take == 0 {
|
||||
break;
|
||||
}
|
||||
if content.len() > take {
|
||||
// Tail truncation — keep the END of older files so newest data is preserved.
|
||||
out.push_str("[…truncated — older content elided…]\n");
|
||||
out.push_str(&content[content.len() - take + 64..]);
|
||||
} else {
|
||||
out.push_str(&content);
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the per-file order so chronological newest is at the bottom.
|
||||
// (We pushed newest-first above to budget the tail; flip now.)
|
||||
let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect();
|
||||
sections.reverse();
|
||||
let final_out = sections
|
||||
.into_iter()
|
||||
.map(|s| format!("===== {s}"))
|
||||
.collect::<String>();
|
||||
|
||||
Ok(final_out)
|
||||
}
|
||||
|
||||
/// Reveal the log directory in the OS file manager.
|
||||
#[tauri::command]
|
||||
pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = crate::app_dirs::log_dir(&app_handle);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?;
|
||||
}
|
||||
let path = dir.to_string_lossy().to_string();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
|
||||
@@ -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