mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-05 22:56:34 +02:00
feat: add onboarding
This commit is contained in:
+42
-12
@@ -586,6 +586,24 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
Ok(server_guard.get_port())
|
||||
}
|
||||
|
||||
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
|
||||
/// dropping the `fingerprint` field unless the user has an active paid plan.
|
||||
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
|
||||
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
|
||||
/// handler via `has_active_paid_subscription()`.
|
||||
fn config_to_api_value<T: serde::Serialize>(
|
||||
config: Option<&T>,
|
||||
is_paid: bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut value = serde_json::to_value(config?).ok()?;
|
||||
if !is_paid {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.remove("fingerprint");
|
||||
}
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -602,6 +620,9 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
)]
|
||||
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = profiles
|
||||
@@ -616,10 +637,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -659,6 +677,9 @@ async fn get_profile(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
@@ -673,10 +694,7 @@ async fn get_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -712,6 +730,9 @@ async fn create_profile(
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
@@ -727,6 +748,18 @@ async fn create_profile(
|
||||
None
|
||||
};
|
||||
|
||||
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
|
||||
// (expired proxy subscription) maps to 402; anything else is a 400.
|
||||
if let Err(err) =
|
||||
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
|
||||
{
|
||||
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
});
|
||||
}
|
||||
|
||||
// Create profile using the async create_profile_with_group method
|
||||
match profile_manager
|
||||
.create_profile_with_group(
|
||||
@@ -776,10 +809,7 @@ async fn create_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
|
||||
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
|
||||
portable_dir().is_some()
|
||||
}
|
||||
|
||||
/// Optional single-root override for all on-disk state. Set
|
||||
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
|
||||
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
|
||||
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
|
||||
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
|
||||
fn data_root() -> Option<PathBuf> {
|
||||
std::env::var_os("DONUTBROWSER_DATA_ROOT")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
|
||||
/// otherwise, in which case the platform default app log dir is used.
|
||||
pub fn log_dir_override() -> Option<PathBuf> {
|
||||
data_root().map(|root| root.join("logs"))
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(root) = data_root() {
|
||||
return root.join("data");
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("data");
|
||||
}
|
||||
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(root) = data_root() {
|
||||
return root.join("cache");
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("cache");
|
||||
}
|
||||
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
|
||||
/// `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 {
|
||||
if let Some(dir) = log_dir_override() {
|
||||
return dir;
|
||||
}
|
||||
use tauri::Manager;
|
||||
handle
|
||||
.path()
|
||||
|
||||
@@ -703,6 +703,7 @@ mod tests {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1220,6 +1220,7 @@ mod tests {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
@@ -656,6 +656,24 @@ impl BrowserRunner {
|
||||
let process_id = wayfern_result.processId.unwrap_or(0);
|
||||
log::info!("Wayfern launched successfully with PID: {process_id}");
|
||||
|
||||
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
|
||||
// applied, which may be UPGRADED from the stored one (e.g. when the
|
||||
// stored fingerprint targets an older browser version). Persist it so the
|
||||
// next launch starts from the upgraded value — saved below via
|
||||
// save_process_info(&updated_profile).
|
||||
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
|
||||
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
|
||||
log::info!(
|
||||
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
|
||||
profile.name,
|
||||
used_fp.len()
|
||||
);
|
||||
cfg.fingerprint = Some(used_fp);
|
||||
updated_profile.wayfern_config = Some(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// Update profile with the process info
|
||||
updated_profile.process_id = Some(process_id);
|
||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||
|
||||
@@ -46,6 +46,16 @@ pub struct CloudUser {
|
||||
pub team_name: Option<String>,
|
||||
#[serde(rename = "teamRole", default)]
|
||||
pub team_role: Option<String>,
|
||||
// This desktop session's position among the user's active devices, oldest
|
||||
// first. Ordinal 1 is the primary device — the only one that can run browser
|
||||
// automation. `default` keeps older login/state payloads (which lack these
|
||||
// fields) deserializing cleanly.
|
||||
#[serde(rename = "deviceOrdinal", default)]
|
||||
pub device_ordinal: Option<i64>,
|
||||
#[serde(rename = "deviceCount", default)]
|
||||
pub device_count: Option<i64>,
|
||||
#[serde(rename = "isPrimaryDevice", default)]
|
||||
pub is_primary_device: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -413,7 +423,18 @@ impl CloudAuthManager {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Login failed ({status}): {body}"));
|
||||
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
|
||||
// limit or a temporary security block). Surface the human-readable
|
||||
// message rather than the raw JSON so the sign-in screen is clear.
|
||||
let message = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| format!("Login failed ({status})"));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let result: DeviceCodeExchangeResponse = response
|
||||
|
||||
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
};
|
||||
|
||||
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
|
||||
match crate::downloader::download_browser(
|
||||
app_handle.clone(),
|
||||
browser.to_string(),
|
||||
version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
downloaded.push(format!("{browser} {version}"));
|
||||
log::info!("Successfully auto-downloaded {browser} {version}");
|
||||
|
||||
// Retry transient failures a few times. Each attempt is wrapped in an overall
|
||||
// timeout so that a hang anywhere in the download pipeline (version resolution,
|
||||
// a stalled stream, extraction) cannot block the next browser forever. This is
|
||||
// the core of the bug fix: Wayfern going first must never starve Camoufox.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
let mut succeeded = false;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let result = tokio::time::timeout(
|
||||
ATTEMPT_TIMEOUT,
|
||||
crate::downloader::download_browser(
|
||||
app_handle.clone(),
|
||||
browser.to_string(),
|
||||
version.clone(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(_)) => {
|
||||
downloaded.push(format!("{browser} {version}"));
|
||||
log::info!("Successfully auto-downloaded {browser} {version}");
|
||||
succeeded = true;
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!(
|
||||
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// The download future itself hung past the overall timeout and was dropped,
|
||||
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
||||
// (the future may have re-resolved to a different version, so clear by
|
||||
// browser prefix) and emit a terminal error event so the UI stops spinning.
|
||||
log::warn!(
|
||||
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
||||
ATTEMPT_TIMEOUT.as_secs()
|
||||
);
|
||||
crate::downloader::clear_download_state_for_browser(browser);
|
||||
let progress = crate::downloader::DownloadProgress {
|
||||
browser: (*browser).to_string(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
};
|
||||
let _ = crate::events::emit("download-progress", &progress);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to auto-download {browser} {version}: {e}");
|
||||
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
// Short backoff before retrying a transient failure.
|
||||
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
|
||||
// still gets its chance even though this one failed/timed out.
|
||||
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(downloaded)
|
||||
|
||||
+125
-15
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
use crate::events;
|
||||
|
||||
// Maximum time to wait for the next chunk of a streaming download before treating
|
||||
// the connection as stalled. Converts an indefinite hang into a terminal error so
|
||||
// the UI can surface it and the caller can move on / retry.
|
||||
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
// Global state to track currently downloading browser-version pairs
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
||||
@@ -44,6 +49,11 @@ impl Downloader {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
|
||||
// for this long, the read fails instead of hanging forever. This is the
|
||||
// transport-level guard; the streaming loop also wraps each read in an
|
||||
// explicit tokio timeout as defense-in-depth.
|
||||
.read_timeout(STREAM_IDLE_TIMEOUT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
api_client: ApiClient::instance(),
|
||||
@@ -470,7 +480,26 @@ impl Downloader {
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
loop {
|
||||
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
|
||||
// surfaces as a terminal error instead of awaiting forever.
|
||||
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
|
||||
Ok(item) => item,
|
||||
Err(_) => {
|
||||
drop(file);
|
||||
// Keep any partial bytes on disk so a later attempt can resume via Range.
|
||||
return Err(
|
||||
format!(
|
||||
"Download stalled: no data received for {}s",
|
||||
STREAM_IDLE_TIMEOUT.as_secs()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(chunk) = next else {
|
||||
break;
|
||||
};
|
||||
if let Some(token) = cancel_token {
|
||||
if token.is_cancelled() {
|
||||
drop(file);
|
||||
@@ -694,20 +723,25 @@ impl Downloader {
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
|
||||
// Emit cancelled stage if the download was cancelled by user
|
||||
if cancel_token.is_cancelled() {
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "cancelled".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
}
|
||||
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||
// maps to "error" so the frontend can show a concrete error toast.
|
||||
let stage = if cancel_token.is_cancelled() {
|
||||
"cancelled"
|
||||
} else {
|
||||
"error"
|
||||
};
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: stage.to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(format!("Failed to download browser: {e}").into());
|
||||
}
|
||||
@@ -844,6 +878,20 @@ impl Downloader {
|
||||
// Do not delete files on verification failure; keep archive for manual retry.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
|
||||
// Emit a terminal error stage so the UI shows an error instead of spinning.
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set on verification failure
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
downloading.contains(&download_key)
|
||||
}
|
||||
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
/// by an outer timeout) before its own error path could run. Because
|
||||
/// `download_browser_full` may re-resolve to a different version than requested, this
|
||||
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
|
||||
/// key is left behind regardless of which version was actually in flight.
|
||||
pub fn clear_download_state_for_browser(browser: &str) {
|
||||
let prefix = format!("{browser}-");
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.retain(|key| !key.starts_with(&prefix));
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.retain(|key, _| !key.starts_with(&prefix));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_browser(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1110,6 +1177,49 @@ mod tests {
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_download_state_for_browser_removes_stuck_keys() {
|
||||
// Simulate a download future that was abandoned without running its own cleanup,
|
||||
// leaving stuck bookkeeping for a version that differs from the requested one.
|
||||
let key = "wayfern-1.2.3-resolved".to_string();
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.insert(key.clone());
|
||||
}
|
||||
{
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.insert(key.clone(), CancellationToken::new());
|
||||
}
|
||||
|
||||
// A different browser's in-progress state must be left untouched.
|
||||
let other = "camoufox-9.9.9".to_string();
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.insert(other.clone());
|
||||
}
|
||||
|
||||
clear_download_state_for_browser("wayfern");
|
||||
|
||||
assert!(
|
||||
!is_downloading("wayfern", "1.2.3-resolved"),
|
||||
"stuck wayfern key should be cleared even when version differs from request"
|
||||
);
|
||||
{
|
||||
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
assert!(
|
||||
!tokens.contains_key(&key),
|
||||
"stuck wayfern cancellation token should be cleared"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
is_downloading("camoufox", "9.9.9"),
|
||||
"unrelated browser's download state must be preserved"
|
||||
);
|
||||
|
||||
// Cleanup so we don't leak global state into other tests.
|
||||
clear_download_state_for_browser("camoufox");
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
|
||||
@@ -281,6 +281,7 @@ mod tests {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>,
|
||||
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||
/// conflict resolution (last-write-wins); bumped on edits only.
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -90,6 +94,7 @@ impl GroupManager {
|
||||
name,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
groups_data.groups.push(group.clone());
|
||||
@@ -136,6 +141,7 @@ impl GroupManager {
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
|
||||
group.name = name;
|
||||
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
@@ -167,6 +173,7 @@ impl GroupManager {
|
||||
existing.name = group.name.clone();
|
||||
existing.sync_enabled = group.sync_enabled;
|
||||
existing.last_sync = group.last_sync;
|
||||
existing.updated_at = group.updated_at;
|
||||
self.save_groups_data(&groups_data)?;
|
||||
}
|
||||
|
||||
@@ -183,6 +190,7 @@ impl GroupManager {
|
||||
existing.name = group.name.clone();
|
||||
existing.sync_enabled = group.sync_enabled;
|
||||
existing.last_sync = group.last_sync;
|
||||
existing.updated_at = group.updated_at;
|
||||
} else {
|
||||
groups_data.groups.push(group.clone());
|
||||
}
|
||||
|
||||
+75
-9
@@ -93,10 +93,10 @@ use downloaded_browsers_registry::{
|
||||
use downloader::{cancel_download, download_browser};
|
||||
|
||||
use settings_manager::{
|
||||
dismiss_window_resize_warning, get_app_settings, get_sync_settings, get_system_info,
|
||||
get_system_language, get_table_sorting_settings, get_window_resize_warning_dismissed,
|
||||
open_log_directory, read_log_files, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings,
|
||||
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
||||
save_sync_settings, save_table_sorting_settings,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
@@ -929,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
|
||||
#[tauri::command]
|
||||
async fn check_vpn_validity(
|
||||
vpn_id: String,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
check_vpn_validity_core(&vpn_id).await
|
||||
}
|
||||
|
||||
pub async fn check_vpn_validity_core(
|
||||
vpn_id: &str,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
|
||||
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
|
||||
|
||||
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
|
||||
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
|
||||
|
||||
@@ -1014,6 +1020,53 @@ async fn check_vpn_validity(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Validate that a profile's selected proxy or VPN actually works before the
|
||||
/// profile is created. Shared by the Tauri command, REST API, and MCP create
|
||||
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
|
||||
/// subscription) fails creation identically everywhere. Returns structured
|
||||
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
|
||||
pub async fn validate_profile_network(
|
||||
proxy_id: Option<&str>,
|
||||
vpn_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
|
||||
let result = check_vpn_validity_core(vpn_id).await?;
|
||||
if !result.is_valid {
|
||||
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
|
||||
// The cloud-included proxy is managed infrastructure; its only failure mode
|
||||
// is the user hitting their usage limit, which surfaces as a 402 at request
|
||||
// time. There's nothing to pre-validate here.
|
||||
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||
return Ok(());
|
||||
}
|
||||
let settings = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_proxy_settings_by_id(proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
|
||||
match crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity(proxy_id, &settings)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.is_valid => {}
|
||||
Ok(_) => {
|
||||
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
|
||||
}
|
||||
Err(err) if err.contains("402") => {
|
||||
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
// Start VPN worker process (detached, survives GUI shutdown)
|
||||
@@ -1122,6 +1175,7 @@ async fn generate_sample_fingerprint(
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
if browser == "camoufox" {
|
||||
@@ -1274,15 +1328,25 @@ pub fn run() {
|
||||
|
||||
let log_file_name = app_dirs::app_name();
|
||||
|
||||
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
|
||||
// the platform default app log dir, so all on-disk state lives under one root.
|
||||
let file_log_target = match app_dirs::log_dir_override() {
|
||||
Some(path) => Target::new(TargetKind::Folder {
|
||||
path,
|
||||
file_name: Some(log_file_name.to_string()),
|
||||
}),
|
||||
None => Target::new(TargetKind::LogDir {
|
||||
file_name: Some(log_file_name.to_string()),
|
||||
}),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.clear_targets() // Clear default targets to avoid duplicates
|
||||
.target(Target::new(TargetKind::Stdout))
|
||||
.target(Target::new(TargetKind::Webview))
|
||||
.target(Target::new(TargetKind::LogDir {
|
||||
file_name: Some(log_file_name.to_string()),
|
||||
}))
|
||||
.target(file_log_target)
|
||||
// 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.
|
||||
@@ -2127,6 +2191,8 @@ pub fn run() {
|
||||
get_system_info,
|
||||
dismiss_window_resize_warning,
|
||||
get_window_resize_warning_dismissed,
|
||||
get_onboarding_completed,
|
||||
complete_onboarding,
|
||||
clear_all_version_cache_and_refetch,
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
|
||||
@@ -1671,9 +1671,15 @@ impl McpServer {
|
||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||
// Fingerprint management
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
|
||||
// Fingerprint management — viewing and editing both require a paid plan.
|
||||
"get_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_get_profile_fingerprint(arguments).await
|
||||
}
|
||||
"update_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_update_profile_fingerprint(arguments).await
|
||||
}
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
self
|
||||
.handle_update_profile_proxy_bypass_rules(arguments)
|
||||
|
||||
@@ -200,6 +200,7 @@ impl ProfileManager {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -303,6 +304,7 @@ impl ProfileManager {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -365,6 +367,7 @@ impl ProfileManager {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -510,6 +513,7 @@ impl ProfileManager {
|
||||
|
||||
// Update profile name (no need to move directories since we use UUID)
|
||||
profile.name = new_name.to_string();
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
// Save profile with new name
|
||||
self.save_profile(&profile)?;
|
||||
@@ -719,6 +723,7 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
profile.group_id = group_id.clone();
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||
@@ -773,6 +778,7 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
profile.tags = deduped;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
// Save profile
|
||||
self.save_profile(&profile)?;
|
||||
@@ -809,6 +815,7 @@ impl ProfileManager {
|
||||
|
||||
// Update note (trim whitespace, set to None if empty)
|
||||
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
// Save profile
|
||||
self.save_profile(&profile)?;
|
||||
@@ -838,6 +845,7 @@ impl ProfileManager {
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
@@ -869,6 +877,7 @@ impl ProfileManager {
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.proxy_bypass_rules = rules;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
@@ -895,6 +904,7 @@ impl ProfileManager {
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.dns_blocklist = dns_blocklist;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
@@ -1058,6 +1068,7 @@ impl ProfileManager {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1225,6 +1236,7 @@ impl ProfileManager {
|
||||
// Update proxy settings and clear VPN (mutual exclusion)
|
||||
profile.proxy_id = proxy_id.clone();
|
||||
profile.vpn_id = None;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
// Save the updated profile
|
||||
self
|
||||
@@ -1324,6 +1336,7 @@ impl ProfileManager {
|
||||
// Update VPN and clear proxy (mutual exclusion)
|
||||
profile.vpn_id = vpn_id.clone();
|
||||
profile.proxy_id = None;
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
|
||||
self
|
||||
.save_profile(&profile)
|
||||
@@ -1368,6 +1381,7 @@ impl ProfileManager {
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.extension_group_id = extension_group_id.clone();
|
||||
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||
@@ -2455,6 +2469,10 @@ pub async fn create_browser_profile_new(
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
|
||||
// subscription) cancels creation with a translatable error.
|
||||
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
create_browser_profile_with_group(
|
||||
@@ -2486,7 +2504,7 @@ pub async fn update_camoufox_config(
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
@@ -2514,7 +2532,7 @@ pub async fn update_wayfern_config(
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
|
||||
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
|
||||
/// any staleness check.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<u64>,
|
||||
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
|
||||
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
|
||||
/// Source of truth for metadata sync conflict resolution (last-write-wins);
|
||||
/// NOT bumped by browser-file changes, which sync via the file manifest.
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -586,6 +586,7 @@ impl ProfileImporter {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -668,6 +669,7 @@ impl ProfileImporter {
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -726,6 +728,7 @@ impl ProfileImporter {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
@@ -103,6 +103,11 @@ pub struct StoredProxy {
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>,
|
||||
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||
/// conflict resolution (last-write-wins) — bumped on config edits only, never
|
||||
/// by sync bookkeeping. `None` on legacy files is treated as 0.
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub is_cloud_managed: bool,
|
||||
#[serde(default)]
|
||||
@@ -124,6 +129,14 @@ pub struct StoredProxy {
|
||||
pub dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
|
||||
pub fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
impl StoredProxy {
|
||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -133,6 +146,7 @@ impl StoredProxy {
|
||||
proxy_settings,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
updated_at: Some(now_secs()),
|
||||
is_cloud_managed: false,
|
||||
is_cloud_derived: false,
|
||||
geo_country: None,
|
||||
@@ -159,10 +173,12 @@ impl StoredProxy {
|
||||
|
||||
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
|
||||
self.proxy_settings = proxy_settings;
|
||||
self.updated_at = Some(now_secs());
|
||||
}
|
||||
|
||||
pub fn update_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
self.updated_at = Some(now_secs());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +471,7 @@ impl ProxyManager {
|
||||
proxy_settings,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: Some(now_secs()),
|
||||
is_cloud_managed: true,
|
||||
is_cloud_derived: false,
|
||||
geo_country: None,
|
||||
@@ -646,6 +663,7 @@ impl ProxyManager {
|
||||
proxy_settings,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: Some(now_secs()),
|
||||
is_cloud_managed: false,
|
||||
is_cloud_derived: true,
|
||||
geo_country: Some(country),
|
||||
@@ -710,6 +728,7 @@ impl ProxyManager {
|
||||
&proxy.geo_isp,
|
||||
);
|
||||
|
||||
proxy.updated_at = Some(now_secs());
|
||||
proxy.proxy_settings.username = Some(geo_username);
|
||||
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
|
||||
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
|
||||
@@ -3154,6 +3173,7 @@ mod tests {
|
||||
},
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: None,
|
||||
is_cloud_managed: false,
|
||||
is_cloud_derived: false,
|
||||
geo_country: Some("US".to_string()),
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
#[serde(default)]
|
||||
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
|
||||
#[serde(default)]
|
||||
pub disable_auto_updates: bool,
|
||||
/// When true, the decrypted in-RAM copy of a password-protected profile is
|
||||
/// preserved between launches for faster subsequent startups. The on-disk
|
||||
@@ -93,6 +95,7 @@ impl Default for AppSettings {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
}
|
||||
@@ -1010,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
|
||||
Ok(settings.window_resize_warning_dismissed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_onboarding_completed() -> Result<bool, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
Ok(settings.onboarding_completed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn complete_onboarding() -> Result<(), String> {
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
settings.onboarding_completed = true;
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_language() -> String {
|
||||
sys_locale::get_locale()
|
||||
@@ -1147,6 +1171,7 @@ mod tests {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
};
|
||||
|
||||
@@ -49,6 +49,21 @@ impl SyncClient {
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
) -> SyncResult<PresignUploadResponse> {
|
||||
self
|
||||
.presign_upload_with_metadata(key, content_type, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Presign an upload, asking the server to sign `metadata` into the object as
|
||||
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
|
||||
/// (empty/None on older servers); the caller must send exactly that back on
|
||||
/// the PUT via `upload_bytes_with_metadata`.
|
||||
pub async fn presign_upload_with_metadata(
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<std::collections::HashMap<String, String>>,
|
||||
) -> SyncResult<PresignUploadResponse> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -58,6 +73,7 @@ impl SyncClient {
|
||||
key: key.to_string(),
|
||||
content_type: content_type.map(|s| s.to_string()),
|
||||
expires_in: Some(3600),
|
||||
metadata,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
@@ -186,6 +202,21 @@ impl SyncClient {
|
||||
presigned_url: &str,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
) -> SyncResult<()> {
|
||||
self
|
||||
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
|
||||
/// MUST be exactly the metadata the presign signed (from
|
||||
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
|
||||
pub async fn upload_bytes_with_metadata(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> SyncResult<()> {
|
||||
let mut req = self
|
||||
.client
|
||||
@@ -197,6 +228,12 @@ impl SyncClient {
|
||||
req = req.header("Content-Type", ct);
|
||||
}
|
||||
|
||||
if let Some(meta) = metadata {
|
||||
for (k, v) in meta {
|
||||
req = req.header(format!("x-amz-meta-{k}"), v);
|
||||
}
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
|
||||
+96
-101
@@ -15,6 +15,11 @@ use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
||||
|
||||
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
|
||||
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
|
||||
/// (last-write-wins) from a HEAD request without downloading the object body.
|
||||
const UPDATED_AT_META_KEY: &str = "updated-at";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||
StdMutex::new(HashMap::new());
|
||||
@@ -358,6 +363,67 @@ impl SyncEngine {
|
||||
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
|
||||
}
|
||||
|
||||
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
|
||||
/// conflict resolution. Prefers the value from S3 object metadata returned by
|
||||
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
|
||||
/// decrypting the small JSON body and reading its embedded `updated_at` (for
|
||||
/// older self-hosted servers that don't surface metadata). Legacy objects with
|
||||
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
|
||||
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
|
||||
if let Some(meta) = &stat.metadata {
|
||||
if let Some(v) = meta
|
||||
.get(UPDATED_AT_META_KEY)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
{
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Fallback: read updated_at from the (small) JSON body.
|
||||
if let Ok(presign) = self.client.presign_download(remote_key).await {
|
||||
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
|
||||
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
|
||||
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
|
||||
/// profile metadata), signing its `updated_at` into S3 object metadata so
|
||||
/// future reconciles can compare via HEAD without downloading the body. The
|
||||
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
|
||||
/// lives in the object metadata.
|
||||
async fn upload_config_json(
|
||||
&self,
|
||||
remote_key: &str,
|
||||
json: &str,
|
||||
updated_at: u64,
|
||||
) -> SyncResult<()> {
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes_with_metadata(
|
||||
&presign.url,
|
||||
&payload,
|
||||
Some(content_type),
|
||||
presign.metadata.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -1431,21 +1497,13 @@ impl SyncEngine {
|
||||
|
||||
match (local_proxy, stat.exists) {
|
||||
(Some(proxy), true) => {
|
||||
// Both exist - compare timestamps
|
||||
let local_updated = proxy.last_sync.unwrap_or(0);
|
||||
let remote_updated: DateTime<Utc> = stat
|
||||
.last_modified
|
||||
.as_ref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let remote_ts = remote_updated.timestamp() as u64;
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = proxy.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_ts > local_updated {
|
||||
// Remote is newer - download
|
||||
if remote_updated > local_updated {
|
||||
self.download_proxy(proxy_id, app_handle).await?;
|
||||
} else if local_updated > remote_ts {
|
||||
// Local is newer - upload
|
||||
} else if local_updated > remote_updated {
|
||||
self.upload_proxy(&proxy).await?;
|
||||
}
|
||||
}
|
||||
@@ -1478,17 +1536,9 @@ 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(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
|
||||
.await?;
|
||||
|
||||
// Update local proxy with new last_sync (always write plaintext locally)
|
||||
@@ -1579,21 +1629,13 @@ impl SyncEngine {
|
||||
|
||||
match (local_group, stat.exists) {
|
||||
(Some(group), true) => {
|
||||
// Both exist - compare timestamps
|
||||
let local_updated = group.last_sync.unwrap_or(0);
|
||||
let remote_updated: DateTime<Utc> = stat
|
||||
.last_modified
|
||||
.as_ref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let remote_ts = remote_updated.timestamp() as u64;
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = group.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_ts > local_updated {
|
||||
// Remote is newer - download
|
||||
if remote_updated > local_updated {
|
||||
self.download_group(group_id, app_handle).await?;
|
||||
} else if local_updated > remote_ts {
|
||||
// Local is newer - upload
|
||||
} else if local_updated > remote_updated {
|
||||
self.upload_group(&group).await?;
|
||||
}
|
||||
}
|
||||
@@ -1626,17 +1668,9 @@ 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(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
@@ -1795,18 +1829,13 @@ impl SyncEngine {
|
||||
|
||||
match (local_vpn, stat.exists) {
|
||||
(Some(vpn), true) => {
|
||||
let local_updated = vpn.last_sync.unwrap_or(0);
|
||||
let remote_updated: DateTime<Utc> = stat
|
||||
.last_modified
|
||||
.as_ref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let remote_ts = remote_updated.timestamp() as u64;
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = vpn.updated_at.unwrap_or(0);
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_ts > local_updated {
|
||||
if remote_updated > local_updated {
|
||||
self.download_vpn(vpn_id, app_handle).await?;
|
||||
} else if local_updated > remote_ts {
|
||||
} else if local_updated > remote_updated {
|
||||
self.upload_vpn(&vpn).await?;
|
||||
}
|
||||
}
|
||||
@@ -1836,17 +1865,9 @@ 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(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
|
||||
.await?;
|
||||
|
||||
// Update local VPN with new last_sync
|
||||
@@ -1946,18 +1967,13 @@ impl SyncEngine {
|
||||
|
||||
match (local_ext, stat.exists) {
|
||||
(Some(ext), true) => {
|
||||
let local_updated = ext.last_sync.unwrap_or(0);
|
||||
let remote_updated: DateTime<Utc> = stat
|
||||
.last_modified
|
||||
.as_ref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let remote_ts = remote_updated.timestamp() as u64;
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = ext.updated_at;
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_ts > local_updated {
|
||||
if remote_updated > local_updated {
|
||||
self.download_extension(ext_id, app_handle).await?;
|
||||
} else if local_updated > remote_ts {
|
||||
} else if local_updated > remote_updated {
|
||||
self.upload_extension(&ext).await?;
|
||||
}
|
||||
}
|
||||
@@ -1987,17 +2003,9 @@ 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(meta_content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
|
||||
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
|
||||
.await?;
|
||||
|
||||
// Also upload the extension file data — encrypted as a sealed envelope
|
||||
@@ -2151,18 +2159,13 @@ impl SyncEngine {
|
||||
|
||||
match (local_group, stat.exists) {
|
||||
(Some(group), true) => {
|
||||
let local_updated = group.last_sync.unwrap_or(0);
|
||||
let remote_updated: DateTime<Utc> = stat
|
||||
.last_modified
|
||||
.as_ref()
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
let remote_ts = remote_updated.timestamp() as u64;
|
||||
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||
let local_updated = group.updated_at;
|
||||
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||
|
||||
if remote_ts > local_updated {
|
||||
if remote_updated > local_updated {
|
||||
self.download_extension_group(group_id, app_handle).await?;
|
||||
} else if local_updated > remote_ts {
|
||||
} else if local_updated > remote_updated {
|
||||
self.upload_extension_group(&group).await?;
|
||||
}
|
||||
}
|
||||
@@ -2196,17 +2199,9 @@ 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(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.upload_config_json(&remote_key, &json, updated_group.updated_at)
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatRequest {
|
||||
@@ -11,6 +12,11 @@ pub struct StatResponse {
|
||||
#[serde(rename = "lastModified")]
|
||||
pub last_modified: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
|
||||
/// the prefix. `None` from older servers that don't return it. Used to read
|
||||
/// `updated-at` for sync conflict resolution without downloading the body.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
|
||||
pub content_type: Option<String>,
|
||||
#[serde(rename = "expiresIn")]
|
||||
pub expires_in: Option<u64>,
|
||||
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
|
||||
pub url: String,
|
||||
#[serde(rename = "expiresAt")]
|
||||
pub expires_at: String,
|
||||
/// The metadata the server actually signed into the URL. The client must send
|
||||
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
|
||||
/// from older servers → client sends no metadata headers (body-GET fallback).
|
||||
#[serde(default)]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct VpnConfig {
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>,
|
||||
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||
/// conflict resolution (last-write-wins); bumped on config edits only.
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parsed WireGuard configuration
|
||||
|
||||
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
|
||||
sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
last_sync: Option<u64>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// VPN storage manager with encryption
|
||||
@@ -247,6 +249,7 @@ impl VpnStorage {
|
||||
last_used: config.last_used,
|
||||
sync_enabled: config.sync_enabled,
|
||||
last_sync: config.last_sync,
|
||||
updated_at: config.updated_at,
|
||||
};
|
||||
|
||||
// Update existing or add new
|
||||
@@ -280,6 +283,7 @@ impl VpnStorage {
|
||||
last_used: stored.last_used,
|
||||
sync_enabled: stored.sync_enabled,
|
||||
last_sync: stored.last_sync,
|
||||
updated_at: stored.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -300,6 +304,7 @@ impl VpnStorage {
|
||||
last_used: stored.last_used,
|
||||
sync_enabled: stored.sync_enabled,
|
||||
last_sync: stored.last_sync,
|
||||
updated_at: stored.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -356,6 +361,7 @@ impl VpnStorage {
|
||||
last_used: None,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
@@ -367,6 +373,7 @@ impl VpnStorage {
|
||||
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
||||
let mut config = self.load_config(id)?;
|
||||
config.name = new_name.to_string();
|
||||
config.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
self.save_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
@@ -420,6 +427,7 @@ impl VpnStorage {
|
||||
last_used: None,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
@@ -463,6 +471,7 @@ mod tests {
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
@@ -487,6 +496,7 @@ mod tests {
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let config2 = VpnConfig {
|
||||
@@ -498,6 +508,7 @@ mod tests {
|
||||
last_used: Some(3000),
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config1).unwrap();
|
||||
@@ -524,6 +535,7 @@ mod tests {
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
|
||||
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cdp_port: Option<u16>,
|
||||
/// The fingerprint Wayfern actually applied, echoed back by
|
||||
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
|
||||
/// (e.g. when the stored one targets an older browser version). Internal
|
||||
/// only — the caller persists it to the profile; never sent to the frontend.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub used_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
struct WayfernInstance {
|
||||
@@ -703,6 +709,7 @@ impl WayfernManager {
|
||||
log::info!("Found {} page targets", page_targets.len());
|
||||
|
||||
// Apply fingerprint if configured
|
||||
let mut used_fingerprint: Option<String> = None;
|
||||
if let Some(fingerprint_json) = &config.fingerprint {
|
||||
log::info!(
|
||||
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
||||
@@ -781,10 +788,30 @@ impl WayfernManager {
|
||||
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
||||
.await
|
||||
{
|
||||
Ok(result) => log::info!(
|
||||
"Successfully applied fingerprint to page target: {:?}",
|
||||
result
|
||||
),
|
||||
Ok(result) => {
|
||||
log::info!(
|
||||
"Successfully applied fingerprint to page target: {:?}",
|
||||
result
|
||||
);
|
||||
// Wayfern.setFingerprint echoes back the fingerprint it actually
|
||||
// used, which may be UPGRADED from what we sent (e.g. when the
|
||||
// stored fingerprint targets an older browser version). Capture
|
||||
// it once, from the first target that succeeds, so the caller can
|
||||
// persist the upgraded value to the profile.
|
||||
if used_fingerprint.is_none() {
|
||||
// getFingerprint/setFingerprint wrap the object as
|
||||
// { fingerprint: {...} }; tolerate a bare object too.
|
||||
let fp = result.get("fingerprint").cloned().unwrap_or(result);
|
||||
if fp.is_object() {
|
||||
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
|
||||
Ok(s) => used_fingerprint = Some(s),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize used fingerprint: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -849,6 +876,7 @@ impl WayfernManager {
|
||||
profilePath: Some(profile_path.to_string()),
|
||||
url: url.map(|s| s.to_string()),
|
||||
cdp_port: Some(port),
|
||||
used_fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -990,6 +1018,7 @@ impl WayfernManager {
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
cdp_port: instance.cdp_port,
|
||||
used_fingerprint: None,
|
||||
});
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -1032,6 +1061,7 @@ impl WayfernManager {
|
||||
profilePath: Some(found_profile_path),
|
||||
url: None,
|
||||
cdp_port,
|
||||
used_fingerprint: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user