refactor: cleanup

This commit is contained in:
zhom committed 2026-09-22 01:17:32 +04:00
1 parent 738fb02e0b
commit 7962e97474
20 files changed
+1859 -503

No files matched your search

+52 -1
View File
@@ -1289,11 +1289,25 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
// it cannot be proven safe, which is an upstream failure, not the
// caller's fault.
StatusCode::SERVICE_UNAVAILABLE
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
} else if code.ends_with("_REQUIRES_PRO")
|| code.ends_with("_PAYMENT_REQUIRED")
|| code.ends_with("_REQUIRES_PLAN")
{
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
// Mapping them here lets the gate live in the shared manager instead of
// being re-implemented in each handler to get the status right.
StatusCode::PAYMENT_REQUIRED
} else if code == "WAYFERN_INSTANCE_LIMIT_REACHED"
|| code == "WAYFERN_GENERATION_LIMIT_REACHED"
{
StatusCode::TOO_MANY_REQUESTS
} else if code == "WAYFERN_PLAN_CHECK_UNAVAILABLE"
|| code == "WAYFERN_GENERATION_UNAVAILABLE"
|| code == "WAYFERN_BROWSER_BUSY"
{
StatusCode::SERVICE_UNAVAILABLE
} else if code == "WAYFERN_BROWSER_EXITED" {
StatusCode::INTERNAL_SERVER_ERROR
} else {
// Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS,
// WAYFERN_VERSION_NOT_AVAILABLE, ...).
@@ -5946,6 +5960,43 @@ mod tests {
}
}
#[test]
fn wayfern_refusals_carry_a_status_a_client_can_act_on() {
for (code, expected) in [
(
"WAYFERN_CROSS_OS_REQUIRES_PLAN",
StatusCode::PAYMENT_REQUIRED,
),
(
"WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN",
StatusCode::PAYMENT_REQUIRED,
),
(
"WAYFERN_INSTANCE_LIMIT_REACHED",
StatusCode::TOO_MANY_REQUESTS,
),
(
"WAYFERN_GENERATION_LIMIT_REACHED",
StatusCode::TOO_MANY_REQUESTS,
),
(
"WAYFERN_PLAN_CHECK_UNAVAILABLE",
StatusCode::SERVICE_UNAVAILABLE,
),
(
"WAYFERN_GENERATION_UNAVAILABLE",
StatusCode::SERVICE_UNAVAILABLE,
),
("WAYFERN_BROWSER_BUSY", StatusCode::SERVICE_UNAVAILABLE),
("WAYFERN_BROWSER_EXITED", StatusCode::INTERNAL_SERVER_ERROR),
("WAYFERN_IDENTITY_REFUSED", StatusCode::BAD_REQUEST),
] {
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
assert_eq!(status, expected, "{code}");
assert!(body.contains(code), "{code} must reach the caller");
}
}
#[test]
fn an_unreachable_lock_service_is_not_the_callers_fault() {
let (status, _) =
+34 -58
View File
@@ -23,15 +23,22 @@ static PROFILE_LAUNCH_LOCKS: LazyLock<
/// automation tools give a navigation rather than a loopback-sized one.
const REMOTE_NAVIGATE_TIMEOUT_SECS: u64 = 30;
async fn profile_launch_lock(profile_id: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
locks
.entry(profile_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
locks
.entry(profile_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
lock.lock_owned().await
profile_launch_lock(profile_id).await.lock_owned().await
}
pub(crate) async fn try_lock_profile_launch(
profile_id: &str,
) -> Option<tokio::sync::OwnedMutexGuard<()>> {
profile_launch_lock(profile_id).await.try_lock_owned().ok()
}
fn emit_launch_stage(profile: &BrowserProfile, stage: &str, error: Option<&str>) {
@@ -237,6 +244,16 @@ impl BrowserRunner {
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
let _launch_in_progress = crate::wayfern_identity_storage::yield_to_launch().await;
let converted;
let profile = if crate::wayfern_identity_storage::needs_conversion(profile) {
converted = crate::wayfern_identity_storage::convert_for_launch(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
&converted
} else {
profile
};
// Get or create wayfern config
let mut wayfern_config = profile.wayfern_config.clone().unwrap_or_else(|| {
log::info!(
@@ -491,48 +508,12 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Check if we need to generate a device for this launch.
//
// Three cases share the block: the user asked for a fresh device on
// every launch, the profile stores none at all, or the profile is legacy
// — a whole device payload and no identity — on a browser that speaks
// the identity API. The second is how a clone arrives here, since
// cloning clears both the payload and the identity so the clone gets an
// independent device instead of the browser's default.
//
// The third is the migration to identity-only storage: donutbrowser
// holds no device on disk, and a payload cannot become an identity
// locally, because only the browser mints an id and the id it mints
// derives its own device. That one-time rotation is the cost of the
// payload leaving disk, and it happens once because the minted id is
// persisted below.
let mut updated_profile = profile.clone();
// A profile that stores a whole device BESIDE an identity needs no new
// device, only its payload folded into overrides and location. This runs
// before the launch reads the config, and the migrated shape is what
// gets persisted below.
if crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut wayfern_config) {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut cfg);
updated_profile.wayfern_config = Some(cfg);
log::info!(
"Migrated Wayfern profile {} to identity-only storage",
profile.name
);
}
let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true);
let migrating_payload = wayfern_config.identity_id.is_none()
&& wayfern_config.fingerprint.is_some()
&& crate::wayfern_manager::supports_identity_api(&profile.version);
let needs_device = migrating_payload
|| (wayfern_config.fingerprint.is_none() && wayfern_config.identity_id.is_none());
let needs_device = crate::wayfern_identity_storage::stored_shape(&wayfern_config)
== crate::wayfern_identity_storage::StoredShape::Empty;
if randomize_requested || needs_device {
if migrating_payload && !randomize_requested {
log::info!(
"Migrating Wayfern profile {} from a stored device to an identity",
profile.name
);
} else if needs_device && !randomize_requested {
if needs_device && !randomize_requested {
log::info!(
"No stored device for Wayfern profile {}; generating one",
profile.name
@@ -563,17 +544,11 @@ impl BrowserRunner {
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
.await
.map_err(|e| {
let detail = e.to_string();
// BOTH refusal texts, because a profile may be on either browser
// version. Older builds word the generation-limit refusal
// differently, and matching only one wording leaves those users
// staring at a raw CDP string, which is the exact defect this
// mapping exists to remove.
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
} else {
crate::backend_error_with_detail("WAYFERN_FINGERPRINT_GENERATION_FAILED", detail)
}
crate::wayfern_manager::wayfern_failure(
&e.to_string(),
"WAYFERN_FINGERPRINT_GENERATION_FAILED",
config_for_generation.os.as_deref(),
)
})?;
let geolocation_applied = generated.geolocation_applied;
@@ -1399,6 +1374,7 @@ impl BrowserRunner {
updated_profile = p;
}
}
crate::wayfern_identity_storage::request_conversion_pass();
log::info!(
"Emitting profile events for successful Wayfern kill: {}",
+2 -18
View File
@@ -993,25 +993,11 @@ impl CloudAuthManager {
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
/// Whether this session's plan entitles it to a Wayfern automation token.
///
/// The token IS the automation entitlement, so this is `browser_automation`
/// and NOT `has_active_paid_subscription`. Gating the mint on "any active
/// plan" meant a Solo account — active, paying, and deliberately sold without
/// automation or fingerprint editing — asked for a token on every startup,
/// every login and every 10-hour refresh, collected a 403 each time, and got
/// the "account temporarily restricted" toast that belongs to the
/// multiple-device rule. Nothing was restricted; the plan simply does not
/// include the feature.
///
/// Reads the entitlement directly rather than going through
/// `can_use_browser_automation`, whose e2e override would send the browser
/// suite off to the live API for a token it already has as a test value.
pub async fn is_entitled_to_wayfern_token(&self) -> bool {
self
.entitlements()
.await
.is_some_and(|e| e.active && e.browser_automation)
self.entitlements().await.is_some_and(|e| e.active)
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
@@ -1326,9 +1312,7 @@ impl CloudAuthManager {
if !self.is_entitled_to_wayfern_token().await {
// Ok(()) here means callers log nothing, so a session that declined to
// mint left no trace at all and looked identical to one that succeeded.
log::info!(
"Skipping wayfern token request: the cached plan does not include browser automation"
);
log::info!("Skipping wayfern token request: the cached plan is not active");
self.clear_wayfern_token().await;
return Ok(());
}
+9 -1
View File
@@ -103,6 +103,7 @@ pub mod sync;
mod synchronizer;
pub mod traffic_stats;
mod wayfern_cdp;
mod wayfern_identity_storage;
mod wayfern_manager;
mod wayfern_persona;
mod wayfern_terms;
@@ -1996,7 +1997,13 @@ async fn generate_sample_fingerprint(
identity_id: generated.identity_id,
location: generated.location,
})
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
.map_err(|e| {
crate::wayfern_manager::wayfern_failure(
&e.to_string(),
"WAYFERN_FINGERPRINT_GENERATION_FAILED",
config.os.as_deref(),
)
})
} else {
Err(format!(
"Unsupported browser for fingerprint generation: {browser}"
@@ -3003,6 +3010,7 @@ pub fn run_with_builder(
log::error!("Startup: failed to bump profiles to latest installed versions: {e}");
}
}
wayfern_identity_storage::request_conversion_pass();
}
if !e2e_automation_enabled() {
+2 -1
View File
@@ -5530,7 +5530,8 @@ impl McpServer {
// Pick the latest downloaded version for this browser
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
let versions = registry.get_downloaded_versions(browser);
let mut versions = registry.get_downloaded_versions(browser);
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
let version = versions.first().ok_or_else(|| McpError {
code: -32000,
message: format!("No downloaded version found for {browser}. Download it first."),
+182 -48
View File
@@ -10,7 +10,14 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
static METADATA_WRITES: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let _serialized = METADATA_WRITES.lock().unwrap_or_else(|e| e.into_inner());
atomic_write_locked(path, data)
}
fn atomic_write_locked(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
@@ -39,6 +46,62 @@ pub struct ProfileManager {
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
fn merge_submitted_wayfern_config(
stored: Option<&WayfernConfig>,
mut config: WayfernConfig,
) -> WayfernConfig {
let mut keeps_identity_view = false;
if let Some(stored) = stored {
if config.identity_id.is_none()
&& (config.fingerprint.is_some() || config.identity_overrides.is_some())
{
config.identity_id = stored.identity_id.clone();
}
keeps_identity_view = config.identity_id.is_some()
&& stored.identity_id == config.identity_id
&& stored.fingerprint.is_some()
&& config.fingerprint.is_some();
if keeps_identity_view {
if config.identity_baseline.is_none() {
config.identity_baseline = stored.identity_baseline.clone();
}
if config.identity_overrides.is_none() {
config.identity_overrides = stored.identity_overrides.clone();
}
if config.location.is_none() {
config.location = stored.location.clone();
}
} else if config.identity_id.is_some() {
if config.location.is_none() {
config.location = stored.location.clone();
}
if config.identity_overrides.is_none() {
config.identity_overrides = stored.identity_overrides.clone();
}
if let Some(fingerprint) = config.fingerprint.take() {
if let Some(object) =
crate::wayfern_manager::WayfernManager::fingerprint_object(&fingerprint)
{
let overrides =
crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object);
config.identity_overrides = if overrides.is_empty() {
None
} else {
serde_json::to_string(&overrides).ok()
};
if config.location.is_none() {
config.location = crate::wayfern_manager::WayfernManager::location_of(&object);
}
}
}
}
}
if !keeps_identity_view {
config.identity_baseline = None;
}
config
}
impl ProfileManager {
fn new() -> Self {
Self {
@@ -199,12 +262,7 @@ impl ProfileManager {
let mut geolocation_applied = true;
// A caller-supplied device is a set of explicit field choices, not a
// payload to store. On a browser with the identity API it becomes the
// identity's overrides and its location, and the device is minted from a
// freshly created identity below like any other profile's. A browser
// without that API has nowhere to put the choices, so there it stays the
// stored payload.
let supplied_device = if crate::wayfern_manager::supports_identity_api(version) {
let supplied_device = if crate::wayfern_manager::supports_wayfern_152(version) {
config
.fingerprint
.take()
@@ -391,6 +449,32 @@ impl ProfileManager {
Ok(profile)
}
pub fn save_profile_unchanged(
&self,
expected: &BrowserProfile,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error>> {
let profile_file = self
.get_profiles_dir()
.join(profile.id.to_string())
.join("metadata.json");
let json = serde_json::to_string_pretty(profile)?;
let expected_json = serde_json::to_string_pretty(expected)?;
let _serialized = METADATA_WRITES.lock().unwrap_or_else(|e| e.into_inner());
let current = fs::read_to_string(&profile_file)?;
if serde_json::from_str::<serde_json::Value>(&current)?
!= serde_json::from_str::<serde_json::Value>(&expected_json)?
{
return Ok(false);
}
atomic_write_locked(&profile_file, json.as_bytes())?;
drop(_serialized);
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
});
Ok(true)
}
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
let profiles_dir = self.get_profiles_dir();
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
@@ -940,6 +1024,10 @@ impl ProfileManager {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
if profile.browser == "wayfern" {
crate::wayfern_identity_storage::request_conversion_pass();
}
Ok(profile)
}
@@ -1381,7 +1469,7 @@ impl ProfileManager {
&self,
app_handle: tauri::AppHandle,
profile_id: &str,
mut config: WayfernConfig,
config: WayfernConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
@@ -1413,47 +1501,7 @@ impl ProfileManager {
);
}
// The identity is internal state, so a caller that edits the fingerprint
// through the API or MCP will not send it back. Dropping it would silently
// re-mint the device on the next launch and throw the edit away with it,
// which is the opposite of what an override is for. Carry it forward unless
// the caller either supplied its own or cleared the fingerprint outright.
if let Some(stored) = profile.wayfern_config.as_ref() {
if config.identity_id.is_none()
&& (config.fingerprint.is_some() || config.identity_overrides.is_some())
{
config.identity_id = stored.identity_id.clone();
}
if config.identity_id.is_some() {
if config.location.is_none() {
config.location = stored.location.clone();
}
if config.identity_overrides.is_none() {
config.identity_overrides = stored.identity_overrides.clone();
}
// A WHOLE fingerprint sent for an identity-backed profile (an older UI
// or an API/MCP caller) is an explicit set of fields: it becomes the
// override map and is never stored as a device.
if let Some(fingerprint) = config.fingerprint.take() {
if let Some(object) =
crate::wayfern_manager::WayfernManager::fingerprint_object(&fingerprint)
{
let overrides =
crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object);
config.identity_overrides = if overrides.is_empty() {
None
} else {
serde_json::to_string(&overrides).ok()
};
if config.location.is_none() {
config.location = crate::wayfern_manager::WayfernManager::location_of(&object);
}
}
}
}
}
// The baseline is a legacy field; nothing writes it any more.
config.identity_baseline = None;
let config = merge_submitted_wayfern_config(profile.wayfern_config.as_ref(), config);
// Update the Wayfern configuration
profile.wayfern_config = Some(config);
@@ -1911,6 +1959,7 @@ impl ProfileManager {
{
latest = updated;
}
crate::wayfern_identity_storage::request_conversion_pass();
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
@@ -1929,6 +1978,91 @@ mod tests {
use tempfile::TempDir;
const VIEW: &str = r#"{"platform":"Win32","hardwareConcurrency":8,"timezone":"Europe/Berlin"}"#;
const EDITED_VIEW: &str =
r#"{"platform":"Win32","hardwareConcurrency":16,"timezone":"Europe/Berlin"}"#;
fn stored_view() -> WayfernConfig {
WayfernConfig {
identity_id: Some("id-1".to_string()),
fingerprint: Some(VIEW.to_string()),
identity_baseline: Some(VIEW.to_string()),
..Default::default()
}
}
#[test]
fn a_submitted_config_keeps_a_stored_identity_view() {
let stored = stored_view();
let submitted = WayfernConfig {
fingerprint: Some(VIEW.to_string()),
identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()),
..Default::default()
};
let merged = merge_submitted_wayfern_config(Some(&stored), submitted);
assert_eq!(merged.identity_id.as_deref(), Some("id-1"));
assert_eq!(merged.fingerprint.as_deref(), Some(VIEW));
assert_eq!(merged.identity_baseline.as_deref(), Some(VIEW));
assert_eq!(
merged.identity_overrides.as_deref(),
Some(r#"{"doNotTrack":"1"}"#)
);
let edited = WayfernConfig {
fingerprint: Some(EDITED_VIEW.to_string()),
..Default::default()
};
let merged = merge_submitted_wayfern_config(Some(&stored), edited);
assert_eq!(merged.fingerprint.as_deref(), Some(EDITED_VIEW));
assert_eq!(merged.identity_baseline.as_deref(), Some(VIEW));
assert!(merged.identity_overrides.is_none());
}
#[test]
fn a_submitted_fingerprint_for_a_plain_identity_becomes_overrides() {
let stored = WayfernConfig {
identity_id: Some("id-1".to_string()),
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
..Default::default()
};
let submitted = WayfernConfig {
fingerprint: Some(EDITED_VIEW.to_string()),
..Default::default()
};
let merged = merge_submitted_wayfern_config(Some(&stored), submitted);
assert_eq!(merged.identity_id.as_deref(), Some("id-1"));
assert!(merged.fingerprint.is_none());
assert!(merged.identity_baseline.is_none());
let overrides =
crate::wayfern_manager::WayfernManager::stored_object(merged.identity_overrides.as_deref());
assert_eq!(
overrides.get("hardwareConcurrency"),
Some(&serde_json::json!(16))
);
assert!(overrides.get("timezone").is_none());
assert_eq!(
merged.location.as_deref(),
Some(r#"{"timezone":"Europe/Berlin"}"#)
);
}
#[test]
fn a_submitted_payload_without_a_stored_identity_stays_a_payload() {
let stored = WayfernConfig {
fingerprint: Some(VIEW.to_string()),
..Default::default()
};
let submitted = WayfernConfig {
fingerprint: Some(EDITED_VIEW.to_string()),
identity_baseline: Some(VIEW.to_string()),
..Default::default()
};
let merged = merge_submitted_wayfern_config(Some(&stored), submitted);
assert!(merged.identity_id.is_none());
assert_eq!(merged.fingerprint.as_deref(), Some(EDITED_VIEW));
assert!(merged.identity_baseline.is_none());
}
fn create_test_profile_manager() -> (&'static ProfileManager, TempDir) {
let temp_dir = TempDir::new().unwrap();
+3 -4
View File
@@ -924,10 +924,7 @@ impl ProfileImporter {
let mut config = wayfern_config.unwrap_or_default();
// A caller-supplied device is a set of explicit field choices, not a
// payload to store: on a browser with the identity API it becomes the
// identity's overrides and its location, and the device is minted from a
// freshly created identity below.
let supplied_device = if crate::wayfern_manager::supports_identity_api(&version) {
let supplied_device = if crate::wayfern_manager::supports_wayfern_152(&version) {
config
.fingerprint
.take()
@@ -1116,6 +1113,8 @@ impl ProfileImporter {
let downloaded_versions = self
.downloaded_browsers_registry
.get_downloaded_versions(browser_type);
let mut downloaded_versions = downloaded_versions;
downloaded_versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
if let Some(version) = downloaded_versions.first() {
return Ok(version.clone());
+885
View File
@@ -0,0 +1,885 @@
use crate::cloud_auth::CLOUD_AUTH;
use crate::profile::types::get_host_os;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::wayfern_manager::{
cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_wayfern_152, wayfern_failure,
HeadlessWayfern, WayfernConfig, WayfernManager,
};
use serde_json::{json, Map, Value};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::LazyLock;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoredShape {
Payload,
IdentityWithView,
Identity,
Empty,
}
#[derive(Debug)]
enum ConversionError {
Deferred(String),
Changed,
Yielded,
Failed(String),
}
impl ConversionError {
fn coded(self) -> String {
match self {
Self::Deferred(coded) | Self::Failed(coded) => coded,
Self::Changed | Self::Yielded => crate::backend_error("WAYFERN_BROWSER_BUSY"),
}
}
}
const COMMAND: &str = "Wayfern.matchLegacyProfile";
const PASS_DELAY: Duration = Duration::from_secs(3);
const RETRY_DELAY: Duration = Duration::from_secs(600);
const CALL_TIMEOUT: Duration = Duration::from_secs(600);
const MAX_RETRIES: u32 = 6;
static PASS_REQUESTED: AtomicBool = AtomicBool::new(false);
static PASS_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
static RETRIES: AtomicU32 = AtomicU32::new(0);
static LAUNCHES: AtomicU32 = AtomicU32::new(0);
static LAUNCH_STARTED: LazyLock<tokio::sync::Notify> = LazyLock::new(tokio::sync::Notify::new);
static SESSION: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
pub struct LaunchInProgress(());
impl Drop for LaunchInProgress {
fn drop(&mut self) {
LAUNCHES.fetch_sub(1, Ordering::SeqCst);
}
}
pub async fn yield_to_launch() -> LaunchInProgress {
LAUNCHES.fetch_add(1, Ordering::SeqCst);
let in_progress = LaunchInProgress(());
LAUNCH_STARTED.notify_waiters();
drop(SESSION.lock().await);
in_progress
}
fn launch_pending() -> bool {
LAUNCHES.load(Ordering::SeqCst) > 0
}
fn stored_identity_id(config: &WayfernConfig) -> Option<&str> {
config
.identity_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
}
fn stored_payload(config: &WayfernConfig) -> Option<Map<String, Value>> {
let json = config.fingerprint.as_deref()?;
WayfernManager::launch_fingerprint_payload(json)
.ok()?
.as_object()
.cloned()
.filter(|object| !object.is_empty())
}
pub fn stored_shape(config: &WayfernConfig) -> StoredShape {
let payload = stored_payload(config).is_some();
let baseline = !WayfernManager::stored_object(config.identity_baseline.as_deref()).is_empty();
match (stored_identity_id(config).is_some(), payload, baseline) {
(true, true, _) | (true, false, true) => StoredShape::IdentityWithView,
(true, false, false) => StoredShape::Identity,
(false, true, _) => StoredShape::Payload,
(false, false, _) => StoredShape::Empty,
}
}
pub fn needs_conversion(profile: &BrowserProfile) -> bool {
profile.browser == "wayfern"
&& supports_wayfern_152(&profile.version)
&& !profile.is_cross_os()
&& profile.wayfern_config.as_ref().is_some_and(|config| {
config.randomize_fingerprint_on_launch != Some(true)
&& matches!(
stored_shape(config),
StoredShape::Payload | StoredShape::IdentityWithView
)
})
}
pub fn conversion_params(profile: &BrowserProfile, config: &WayfernConfig) -> Value {
let mut params = Map::new();
if let Some(payload) = stored_payload(config) {
params.insert("fingerprint".to_string(), Value::Object(payload));
}
if let Some(identity_id) = stored_identity_id(config) {
params.insert("identityId".to_string(), json!(identity_id));
}
let baseline = WayfernManager::stored_object(config.identity_baseline.as_deref());
if !baseline.is_empty() {
params.insert("identityBaseline".to_string(), Value::Object(baseline));
}
let overrides = WayfernManager::stored_object(config.identity_overrides.as_deref());
if !overrides.is_empty() {
params.insert("identityOverrides".to_string(), Value::Object(overrides));
}
params.insert("profileSalt".to_string(), json!(profile.id.to_string()));
Value::Object(params)
}
pub fn converted_profile(
profile: &BrowserProfile,
response: &Value,
) -> Result<BrowserProfile, String> {
if response.get("usable").and_then(Value::as_bool) != Some(true)
|| response.get("kept").and_then(Value::as_bool).is_none()
{
return Err("the browser did not return a usable identity".to_string());
}
let identity_id = response
.get("identityId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| id.len() == 36)
.ok_or_else(|| "the browser returned no identity".to_string())?;
let os = response
.get("operatingSystem")
.and_then(Value::as_str)
.and_then(WayfernManager::normalize_os_name)
.ok_or_else(|| "the browser returned no operating system".to_string())?;
let overrides = response
.get("overrides")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let location = response
.get("location")
.and_then(Value::as_object)
.and_then(WayfernManager::location_of);
let mut updated = profile.clone();
let config = updated
.wayfern_config
.get_or_insert_with(WayfernConfig::default);
config.identity_id = Some(identity_id.to_string());
config.os = Some(os.to_string());
config.identity_overrides = if overrides.is_empty() {
None
} else {
Some(Value::Object(overrides).to_string())
};
if location.is_some() {
config.location = location;
}
config.fingerprint = None;
config.identity_baseline = None;
let host_os = get_host_os();
if updated.host_os.is_none() && os != host_os {
updated.host_os = Some(host_os);
}
Ok(updated)
}
fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
let path = ProfileManager::instance()
.get_profiles_dir()
.join(id.to_string())
.join("metadata.json");
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
async fn is_running(profile: &BrowserProfile) -> bool {
if profile.process_id.is_some() {
return true;
}
let profiles_dir = ProfileManager::instance().get_profiles_dir();
let data_path = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
WayfernManager::instance()
.find_wayfern_by_profile(&data_path.to_string_lossy())
.await
.is_some()
}
fn write_backup(profile: &BrowserProfile) -> Result<(), String> {
let dir = crate::app_dirs::data_subdir().join("wayfern-config-backups");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.json", profile.id));
if path.exists() {
return Ok(());
}
let body = serde_json::to_vec_pretty(&json!({
"profileId": profile.id.to_string(),
"version": profile.version,
"wayfernConfig": profile.wayfern_config,
}))
.map_err(|e| e.to_string())?;
crate::app_dirs::write_owner_only(&path, &body).map_err(|e| e.to_string())
}
fn config_value(profile: &BrowserProfile) -> Value {
serde_json::to_value(&profile.wayfern_config).unwrap_or(Value::Null)
}
fn internal(detail: impl std::fmt::Display) -> String {
crate::backend_error_with_detail("INTERNAL_ERROR", detail)
}
fn classify(detail: &str) -> ConversionError {
let coded = wayfern_failure(detail, "WAYFERN_FINGERPRINT_APPLY_FAILED", None);
let permanent = !is_temporary_wayfern_failure(&coded)
&& (cdp_error_is_invalid_params(detail) || detail.contains("wasn't found"));
if permanent {
ConversionError::Failed(coded)
} else {
ConversionError::Deferred(coded)
}
}
async fn request(
session: &HeadlessWayfern,
params: Value,
interruptible: bool,
) -> Result<Value, ConversionError> {
let launch_started = LAUNCH_STARTED.notified();
if interruptible && launch_pending() {
return Err(ConversionError::Yielded);
}
let call = tokio::time::timeout(
CALL_TIMEOUT,
session.call(WayfernManager::instance(), COMMAND, params),
);
let outcome = if interruptible {
tokio::select! {
outcome = call => outcome,
() = launch_started => return Err(ConversionError::Yielded),
}
} else {
call.await
};
match outcome {
Ok(Ok(response)) => Ok(response),
Ok(Err(e)) => Err(classify(&e.to_string())),
Err(_) => Err(ConversionError::Deferred(internal(format!(
"{COMMAND} did not answer"
)))),
}
}
async fn convert_and_save(
session: &HeadlessWayfern,
profile: &BrowserProfile,
interruptible: bool,
wait_for_withheld: bool,
) -> Result<BrowserProfile, ConversionError> {
let config = profile.wayfern_config.clone().unwrap_or_default();
let response = request(session, conversion_params(profile, &config), interruptible).await?;
if wait_for_withheld
&& response.get("withheld").and_then(Value::as_bool) == Some(true)
&& !CLOUD_AUTH.is_logged_in().await
{
return Err(ConversionError::Deferred(crate::backend_error(
"WAYFERN_PLAN_CHECK_UNAVAILABLE",
)));
}
let updated = converted_profile(profile, &response).map_err(|reason| {
ConversionError::Failed(crate::backend_error_with_detail(
"WAYFERN_FINGERPRINT_APPLY_FAILED",
reason,
))
})?;
let Some(current) = read_profile(&profile.id) else {
return Err(ConversionError::Failed(crate::backend_error(
"PROFILE_NOT_FOUND",
)));
};
if config_value(&current) != config_value(profile) || current.version != profile.version {
return Err(ConversionError::Changed);
}
if is_running(&current).await {
return Err(ConversionError::Deferred(crate::backend_error(
"PROFILE_RUNNING",
)));
}
write_backup(&current).map_err(|e| ConversionError::Deferred(internal(e)))?;
let mut stored = current.clone();
stored.wayfern_config = updated.wayfern_config;
if stored.host_os.is_none() {
stored.host_os = updated.host_os;
}
if !ProfileManager::instance()
.save_profile_unchanged(&current, &stored)
.map_err(|e| ConversionError::Deferred(internal(e)))?
{
return Err(ConversionError::Changed);
}
log::info!("Stored Wayfern profile {} as an identity", stored.id);
Ok(stored)
}
async fn token_for_conversion() -> Result<Option<String>, String> {
let mut token = CLOUD_AUTH.get_wayfern_token().await;
if token.is_some() || !CLOUD_AUTH.is_entitled_to_wayfern_token().await {
return Ok(token);
}
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await;
token = CLOUD_AUTH.get_wayfern_token().await;
if token.is_some() {
return Ok(token);
}
}
Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE"))
}
pub async fn convert_for_launch(profile: &BrowserProfile) -> Result<BrowserProfile, String> {
let Some(current) = read_profile(&profile.id) else {
return Err(crate::backend_error("PROFILE_NOT_FOUND"));
};
if !needs_conversion(&current) {
return Ok(current);
}
let token = token_for_conversion().await?;
let session = HeadlessWayfern::start(
WayfernManager::instance(),
&current,
token.as_deref(),
&format!("profile {}", current.id),
)
.await
.map_err(|e| wayfern_failure(&e.to_string(), "WAYFERN_FINGERPRINT_APPLY_FAILED", None))?;
let mut result = convert_and_save(&session, &current, false, true).await;
if matches!(result, Err(ConversionError::Changed)) {
result = match read_profile(&profile.id) {
Some(latest) if needs_conversion(&latest) && latest.version == current.version => {
convert_and_save(&session, &latest, false, true).await
}
Some(latest) if !needs_conversion(&latest) => Ok(latest),
Some(_) => Err(ConversionError::Changed),
None => Err(ConversionError::Failed(crate::backend_error(
"PROFILE_NOT_FOUND",
))),
};
}
session.stop().await;
match result {
Ok(converted) => Ok(converted),
Err(ConversionError::Failed(reason)) => {
log::warn!(
"Launching Wayfern profile {} on its stored device: {reason}",
current.id
);
Ok(current)
}
Err(other) => Err(other.coded()),
}
}
fn any_wayfern_running(profiles: &[BrowserProfile]) -> bool {
profiles
.iter()
.any(|profile| profile.browser == "wayfern" && profile.process_id.is_some())
}
async fn run_pass() -> bool {
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
return false;
}
let Ok(profiles) = ProfileManager::instance().list_profiles() else {
return true;
};
let candidates: Vec<uuid::Uuid> = profiles
.iter()
.filter(|profile| needs_conversion(profile))
.map(|profile| profile.id)
.collect();
if candidates.is_empty() {
return false;
}
if any_wayfern_running(&profiles) {
return true;
}
let token = match token_for_conversion().await {
Ok(token) => token,
Err(_) => return true,
};
let mut deferred = false;
let mut stored = 0usize;
let mut session: Option<(
String,
HeadlessWayfern,
tokio::sync::MutexGuard<'static, ()>,
)> = None;
for id in candidates {
if launch_pending() {
deferred = true;
break;
}
let Some(_launch_guard) = crate::browser_runner::try_lock_profile_launch(&id.to_string()).await
else {
deferred = true;
continue;
};
let Some(profile) = read_profile(&id) else {
continue;
};
if !needs_conversion(&profile) {
continue;
}
let fleet = ProfileManager::instance()
.list_profiles()
.unwrap_or_default();
if any_wayfern_running(&fleet) || is_running(&profile).await {
deferred = true;
break;
}
if session
.as_ref()
.is_none_or(|(version, _, _)| version != &profile.version)
{
if let Some((_, previous, _alive)) = session.take() {
previous.stop().await;
}
if launch_pending() {
deferred = true;
break;
}
let alive = SESSION.lock().await;
match HeadlessWayfern::start(
WayfernManager::instance(),
&profile,
token.as_deref(),
"stored profiles",
)
.await
{
Ok(started) => session = Some((profile.version.clone(), started, alive)),
Err(e) => {
log::warn!(
"Could not start Wayfern {} for stored profiles: {e}",
profile.version
);
deferred = true;
continue;
}
}
}
let Some((_, browser, _)) = session.as_ref() else {
continue;
};
match convert_and_save(browser, &profile, true, true).await {
Ok(_) => stored += 1,
Err(ConversionError::Yielded) => {
deferred = true;
break;
}
Err(ConversionError::Changed) => deferred = true,
Err(ConversionError::Deferred(reason)) => {
log::info!("Stored Wayfern profile {id} is unchanged for now: {reason}");
deferred = true;
}
Err(ConversionError::Failed(reason)) => {
log::warn!("Stored Wayfern profile {id} could not be converted: {reason}");
}
}
}
if let Some((_, browser, _alive)) = session.take() {
browser.stop().await;
}
if stored > 0 {
let _ = crate::events::emit_empty("profiles-changed");
}
deferred
}
pub fn request_conversion_pass() {
if cfg!(test) || PASS_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
tauri::async_runtime::spawn(async move {
tokio::time::sleep(PASS_DELAY).await;
let deferred = {
let _pass = PASS_LOCK.lock().await;
PASS_REQUESTED.store(false, Ordering::SeqCst);
run_pass().await
};
if !deferred {
RETRIES.store(0, Ordering::SeqCst);
return;
}
if RETRIES.fetch_add(1, Ordering::SeqCst) >= MAX_RETRIES {
return;
}
tokio::time::sleep(RETRY_DELAY).await;
request_conversion_pass();
});
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(version: &str, config: WayfernConfig) -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::parse_str("7d1f0f4e-9e36-4a0b-8c55-3f0c2c9f5a11").unwrap(),
name: "stored".to_string(),
browser: "wayfern".to_string(),
version: version.to_string(),
process_id: None,
proxy_id: None,
vpn_id: None,
launch_hook: None,
last_launch: None,
release_type: "stable".to_string(),
wayfern_config: Some(config),
group_id: None,
tags: Vec::new(),
note: None,
window_color: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(get_host_os()),
ephemeral: false,
temporary: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
}
}
fn payload() -> String {
json!({
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
"platform": "Win32",
"webglRenderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x00002503) Direct3D11 vs_5_0 ps_5_0, D3D11)",
"hardwareConcurrency": 12,
"timezone": "Europe/Berlin",
"language": "de-DE",
"languages": "de-DE,de",
"latitude": 52.52,
"longitude": 13.405
})
.to_string()
}
const LEGACY_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66a0e1";
const CURRENT_ID: &str = "0b1c2d3e-4f50-4a61-8b72-93a4b5c6d7e8";
#[test]
fn shapes_follow_what_the_config_stores() {
let empty = WayfernConfig::default();
assert_eq!(stored_shape(&empty), StoredShape::Empty);
let payload_only = WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
};
assert_eq!(stored_shape(&payload_only), StoredShape::Payload);
let with_view = WayfernConfig {
identity_id: Some(LEGACY_ID.to_string()),
fingerprint: Some(payload()),
identity_baseline: Some(payload()),
..Default::default()
};
assert_eq!(stored_shape(&with_view), StoredShape::IdentityWithView);
let baseline_only = WayfernConfig {
identity_id: Some(LEGACY_ID.to_string()),
identity_baseline: Some(payload()),
..Default::default()
};
assert_eq!(stored_shape(&baseline_only), StoredShape::IdentityWithView);
let identity = WayfernConfig {
identity_id: Some(CURRENT_ID.to_string()),
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
..Default::default()
};
assert_eq!(stored_shape(&identity), StoredShape::Identity);
for unusable in ["{}", "null", "not json", " "] {
let config = WayfernConfig {
fingerprint: Some(unusable.to_string()),
..Default::default()
};
assert_eq!(stored_shape(&config), StoredShape::Empty, "{unusable}");
}
let wrapped = WayfernConfig {
fingerprint: Some(format!(r#"{{"fingerprint":{}}}"#, payload())),
..Default::default()
};
assert_eq!(stored_shape(&wrapped), StoredShape::Payload);
}
#[test]
fn only_stored_devices_on_152_are_converted() {
let payload_only = WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
};
assert!(needs_conversion(&profile_with(
"152.0.7977.64",
payload_only.clone()
)));
assert!(!needs_conversion(&profile_with(
"151.0.7922.71",
payload_only.clone()
)));
assert!(!needs_conversion(&profile_with(
"150.0.7801.12",
payload_only.clone()
)));
let mut randomized = payload_only.clone();
randomized.randomize_fingerprint_on_launch = Some(true);
assert!(!needs_conversion(&profile_with(
"152.0.7977.64",
randomized
)));
let identity = WayfernConfig {
identity_id: Some(CURRENT_ID.to_string()),
..Default::default()
};
assert!(!needs_conversion(&profile_with("152.0.7977.64", identity)));
let mut camoufox = profile_with("152.0.7977.64", payload_only.clone());
camoufox.browser = "camoufox".to_string();
assert!(!needs_conversion(&camoufox));
let mut foreign = profile_with("152.0.7977.64", payload_only);
foreign.host_os = Some(
if get_host_os() == "windows" {
"macos"
} else {
"windows"
}
.to_string(),
);
assert!(!needs_conversion(&foreign));
}
#[test]
fn the_request_carries_every_stored_part_and_the_profile_salt() {
let config = WayfernConfig {
identity_id: Some(format!(" {LEGACY_ID} ")),
fingerprint: Some(format!(r#"{{"fingerprint":{}}}"#, payload())),
identity_baseline: Some(r#"{"hardwareConcurrency":8}"#.to_string()),
identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()),
..Default::default()
};
let profile = profile_with("152.0.7977.64", config.clone());
let params = conversion_params(&profile, &config);
assert_eq!(params["identityId"], json!(LEGACY_ID));
assert_eq!(params["profileSalt"], json!(profile.id.to_string()));
assert_eq!(params["identityBaseline"]["hardwareConcurrency"], json!(8));
assert_eq!(params["identityOverrides"]["doNotTrack"], json!("1"));
assert_eq!(params["fingerprint"]["platform"], json!("Win32"));
assert_eq!(params["fingerprint"]["languages"], json!(["de-DE", "de"]));
assert!(params.get("operatingSystem").is_none());
let bare = WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
};
let params = conversion_params(&profile, &bare);
assert!(params.get("identityId").is_none());
assert!(params.get("identityBaseline").is_none());
assert!(params.get("identityOverrides").is_none());
}
fn response(overrides: Value) -> Value {
json!({
"identityId": CURRENT_ID,
"operatingSystem": "windows",
"distance": 3.5,
"verdict": "close",
"usable": true,
"overrides": overrides,
"location": {
"timezone": "Europe/Berlin",
"language": "de-DE",
"latitude": 52.52,
"longitude": 13.405,
"platform": "Win32"
},
"drawn": 40,
"kept": false
})
}
#[test]
fn a_converted_profile_stores_only_the_identity_location_and_overrides() {
let config = WayfernConfig {
fingerprint: Some(payload()),
identity_baseline: Some(payload()),
geo_proxy_signature: Some("direct".to_string()),
webrtc_mode: Some("tcp_only".to_string()),
..Default::default()
};
let profile = profile_with("152.0.7977.64", config);
let converted = converted_profile(&profile, &response(json!({}))).unwrap();
let stored = converted.wayfern_config.as_ref().unwrap();
assert_eq!(stored.identity_id.as_deref(), Some(CURRENT_ID));
assert_eq!(stored.os.as_deref(), Some("windows"));
assert!(stored.fingerprint.is_none());
assert!(stored.identity_baseline.is_none());
assert!(stored.identity_overrides.is_none());
assert_eq!(stored.geo_proxy_signature.as_deref(), Some("direct"));
assert_eq!(stored.webrtc_mode.as_deref(), Some("tcp_only"));
let location = WayfernManager::stored_object(stored.location.as_deref());
assert_eq!(location.get("timezone"), Some(&json!("Europe/Berlin")));
assert_eq!(location.get("latitude"), Some(&json!(52.52)));
assert!(location.get("platform").is_none());
let written = serde_json::to_string(stored).unwrap();
assert!(!written.contains("\"fingerprint\""));
assert!(!written.contains("\"identity_baseline\""));
assert_eq!(
stored_shape(stored),
StoredShape::Identity,
"a converted profile is never converted again"
);
let with_overrides =
converted_profile(&profile, &response(json!({"hardwareConcurrency": 12}))).unwrap();
let overrides = WayfernManager::stored_object(
with_overrides
.wayfern_config
.as_ref()
.unwrap()
.identity_overrides
.as_deref(),
);
assert_eq!(overrides.get("hardwareConcurrency"), Some(&json!(12)));
}
#[test]
fn an_incomplete_answer_is_never_stored() {
let profile = profile_with(
"152.0.7977.64",
WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
},
);
let mut unusable = response(json!({}));
unusable["usable"] = json!(false);
assert!(converted_profile(&profile, &unusable).is_err());
let mut old_answer = response(json!({}));
old_answer.as_object_mut().unwrap().remove("kept");
assert!(converted_profile(&profile, &old_answer).is_err());
let mut no_id = response(json!({}));
no_id["identityId"] = json!("");
assert!(converted_profile(&profile, &no_id).is_err());
let mut no_os = response(json!({}));
no_os["operatingSystem"] = json!("beos");
assert!(converted_profile(&profile, &no_os).is_err());
}
#[test]
fn a_claim_off_the_host_keeps_the_profile_launchable_here() {
let other = if get_host_os() == "macos" {
"windows"
} else {
"macos"
};
let mut profile = profile_with(
"152.0.7977.64",
WayfernConfig {
fingerprint: Some(payload()),
..Default::default()
},
);
profile.host_os = None;
assert!(!profile.is_cross_os());
let mut answer = response(json!({}));
answer["operatingSystem"] = json!(other);
let converted = converted_profile(&profile, &answer).unwrap();
assert_eq!(converted.host_os.as_deref(), Some(get_host_os().as_str()));
assert!(!converted.is_cross_os());
let mut mobile = response(json!({}));
mobile["operatingSystem"] = json!("android");
let converted = converted_profile(&profile, &mobile).unwrap();
assert!(!converted.is_cross_os());
let mut same = response(json!({}));
same["operatingSystem"] = json!(get_host_os());
let converted = converted_profile(&profile, &same).unwrap();
assert!(converted.host_os.is_none());
assert!(!converted.is_cross_os());
}
#[tokio::test]
async fn a_launch_stops_a_conversion_and_waits_for_its_browser() {
let alive = SESSION.lock().await;
let launch_started = LAUNCH_STARTED.notified();
assert!(!launch_pending());
let launch = tokio::spawn(yield_to_launch());
tokio::time::timeout(Duration::from_secs(5), launch_started)
.await
.expect("a starting launch interrupts the running conversion");
assert!(launch_pending());
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!launch.is_finished(),
"the launch waits while the conversion browser is still alive"
);
drop(alive);
let in_progress = tokio::time::timeout(Duration::from_secs(5), launch)
.await
.expect("the launch continues once the conversion browser is gone")
.unwrap();
assert!(launch_pending());
drop(in_progress);
assert!(!launch_pending());
}
#[test]
fn protocol_refusals_are_permanent_and_everything_else_waits() {
let invalid =
r#"CDP error: {"code":-32602,"message":"fingerprint must be the stored device payload"}"#;
assert!(matches!(classify(invalid), ConversionError::Failed(_)));
let missing =
r#"CDP error: {"code":-32601,"message":"'Wayfern.matchLegacyProfile' wasn't found"}"#;
assert!(matches!(classify(missing), ConversionError::Failed(_)));
let transient = r#"CDP error: {"code":-32000,"message":"Fingerprint authorization service is temporarily unavailable. Retry the command."}"#;
match classify(transient) {
ConversionError::Deferred(coded) => {
assert!(coded.contains("WAYFERN_PLAN_CHECK_UNAVAILABLE"))
}
other => panic!("unexpected {other:?}"),
}
assert!(matches!(
classify("No response received from CDP"),
ConversionError::Deferred(_)
));
}
}
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "Some Donut Browser files are from different versions. Reinstall the latest update; your profiles will stay safe.",
"updateProfilesRunning": "Stop all running profiles before installing the update.",
"updatePreparationFailed": "Donut Browser could not safely stop a background network process. Restart your computer, then try the update again.",
"wayfernTermsRequired": "Accept the Wayfern Terms and Conditions before starting the MCP server.",
"wayfernTermsRequired": "Accept the Wayfern Terms and Conditions to use Wayfern.",
"apiPortUnavailable": "The API server could not find an available port.",
"mcpServerAlreadyRunning": "The MCP server is already running.",
"mcpServerNotRunning": "The MCP server is not running.",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway",
"wayfernFingerprintApplyFailed": "Could not apply this profile's fingerprint, so the browser was not started. {{detail}}",
"wayfernFingerprintGenerationFailed": "Could not create a fingerprint for this profile. {{detail}}",
"wayfernGenerationLimitReached": "The fingerprint generation limit for this account has been reached. Your existing profiles will keep launching normally — only new fingerprints are paused, and they become available again a little later.",
"wayfernGenerationLimitReached": "Temporary fingerprint generation limit reached. Try again later.",
"wayfernCrossOsRequiresPlan": "This profile claims {{detail}}, which needs a paid plan and an active sign-in. Sign in or switch the profile to your own operating system.",
"wayfernIdentityRefused": "The browser did not start on this profile's identity, so it was closed. {{detail}}",
"wayfernInstanceLimitReached": "Too many browsers are open for your plan. Close a browser and try again.",
"wayfernPlanCheckUnavailable": "Could not check your plan right now. Try again in a moment.",
"wayfernCustomFingerprintRequiresPlan": "Your plan does not include custom fingerprints.",
"wayfernGenerationUnavailable": "Could not create a fingerprint right now. Try again later.",
"wayfernBrowserBusy": "The browser was busy applying a fingerprint. Try again.",
"wayfernBrowserExited": "The browser closed while starting. {{detail}}",
"profileExportFailed": "This profile could not be exported. {{detail}}",
"profileExportTooLarge": "This profile is too large to export. Clear some of its browsing data and try again.",
"profileImportFailed": "This archive could not be read. {{detail}}",
+8 -2
View File
@@ -2210,7 +2210,7 @@
"proxySidecarVersionMismatch": "Algunos archivos de Donut Browser pertenecen a versiones diferentes. Reinstala la última actualización; tus perfiles permanecerán seguros.",
"updateProfilesRunning": "Detén todos los perfiles en ejecución antes de instalar la actualización.",
"updatePreparationFailed": "Donut Browser no pudo detener de forma segura un proceso de red en segundo plano. Reinicia el equipo y vuelve a intentar la actualización.",
"wayfernTermsRequired": "Acepta los Términos y condiciones de Wayfern antes de iniciar el servidor MCP.",
"wayfernTermsRequired": "Acepta los Términos y condiciones de Wayfern para usar Wayfern.",
"apiPortUnavailable": "El servidor API no pudo encontrar un puerto disponible.",
"mcpServerAlreadyRunning": "El servidor MCP ya está en ejecución.",
"mcpServerNotRunning": "El servidor MCP no está en ejecución.",
@@ -2285,9 +2285,15 @@
"importSourceBrowserRunning": "Cierra {{browser}} primero o elige importar de todos modos",
"wayfernFingerprintApplyFailed": "No se pudo aplicar la huella digital de este perfil, por lo que el navegador no se inició. {{detail}}",
"wayfernFingerprintGenerationFailed": "No se pudo crear una huella digital para este perfil. {{detail}}",
"wayfernGenerationLimitReached": "Se alcanzó el límite de generación de huellas digitales de esta cuenta. Tus perfiles existentes seguirán abriéndose con normalidad: solo se pausan las huellas nuevas, y volverán a estar disponibles un poco más tarde.",
"wayfernGenerationLimitReached": "Se alcanzó el límite temporal de generación de huellas digitales. Inténtalo de nuevo más tarde.",
"wayfernCrossOsRequiresPlan": "Este perfil declara {{detail}}, lo que requiere un plan de pago y una sesión activa. Inicia sesión o cambia el perfil a tu propio sistema operativo.",
"wayfernIdentityRefused": "El navegador no se inició con la identidad de este perfil, así que se cerró. {{detail}}",
"wayfernInstanceLimitReached": "Hay demasiados navegadores abiertos para tu plan. Cierra un navegador e inténtalo de nuevo.",
"wayfernPlanCheckUnavailable": "No se pudo comprobar tu plan ahora mismo. Inténtalo de nuevo en un momento.",
"wayfernCustomFingerprintRequiresPlan": "Tu plan no incluye huellas digitales personalizadas.",
"wayfernGenerationUnavailable": "No se pudo crear una huella digital ahora mismo. Inténtalo de nuevo más tarde.",
"wayfernBrowserBusy": "El navegador estaba ocupado aplicando una huella digital. Inténtalo de nuevo.",
"wayfernBrowserExited": "El navegador se cerró mientras se iniciaba. {{detail}}",
"profileExportFailed": "No se pudo exportar este perfil. {{detail}}",
"profileExportTooLarge": "Este perfil es demasiado grande para exportarlo. Borra parte de sus datos de navegación e inténtalo de nuevo.",
"profileImportFailed": "No se pudo leer este archivo. {{detail}}",
+8 -2
View File
@@ -2210,7 +2210,7 @@
"proxySidecarVersionMismatch": "Certains fichiers de Donut Browser proviennent de versions différentes. Réinstallez la dernière mise à jour ; vos profils resteront intacts.",
"updateProfilesRunning": "Arrêtez tous les profils en cours d’exécution avant d’installer la mise à jour.",
"updatePreparationFailed": "Donut Browser n’a pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez l’ordinateur, puis réessayez la mise à jour.",
"wayfernTermsRequired": "Acceptez les conditions générales de Wayfern avant de démarrer le serveur MCP.",
"wayfernTermsRequired": "Acceptez les conditions générales de Wayfern pour utiliser Wayfern.",
"apiPortUnavailable": "Le serveur API n’a trouvé aucun port disponible.",
"mcpServerAlreadyRunning": "Le serveur MCP est déjà en cours d’exécution.",
"mcpServerNotRunning": "Le serveur MCP n’est pas en cours d’exécution.",
@@ -2285,9 +2285,15 @@
"importSourceBrowserRunning": "Fermez d'abord {{browser}}, ou choisissez d'importer quand même",
"wayfernFingerprintApplyFailed": "Impossible d'appliquer l'empreinte de ce profil, le navigateur n'a donc pas été lancé. {{detail}}",
"wayfernFingerprintGenerationFailed": "Impossible de créer une empreinte pour ce profil. {{detail}}",
"wayfernGenerationLimitReached": "La limite de génération d'empreintes de ce compte est atteinte. Vos profils existants continueront de se lancer normalement : seules les nouvelles empreintes sont suspendues, et elles redeviendront disponibles un peu plus tard.",
"wayfernGenerationLimitReached": "Limite temporaire de génération d'empreintes atteinte. Réessayez plus tard.",
"wayfernCrossOsRequiresPlan": "Ce profil déclare {{detail}}, ce qui nécessite une formule payante et une connexion active. Connectez-vous ou basculez le profil vers votre propre système d'exploitation.",
"wayfernIdentityRefused": "Le navigateur n'a pas démarré avec l'identité de ce profil, il a donc été fermé. {{detail}}",
"wayfernInstanceLimitReached": "Trop de navigateurs sont ouverts pour votre forfait. Fermez un navigateur et réessayez.",
"wayfernPlanCheckUnavailable": "Impossible de vérifier votre forfait pour le moment. Réessayez dans un instant.",
"wayfernCustomFingerprintRequiresPlan": "Votre forfait n'inclut pas les empreintes personnalisées.",
"wayfernGenerationUnavailable": "Impossible de créer une empreinte pour le moment. Réessayez plus tard.",
"wayfernBrowserBusy": "Le navigateur était occupé à appliquer une empreinte. Réessayez.",
"wayfernBrowserExited": "Le navigateur s'est fermé pendant le démarrage. {{detail}}",
"profileExportFailed": "Ce profil n'a pas pu être exporté. {{detail}}",
"profileExportTooLarge": "Ce profil est trop volumineux pour être exporté. Effacez une partie de ses données de navigation puis réessayez.",
"profileImportFailed": "Cette archive n'a pas pu être lue. {{detail}}",
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "Donut Browser のファイルに異なるバージョンが混在しています。最新のアップデートを再インストールしてください。プロファイルはそのまま保持されます。",
"updateProfilesRunning": "アップデートをインストールする前に、実行中のプロファイルをすべて停止してください。",
"updatePreparationFailed": "バックグラウンドのネットワークプロセスを安全に停止できませんでした。コンピューターを再起動してから、もう一度アップデートしてください。",
"wayfernTermsRequired": "MCPサーバーを起動する前にWayfernの利用規約に同意してください。",
"wayfernTermsRequired": "Wayfernを使用するには、Wayfernの利用規約に同意してください。",
"apiPortUnavailable": "APIサーバーで利用可能なポートが見つかりませんでした。",
"mcpServerAlreadyRunning": "MCPサーバーはすでに実行中です。",
"mcpServerNotRunning": "MCPサーバーは実行されていません。",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "先に {{browser}} を閉じるか、このままインポートを選択してください",
"wayfernFingerprintApplyFailed": "このプロファイルのフィンガープリントを適用できなかったため、ブラウザーは起動しませんでした。{{detail}}",
"wayfernFingerprintGenerationFailed": "このプロファイルのフィンガープリントを作成できませんでした。{{detail}}",
"wayfernGenerationLimitReached": "このアカウントのフィンガープリント生成上限に達しました。既存のプロファイルはこれまでどおり起動できます。停止されるのは新しいフィンガープリントのみで、しばらくすると再び利用できるようになります。",
"wayfernGenerationLimitReached": "一時的なフィンガープリント生成上限に達しました。しばらくしてから再度お試しください。",
"wayfernCrossOsRequiresPlan": "このプロファイルは {{detail}} を指定しています。これには有料プランとサインインが必要です。サインインするか、プロファイルをお使いのオペレーティングシステムに変更してください。",
"wayfernIdentityRefused": "ブラウザーがこのプロファイルのアイデンティティで起動しなかったため、終了しました。{{detail}}",
"wayfernInstanceLimitReached": "ご利用のプランでは、開いているブラウザーが多すぎます。ブラウザーを1つ閉じてからもう一度お試しください。",
"wayfernPlanCheckUnavailable": "現在、ご利用のプランを確認できません。しばらくしてからもう一度お試しください。",
"wayfernCustomFingerprintRequiresPlan": "ご利用のプランにはカスタムフィンガープリントが含まれていません。",
"wayfernGenerationUnavailable": "現在、フィンガープリントを作成できません。しばらくしてから再度お試しください。",
"wayfernBrowserBusy": "ブラウザーがフィンガープリントを適用中でした。もう一度お試しください。",
"wayfernBrowserExited": "ブラウザーが起動中に終了しました。{{detail}}",
"profileExportFailed": "このプロファイルを書き出せませんでした。{{detail}}",
"profileExportTooLarge": "このプロファイルは大きすぎて書き出せません。閲覧データを一部消してからもう一度お試しください。",
"profileImportFailed": "このアーカイブを読み取れませんでした。{{detail}}",
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "Donut Browser 파일에 서로 다른 버전이 섞여 있습니다. 최신 업데이트를 다시 설치해 주세요. 프로필은 안전하게 유지됩니다.",
"updateProfilesRunning": "업데이트를 설치하기 전에 실행 중인 모든 프로필을 중지하세요.",
"updatePreparationFailed": "Donut Browser가 백그라운드 네트워크 프로세스를 안전하게 중지하지 못했습니다. 컴퓨터를 다시 시작한 후 업데이트를 다시 시도하세요.",
"wayfernTermsRequired": "MCP 서버를 시작하기 전에 Wayfern 이용 약관에 동의하세요.",
"wayfernTermsRequired": "Wayfern을 사용하려면 Wayfern 이용 약관에 동의하세요.",
"apiPortUnavailable": "API 서버에서 사용 가능한 포트를 찾지 못했습니다.",
"mcpServerAlreadyRunning": "MCP 서버가 이미 실행 중입니다.",
"mcpServerNotRunning": "MCP 서버가 실행 중이 아닙니다.",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "{{browser}}을(를) 먼저 닫거나 그래도 가져오기를 선택하세요",
"wayfernFingerprintApplyFailed": "이 프로필의 지문을 적용하지 못해 브라우저를 시작하지 않았습니다. {{detail}}",
"wayfernFingerprintGenerationFailed": "이 프로필의 지문을 만들지 못했습니다. {{detail}}",
"wayfernGenerationLimitReached": "이 계정의 지문 생성 한도에 도달했습니다. 기존 프로필은 평소대로 실행되며, 새 지문만 일시 중지되고 잠시 후 다시 사용할 수 있습니다.",
"wayfernGenerationLimitReached": "일시적인 지문 생성 한도에 도달했습니다. 나중에 다시 시도하세요.",
"wayfernCrossOsRequiresPlan": "이 프로필은 {{detail}}을(를) 요구하며, 유료 플랜과 로그인 상태가 필요합니다. 로그인하거나 프로필을 사용 중인 운영체제로 변경하세요.",
"wayfernIdentityRefused": "브라우저가 이 프로필의 아이덴티티로 시작되지 않아 종료되었습니다. {{detail}}",
"wayfernInstanceLimitReached": "현재 요금제 기준으로 열려 있는 브라우저가 너무 많습니다. 브라우저를 하나 닫고 다시 시도하세요.",
"wayfernPlanCheckUnavailable": "지금은 요금제를 확인할 수 없습니다. 잠시 후 다시 시도하세요.",
"wayfernCustomFingerprintRequiresPlan": "현재 요금제에는 사용자 지정 지문이 포함되어 있지 않습니다.",
"wayfernGenerationUnavailable": "지금은 지문을 만들 수 없습니다. 나중에 다시 시도하세요.",
"wayfernBrowserBusy": "브라우저가 지문을 적용하는 중이었습니다. 다시 시도하세요.",
"wayfernBrowserExited": "브라우저가 시작되는 도중에 종료되었습니다. {{detail}}",
"profileExportFailed": "이 프로필을 내보낼 수 없습니다. {{detail}}",
"profileExportTooLarge": "이 프로필은 너무 커서 내보낼 수 없습니다. 검색 데이터를 일부 지우고 다시 시도하세요.",
"profileImportFailed": "이 아카이브를 읽을 수 없습니다. {{detail}}",
+8 -2
View File
@@ -2210,7 +2210,7 @@
"proxySidecarVersionMismatch": "Alguns arquivos do Donut Browser são de versões diferentes. Reinstale a atualização mais recente; seus perfis permanecerão seguros.",
"updateProfilesRunning": "Pare todos os perfis em execução antes de instalar a atualização.",
"updatePreparationFailed": "O Donut Browser não conseguiu encerrar com segurança um processo de rede em segundo plano. Reinicie o computador e tente atualizar novamente.",
"wayfernTermsRequired": "Aceite os Termos e Condições da Wayfern antes de iniciar o servidor MCP.",
"wayfernTermsRequired": "Aceite os Termos e Condições da Wayfern para usar o Wayfern.",
"apiPortUnavailable": "O servidor da API não encontrou uma porta disponível.",
"mcpServerAlreadyRunning": "O servidor MCP já está em execução.",
"mcpServerNotRunning": "O servidor MCP não está em execução.",
@@ -2285,9 +2285,15 @@
"importSourceBrowserRunning": "Feche o {{browser}} primeiro ou escolha importar mesmo assim",
"wayfernFingerprintApplyFailed": "Não foi possível aplicar a impressão digital deste perfil, por isso o navegador não foi iniciado. {{detail}}",
"wayfernFingerprintGenerationFailed": "Não foi possível criar uma impressão digital para este perfil. {{detail}}",
"wayfernGenerationLimitReached": "O limite de geração de impressões digitais desta conta foi atingido. Os seus perfis existentes continuarão a abrir normalmente: apenas as novas impressões digitais estão pausadas e voltarão a ficar disponíveis um pouco mais tarde.",
"wayfernGenerationLimitReached": "Limite temporário de geração de impressões digitais atingido. Tente novamente mais tarde.",
"wayfernCrossOsRequiresPlan": "Este perfil declara {{detail}}, o que exige um plano pago e uma sessão ativa. Inicie sessão ou mude o perfil para o seu próprio sistema operativo.",
"wayfernIdentityRefused": "O navegador não iniciou com a identidade deste perfil, então foi fechado. {{detail}}",
"wayfernInstanceLimitReached": "Há navegadores abertos demais para o seu plano. Feche um navegador e tente novamente.",
"wayfernPlanCheckUnavailable": "Não foi possível verificar o seu plano agora. Tente novamente em instantes.",
"wayfernCustomFingerprintRequiresPlan": "O seu plano não inclui impressões digitais personalizadas.",
"wayfernGenerationUnavailable": "Não foi possível criar uma impressão digital agora. Tente novamente mais tarde.",
"wayfernBrowserBusy": "O navegador estava ocupado com a aplicação de uma impressão digital. Tente novamente.",
"wayfernBrowserExited": "O navegador fechou durante a inicialização. {{detail}}",
"profileExportFailed": "Não foi possível exportar este perfil. {{detail}}",
"profileExportTooLarge": "Este perfil é grande demais para exportar. Limpe parte dos dados de navegação e tente de novo.",
"profileImportFailed": "Não foi possível ler este arquivo. {{detail}}",
+8 -2
View File
@@ -2220,7 +2220,7 @@
"proxySidecarVersionMismatch": "Некоторые файлы Donut Browser относятся к разным версиям. Переустановите последнее обновление — ваши профили останутся в безопасности.",
"updateProfilesRunning": "Остановите все запущенные профили перед установкой обновления.",
"updatePreparationFailed": "Donut Browser не удалось безопасно остановить фоновый сетевой процесс. Перезагрузите компьютер и повторите обновление.",
"wayfernTermsRequired": "Примите Условия использования Wayfern перед запуском сервера MCP.",
"wayfernTermsRequired": "Примите Условия использования Wayfern, чтобы пользоваться Wayfern.",
"apiPortUnavailable": "Серверу API не удалось найти свободный порт.",
"mcpServerAlreadyRunning": "Сервер MCP уже запущен.",
"mcpServerNotRunning": "Сервер MCP не запущен.",
@@ -2295,9 +2295,15 @@
"importSourceBrowserRunning": "Сначала закройте {{browser}} или выберите импорт всё равно",
"wayfernFingerprintApplyFailed": "Не удалось применить отпечаток этого профиля, поэтому браузер не был запущен. {{detail}}",
"wayfernFingerprintGenerationFailed": "Не удалось создать отпечаток для этого профиля. {{detail}}",
"wayfernGenerationLimitReached": "Достигнут лимит создания отпечатков для этого аккаунта. Существующие профили продолжат запускаться как обычно, приостановлены только новые отпечатки, они снова станут доступны чуть позже.",
"wayfernGenerationLimitReached": "Достигнут временный лимит создания отпечатков. Повторите попытку позже.",
"wayfernCrossOsRequiresPlan": "Этот профиль заявляет {{detail}}, для чего нужны платный тариф и активный вход. Войдите в аккаунт или переключите профиль на вашу операционную систему.",
"wayfernIdentityRefused": "Браузер запустился не с идентичностью этого профиля, поэтому был закрыт. {{detail}}",
"wayfernInstanceLimitReached": "Для вашего тарифа открыто слишком много браузеров. Закройте один браузер и попробуйте снова.",
"wayfernPlanCheckUnavailable": "Сейчас не удалось проверить ваш тариф. Попробуйте снова через минуту.",
"wayfernCustomFingerprintRequiresPlan": "Ваш тариф не включает пользовательские отпечатки.",
"wayfernGenerationUnavailable": "Сейчас не удалось создать отпечаток. Повторите попытку позже.",
"wayfernBrowserBusy": "Браузер был занят применением отпечатка. Попробуйте снова.",
"wayfernBrowserExited": "Браузер закрылся во время запуска. {{detail}}",
"profileExportFailed": "Не удалось экспортировать этот профиль. {{detail}}",
"profileExportTooLarge": "Профиль слишком велик для экспорта. Очистите часть данных браузера и попробуйте снова.",
"profileImportFailed": "Не удалось прочитать этот архив. {{detail}}",
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "Bazı Donut Browser dosyaları farklı sürümlere ait. En son güncellemeyi yeniden yükleyin; profilleriniz güvende kalır.",
"updateProfilesRunning": "Güncellemeyi yüklemeden önce çalışan tüm profilleri durdurun.",
"updatePreparationFailed": "Donut Browser arka plandaki bir ağ işlemini güvenli şekilde durduramadı. Bilgisayarınızı yeniden başlatıp güncellemeyi tekrar deneyin.",
"wayfernTermsRequired": "MCP sunucusunu başlatmadan önce Wayfern Hüküm ve Koşullarını kabul edin.",
"wayfernTermsRequired": "Wayfern'ü kullanmak için Wayfern Hüküm ve Koşullarını kabul edin.",
"apiPortUnavailable": "API sunucusu kullanılabilir bir bağlantı noktası bulamadı.",
"mcpServerAlreadyRunning": "MCP sunucusu zaten çalışıyor.",
"mcpServerNotRunning": "MCP sunucusu çalışmıyor.",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "Önce {{browser}} uygulamasını kapatın veya yine de içe aktarmayı seçin",
"wayfernFingerprintApplyFailed": "Bu profilin parmak izi uygulanamadı, bu yüzden tarayıcı başlatılmadı. {{detail}}",
"wayfernFingerprintGenerationFailed": "Bu profil için parmak izi oluşturulamadı. {{detail}}",
"wayfernGenerationLimitReached": "Bu hesabın parmak izi oluşturma sınırına ulaşıldı. Mevcut profilleriniz normal şekilde açılmaya devam eder; yalnızca yeni parmak izleri duraklatıldı ve biraz sonra tekrar kullanılabilir olacak.",
"wayfernGenerationLimitReached": "Geçici parmak izi oluşturma sınırına ulaşıldı. Daha sonra tekrar deneyin.",
"wayfernCrossOsRequiresPlan": "Bu profil {{detail}} bildiriyor; bunun için ücretli bir plan ve etkin bir oturum gerekir. Oturum açın veya profili kendi işletim sisteminize geçirin.",
"wayfernIdentityRefused": "Tarayıcı bu profilin kimliğiyle başlamadığı için kapatıldı. {{detail}}",
"wayfernInstanceLimitReached": "Planınız için çok fazla tarayıcı açık. Bir tarayıcıyı kapatıp tekrar deneyin.",
"wayfernPlanCheckUnavailable": "Planınız şu anda kontrol edilemedi. Kısa süre sonra yeniden deneyin.",
"wayfernCustomFingerprintRequiresPlan": "Planınız özel parmak izlerini içermiyor.",
"wayfernGenerationUnavailable": "Şu anda parmak izi oluşturulamadı. Daha sonra tekrar deneyin.",
"wayfernBrowserBusy": "Tarayıcı bir parmak izi uygulamakla meşguldü. Tekrar deneyin.",
"wayfernBrowserExited": "Tarayıcı başlatılırken kapandı. {{detail}}",
"profileExportFailed": "Bu profil dışa aktarılamadı. {{detail}}",
"profileExportTooLarge": "Bu profil dışa aktarmak için fazla büyük. Gezinme verilerinin bir kısmını silip yeniden deneyin.",
"profileImportFailed": "Bu arşiv okunamadı. {{detail}}",
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "Một số tệp Donut Browser thuộc các phiên bản khác nhau. Hãy cài đặt lại bản cập nhật mới nhất; hồ sơ của bạn vẫn được giữ an toàn.",
"updateProfilesRunning": "Hãy dừng tất cả hồ sơ đang chạy trước khi cài đặt bản cập nhật.",
"updatePreparationFailed": "Donut Browser không thể dừng an toàn một tiến trình mạng chạy nền. Hãy khởi động lại máy tính rồi thử cập nhật lại.",
"wayfernTermsRequired": "Hãy chấp nhận Điều khoản và Điều kiện của Wayfern trước khi khởi động máy chủ MCP.",
"wayfernTermsRequired": "Hãy chấp nhận Điều khoản và Điều kiện của Wayfern để sử dụng Wayfern.",
"apiPortUnavailable": "Máy chủ API không tìm thấy cổng khả dụng.",
"mcpServerAlreadyRunning": "Máy chủ MCP đang chạy.",
"mcpServerNotRunning": "Máy chủ MCP chưa chạy.",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "Hãy đóng {{browser}} trước, hoặc chọn vẫn nhập",
"wayfernFingerprintApplyFailed": "Không thể áp dụng dấu vân tay của hồ sơ này nên trình duyệt chưa được khởi chạy. {{detail}}",
"wayfernFingerprintGenerationFailed": "Không thể tạo dấu vân tay cho hồ sơ này. {{detail}}",
"wayfernGenerationLimitReached": "Đã đạt đến giới hạn tạo dấu vân tay của tài khoản này. Các hồ sơ hiện có vẫn khởi chạy bình thường, chỉ dấu vân tay mới bị tạm dừng và sẽ khả dụng trở lại sau một lát.",
"wayfernGenerationLimitReached": "Đã đạt giới hạn tạm thời về tạo dấu vân tay. Vui lòng thử lại sau.",
"wayfernCrossOsRequiresPlan": "Hồ sơ này khai báo {{detail}}, cần gói trả phí và phiên đăng nhập đang hoạt động. Hãy đăng nhập hoặc chuyển hồ sơ sang hệ điều hành của bạn.",
"wayfernIdentityRefused": "Trình duyệt không khởi động với danh tính của hồ sơ này nên đã bị đóng. {{detail}}",
"wayfernInstanceLimitReached": "Có quá nhiều trình duyệt đang mở so với gói của bạn. Hãy đóng một trình duyệt rồi thử lại.",
"wayfernPlanCheckUnavailable": "Hiện không thể kiểm tra gói của bạn. Hãy thử lại sau giây lát.",
"wayfernCustomFingerprintRequiresPlan": "Gói của bạn không bao gồm dấu vân tay tùy chỉnh.",
"wayfernGenerationUnavailable": "Hiện không thể tạo dấu vân tay. Vui lòng thử lại sau.",
"wayfernBrowserBusy": "Trình duyệt đang bận áp dụng dấu vân tay. Hãy thử lại.",
"wayfernBrowserExited": "Trình duyệt đã đóng khi đang khởi động. {{detail}}",
"profileExportFailed": "Không thể xuất hồ sơ này. {{detail}}",
"profileExportTooLarge": "Hồ sơ này quá lớn để xuất. Hãy xóa bớt dữ liệu duyệt web rồi thử lại.",
"profileImportFailed": "Không thể đọc tệp lưu trữ này. {{detail}}",
+8 -2
View File
@@ -2200,7 +2200,7 @@
"proxySidecarVersionMismatch": "部分 Donut Browser 文件来自不同版本。请重新安装最新更新;你的配置文件将保持安全。",
"updateProfilesRunning": "安装更新前,请停止所有正在运行的配置文件。",
"updatePreparationFailed": "Donut Browser 无法安全停止后台网络进程。请重启电脑,然后再次尝试更新。",
"wayfernTermsRequired": "请先接受 Wayfern 条款与条件,再启动 MCP 服务器。",
"wayfernTermsRequired": "请先接受 Wayfern 条款与条件,再使用 Wayfern。",
"apiPortUnavailable": "API 服务器找不到可用端口。",
"mcpServerAlreadyRunning": "MCP 服务器已在运行。",
"mcpServerNotRunning": "MCP 服务器未运行。",
@@ -2275,9 +2275,15 @@
"importSourceBrowserRunning": "请先关闭 {{browser}},或选择仍要导入",
"wayfernFingerprintApplyFailed": "无法应用此配置文件的指纹,因此浏览器未启动。{{detail}}",
"wayfernFingerprintGenerationFailed": "无法为此配置文件创建指纹。{{detail}}",
"wayfernGenerationLimitReached": "此账户的指纹生成次数已达上限。现有配置文件仍可正常启动,仅新指纹被暂停,稍后会重新可用。",
"wayfernGenerationLimitReached": "已达到临时指纹生成上限。请稍后再试。",
"wayfernCrossOsRequiresPlan": "此配置文件声明为 {{detail}},需要付费套餐并保持登录。请登录,或将配置文件切换为您自己的操作系统。",
"wayfernIdentityRefused": "浏览器未以此配置文件的身份启动,因此已被关闭。{{detail}}",
"wayfernInstanceLimitReached": "当前套餐下打开的浏览器过多。请关闭一个浏览器后重试。",
"wayfernPlanCheckUnavailable": "目前无法检查你的套餐。请稍后再试。",
"wayfernCustomFingerprintRequiresPlan": "你的套餐不包含自定义指纹。",
"wayfernGenerationUnavailable": "目前无法创建指纹。请稍后再试。",
"wayfernBrowserBusy": "浏览器正忙于应用指纹。请重试。",
"wayfernBrowserExited": "浏览器在启动过程中关闭了。{{detail}}",
"profileExportFailed": "无法导出此配置文件。{{detail}}",
"profileExportTooLarge": "此配置文件太大,无法导出。请清理部分浏览数据后重试。",
"profileImportFailed": "无法读取此存档。{{detail}}",
+20 -4
View File
@@ -207,16 +207,18 @@ export type BackendErrorCode =
// can have. `detail` carries the underlying browser error for support.
| "WAYFERN_FINGERPRINT_APPLY_FAILED"
| "WAYFERN_FINGERPRINT_GENERATION_FAILED"
// Its own code rather than a generation failure: the block applies to the
// whole account rather than one profile, so "try again" is wrong advice, and
// a user whose every profile refuses at once has to be told this is one
// limit and not a broken install.
| "WAYFERN_GENERATION_LIMIT_REACHED"
// A cross-OS claim needs an active signed-in session, so an expired or
// offline one cannot apply it. Distinct from the generic apply failure because
// signing in again is the fix; `detail` names the claimed OS.
| "WAYFERN_CROSS_OS_REQUIRES_PLAN"
| "WAYFERN_IDENTITY_REFUSED"
| "WAYFERN_INSTANCE_LIMIT_REACHED"
| "WAYFERN_PLAN_CHECK_UNAVAILABLE"
| "WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN"
| "WAYFERN_GENERATION_UNAVAILABLE"
| "WAYFERN_BROWSER_BUSY"
| "WAYFERN_BROWSER_EXITED"
| "PROFILE_EXPORT_FAILED"
| "PROFILE_EXPORT_TOO_LARGE"
| "PROFILE_IMPORT_FAILED"
@@ -761,6 +763,20 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.wayfernIdentityRefused", {
detail: parsed.params?.detail ?? "",
});
case "WAYFERN_INSTANCE_LIMIT_REACHED":
return t("backendErrors.wayfernInstanceLimitReached");
case "WAYFERN_PLAN_CHECK_UNAVAILABLE":
return t("backendErrors.wayfernPlanCheckUnavailable");
case "WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN":
return t("backendErrors.wayfernCustomFingerprintRequiresPlan");
case "WAYFERN_GENERATION_UNAVAILABLE":
return t("backendErrors.wayfernGenerationUnavailable");
case "WAYFERN_BROWSER_BUSY":
return t("backendErrors.wayfernBrowserBusy");
case "WAYFERN_BROWSER_EXITED":
return t("backendErrors.wayfernBrowserExited", {
detail: parsed.params?.detail ?? "",
});
case "PROFILE_EXPORT_FAILED":
return t("backendErrors.profileExportFailed", {
detail: parsed.params?.detail ?? "",