feat: prevent launch with inconsistent geodata

This commit is contained in:
zhom
2026-08-05 21:39:10 -07:00
parent 29cb83d063
commit 39bbdcb547
41 changed files with 4010 additions and 644 deletions
+2 -1
View File
@@ -174,8 +174,9 @@ export const commandCoverage = {
"generate_sample_fingerprint",
"is_geoip_database_available",
"download_geoip_database",
"fingerprint_consistency::check_profile_fingerprint_consistency",
"fingerprint_consistency::match_profile_fingerprint_to_exit",
"launch_gate::get_profile_pre_launch_checks",
"launch_gate::ack_launch_gate",
"check_wayfern_terms_accepted",
"check_wayfern_downloaded",
"accept_wayfern_terms",
+42 -6
View File
@@ -244,13 +244,49 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
profileId: profile.id,
exitIp: "8.8.8.8",
});
const consistency = await app.invoke(
"check_profile_fingerprint_consistency",
{
profileId: profile.id,
},
// Pre-launch gate: local-only checks that must answer without starting a
// proxy, an Xray worker or the browser.
const checks = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
assert.ok(Array.isArray(checks.vpn_extensions));
assert.equal(
typeof checks.scan_state,
"string",
"the scan must report whether it saw the whole profile",
);
assert.equal(typeof checks.consistency, "object");
assert.equal(typeof checks.exit_probe_pending, "boolean");
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
// This profile has no VPN extension, so nothing may block its launch.
assert.equal(
checks.vpn_extensions.length,
0,
"a clean profile must not report a VPN extension",
);
assert.equal(
checks.consent_token,
null,
"a consent token is only minted when a cached mismatch is blocking",
);
// Acknowledgements are per-profile and must be accepted for both kinds.
await app.invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: false,
ackExtensionKeys: ["crx:e2e-nonexistent-extension"],
});
await app.invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [],
});
assert.match(
await app.invokeError("get_profile_pre_launch_checks", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
assert.equal(typeof consistency, "object");
const directProfile = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
+8 -7
View File
@@ -2392,9 +2392,7 @@ async fn run_profile(
state.app_handle.clone(),
profile.clone(),
url,
Some(remote_debugging_port),
headless,
true,
crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
)
.await
.map_err(manager_error_response)?;
@@ -3469,7 +3467,12 @@ async fn open_url_in_profile(
let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner
.open_url_with_profile(state.app_handle.clone(), id, request.url)
.open_url_with_profile(
state.app_handle.clone(),
id,
request.url,
crate::launch_gate::FingerprintGate::Advisory,
)
.await
.map_err(manager_error_response)?;
@@ -3626,9 +3629,7 @@ async fn batch_run_profiles(
state.app_handle.clone(),
profile.clone(),
request.url.clone(),
Some(port),
headless,
true,
crate::browser_runner::LaunchOptions::automation(Some(port), headless),
)
.await
{
+272 -65
View File
@@ -172,12 +172,15 @@ impl BrowserRunner {
});
}
/// Resolve the upstream a launch will use.
///
/// Deliberately does NOT fire the launch hook: that moved below the gate, so
/// a launch the user blocks and then retries calls the user's webhook once
/// rather than once per attempt.
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
Self::fire_launch_hook(profile);
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await
@@ -210,9 +213,9 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
_local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
@@ -277,12 +280,61 @@ impl BrowserRunner {
upstream_proxy = Some(worker.local_proxy_settings());
}
/// Stops a VPN worker this launch started, if the launch then fails.
///
/// `created` is the whole point: `start_vpn_worker` reuses a live worker
/// for the same VPN, so an unconditional stop would sever the tunnel a
/// *different* profile is browsing through the moment this one is
/// cancelled. The in-use check is a second belt for a worker adopted by a
/// browser that started between the two points.
struct VpnLaunchGuard {
worker_id: Option<String>,
vpn_id: String,
created: bool,
profile_name: String,
}
impl Drop for VpnLaunchGuard {
fn drop(&mut self) {
let Some(worker_id) = self.worker_id.take() else {
return;
};
if !self.created {
return;
}
log::warn!(
"Launch failed after VPN worker start for profile {}; stopping worker",
self.profile_name
);
let vpn_id = self.vpn_id.clone();
tauri::async_runtime::spawn(async move {
// Serialize against worker startup for the whole check-then-stop.
// Without it another launch can adopt this worker between the
// in-use check and the kill, and lose its tunnel a moment later.
let _adopt_guard = crate::vpn_worker_runner::lock_vpn_starts().await;
if crate::vpn_worker_runner::vpn_id_in_use_by_running_browser(&vpn_id) {
log::info!("VPN {vpn_id} is still in use by a running browser; leaving it up");
return;
}
if let Err(error) = crate::vpn_worker_runner::stop_vpn_worker(&worker_id).await {
log::warn!("Failed to stop VPN worker after failed launch: {error}");
}
});
}
}
let mut vpn_launch_guard: Option<VpnLaunchGuard> = None;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
match crate::vpn_worker_runner::start_vpn_worker_tracked(vpn_id).await {
Ok(started) => {
vpn_launch_guard = Some(VpnLaunchGuard {
worker_id: Some(started.config.id.clone()),
vpn_id: vpn_id.clone(),
created: started.created,
profile_name: profile.name.clone(),
});
if let Some(port) = started.config.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
@@ -295,12 +347,33 @@ impl BrowserRunner {
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}").into());
return Err(crate::backend_error_with_detail("VPN_WORKER_START_FAILED", e).into());
}
}
}
}
// The gate sits exactly here on purpose. By this line the upstream is
// fully normalized across all three transports — VLESS and VPN are
// authenticated loopback workers, a stored proxy is its resolved
// settings — so one probe covers every profile. And it is still ahead of
// the local proxy worker, the decrypted profile copy, the extension
// unpack, and the browser process, so a blocked launch has nothing to
// undo beyond the two workers whose guards are already armed above.
//
// Run concurrently with the blocklist compile so the added wall clock is
// max(), not sum().
let (blocklist, gate_result) = tokio::join!(
Self::resolve_blocklist_file(profile),
crate::launch_gate::enforce_fingerprint_gate(profile, upstream_proxy.as_ref(), gate),
);
gate_result.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let (blocklist_file, dns_allowlist_mode) = blocklist?;
// Past the gate: this launch is really happening, so tell the user's
// webhook exactly once.
Self::fire_launch_hook(profile);
log::info!(
"Starting local proxy for Wayfern profile: {} (upstream: {})",
profile.name,
@@ -313,7 +386,6 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
let profile_id_str = profile.id.to_string();
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
// Unique per-launch key: a shared constant here would let concurrent
// launches overwrite each other's active_proxies entry, ending with one
// browser's worker tracked under another browser's PID.
@@ -553,11 +625,14 @@ impl BrowserRunner {
}
}
// The browser and both detached routing workers now share one verified
// The browser and every detached routing worker now share one verified
// process identity, so later profile-persistence failures must not tear
// down a live route.
proxy_launch_guard.armed = false;
xray_launch_guard.worker_id = None;
if let Some(guard) = vpn_launch_guard.as_mut() {
guard.worker_id = None;
}
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
@@ -694,6 +769,7 @@ impl BrowserRunner {
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Wayfern starts (and PID-reconciles) its own local proxy
// inside `launch_browser_internal`, so we hand it None here rather than
@@ -703,9 +779,9 @@ impl BrowserRunner {
app_handle,
profile,
url,
None,
remote_debugging_port,
headless,
gate,
)
.await
}
@@ -716,6 +792,7 @@ impl BrowserRunner {
profile: &BrowserProfile,
url: Option<String>,
internal_proxy_settings: Option<&ProxySettings>,
gate: &crate::launch_gate::FingerprintGate,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
log::info!(
"launch_or_open_url called for profile: {} (ID: {})",
@@ -796,14 +873,7 @@ impl BrowserRunner {
} else {
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
.await
}
}
@@ -1270,6 +1340,7 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile_id: String,
url: String,
gate: crate::launch_gate::FingerprintGate,
) -> Result<(), String> {
// Get the profile by name
let profiles = self
@@ -1319,21 +1390,30 @@ impl BrowserRunner {
// dropped event stream plus an unreachable backend) fell straight through
// to a local launch on a profile a host was writing to.
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
log::info!("Opening URL with selected profile");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
if let Err(e) = self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None, &gate)
.await
.map_err(|e| {
log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
format!("Failed to open URL with profile: {e}")
})?;
{
log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
// This path takes the team lock too, and a blocked launch never records a
// process_id for the status sweep to release it from.
unwind_launch(&profile, acquired_team_lock).await;
// Pass structured errors through untouched: the gate's block carries the
// mismatch detail the dialog renders, and wrapping it in English would
// reach the user as raw JSON.
return Err(crate::wrap_backend_error(
e,
"Failed to open URL with profile",
));
}
log::info!("Successfully opened URL with selected profile");
Ok(())
@@ -1345,18 +1425,115 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
consent_token: Option<String>,
) -> Result<BrowserProfile, String> {
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
let options = LaunchOptions {
gate: match consent_token {
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
None => crate::launch_gate::FingerprintGate::Enforce,
},
..Default::default()
};
launch_browser_profile_impl(app_handle, profile, url, options).await
}
/// How one launch should behave.
///
/// A struct rather than four trailing positional arguments: `headless` and
/// `force_new` are already passed adjacently as bare booleans, so a fifth would
/// compile everywhere while silently inverting behavior wherever the order was
/// got wrong.
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
pub remote_debugging_port: Option<u16>,
pub headless: bool,
pub force_new: bool,
pub gate: crate::launch_gate::FingerprintGate,
}
impl LaunchOptions {
/// Automation defaults: report, never block, never probe. A headless client
/// has no dialog to answer and cannot regenerate its fingerprint mid-run, so
/// a hard failure would turn a warning into an outage for a whole fleet.
pub fn automation(remote_debugging_port: Option<u16>, headless: bool) -> Self {
Self {
remote_debugging_port,
headless,
force_new: true,
gate: crate::launch_gate::FingerprintGate::Advisory,
}
}
}
/// Release the team lock a launch attempt took before it failed.
///
/// Until the gate existed, failing here was rare enough that leaking was merely
/// untidy. Cancelling a blocked launch is now an ordinary outcome, and the lock
/// renews itself on a 30s heartbeat while only ever being released via a stored
/// `process_id` — which a launch that never spawned does not have. So a leak
/// leaves the profile reading as locked to the whole team until the app quits.
///
/// `acquired` is threaded from `acquire_team_lock_if_needed` so this releases
/// only what this call took, never a lock a REST handler up the stack owns.
///
/// Several of these error paths are reachable while a browser for the profile
/// is genuinely still running — `PROFILE_RUNNING`, or a failure to open a URL
/// in an existing window. That browser owns the lock and the running mark, so
/// releasing either would strand it: the team would see the profile as free
/// while someone is typing in it, and `mark_profile_stopped` would queue a sync
/// of a profile directory being written to. Hence the liveness check.
async fn unwind_launch(profile: &BrowserProfile, acquired_team_lock: bool) {
if browser_is_running_for(&profile.id.to_string()) {
log::debug!(
"Not unwinding launch state for {}: a browser is still running for it",
profile.name
);
return;
}
if acquired_team_lock {
crate::team_lock::release_team_lock_if_needed(profile).await;
}
// Otherwise this mark sticks for the rest of the session and silently defers
// every sync of the profile. Safe here precisely because nothing is running.
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler
.mark_profile_stopped(&profile.id.to_string())
.await;
}
}
/// Whether a live browser process is recorded for this profile right now.
/// Re-read from disk: the caller's copy predates the launch attempt.
fn browser_is_running_for(profile_id: &str) -> bool {
BrowserRunner::instance()
.profile_manager
.list_profiles()
.ok()
.and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.map(|p| {
p.process_id
.is_some_and(crate::proxy_storage::is_process_running)
})
})
.unwrap_or(false)
}
pub async fn launch_browser_profile_impl(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
force_new: bool,
options: LaunchOptions,
) -> Result<BrowserProfile, String> {
let LaunchOptions {
remote_debugging_port,
headless,
force_new,
gate,
} = options;
log::info!(
"Launch request received for profile: {} (ID: {})",
profile.name,
@@ -1379,7 +1556,7 @@ pub async fn launch_browser_profile_impl(
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
// Notify sync scheduler that profile is now running and queue sync for when it stops
if let Some(scheduler) = crate::sync::get_global_scheduler() {
@@ -1403,6 +1580,7 @@ pub async fn launch_browser_profile_impl(
.find(|p| p.id == profile.id)
.unwrap_or_else(|| profile.clone()),
Err(e) => {
unwind_launch(&profile, acquired_team_lock).await;
return Err(e);
}
};
@@ -1419,15 +1597,24 @@ pub async fn launch_browser_profile_impl(
profile_for_launch.id
);
if force_new
&& browser_runner
if force_new {
let already_running = match browser_runner
.check_browser_status(app_handle.clone(), &profile_for_launch)
.await
.map_err(|error| {
crate::wrap_backend_error(error, "Failed to check browser status before launch")
})?
{
return Err(crate::backend_error("PROFILE_RUNNING"));
{
Ok(running) => running,
Err(error) => {
unwind_launch(&profile, acquired_team_lock).await;
return Err(crate::wrap_backend_error(
error,
"Failed to check browser status before launch",
));
}
};
if already_running {
unwind_launch(&profile, acquired_team_lock).await;
return Err(crate::backend_error("PROFILE_RUNNING"));
}
}
// Launch browser or open URL in existing instance. Wayfern starts its
@@ -1445,39 +1632,54 @@ pub async fn launch_browser_profile_impl(
url,
remote_debugging_port,
headless,
&gate,
)
.await
} else {
browser_runner
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None, &gate)
.await
};
let updated_profile = launch_result.map_err(|e| {
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
let updated_profile = match launch_result {
Ok(updated) => updated,
Err(e) => {
log::info!(
"Browser launch failed for profile: {}, error: {}",
profile_for_launch.name,
e
);
// Emit a failure event to clear loading states in the frontend
#[derive(serde::Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: profile_for_launch.id.to_string(),
is_running: false,
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
// Emit a failure event to clear loading states in the frontend
#[derive(serde::Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: profile_for_launch.id.to_string(),
is_running: false,
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
unwind_launch(&profile, acquired_team_lock).await;
// Check if this is an architecture compatibility issue
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
if io_error.kind() == std::io::ErrorKind::Other
&& io_error.to_string().contains("Exec format error")
{
return Err(format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH));
}
}
return Err(crate::wrap_backend_error(
e,
"Failed to launch browser or open URL",
));
}
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
})?;
};
log::info!(
"Browser launch completed for profile: {} (ID: {})",
@@ -1610,10 +1812,15 @@ pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
profile_id: String,
url: String,
consent_token: Option<String>,
) -> Result<(), String> {
let browser_runner = BrowserRunner::instance();
let gate = match consent_token {
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
None => crate::launch_gate::FingerprintGate::Enforce,
};
browser_runner
.open_url_with_profile(app_handle, profile_id, url)
.open_url_with_profile(app_handle, profile_id, url, gate)
.await
}
+36 -11
View File
@@ -28,9 +28,9 @@ const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
/// flags instead of a single "is paid?" boolean, so a plan like the future
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
/// data here.
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
/// hands-on remote session) is just data here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entitlements {
#[serde(default)]
@@ -49,6 +49,12 @@ pub struct Entitlements {
/// together.
#[serde(rename = "cookieBot", default)]
pub cookie_bot: bool,
/// Whether the plan may open a HANDS-ON remote session. Distinct from
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
/// drive a remote browser itself, so anything that offers interactive remote
/// control must read THIS rather than `remote_browser_hours > 0`.
#[serde(rename = "remoteInteractive", default)]
pub remote_interactive: bool,
#[serde(rename = "profileLimit", default)]
pub profile_limit: i64,
#[serde(rename = "requestsPerHour", default)]
@@ -77,16 +83,29 @@ fn derive_entitlements(
cloud_backup: false,
team_collaboration: false,
cookie_bot: false,
remote_interactive: false,
profile_limit: 0,
requests_per_hour: 0,
remote_browser_hours: 0,
};
}
// pro and any unrecognized paid plan -> pro-level (never team).
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
"starter" => (false, true, true, false),
"team" | "enterprise" => (true, true, true, true),
_ => (true, true, true, false),
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
// team_collaboration, cookie_bot, remote_interactive).
//
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
// one row where cookie_bot and browser_automation disagree, which is why
// cookie_bot can no longer be derived from browser_automation below.
let (
browser_automation,
cross_os_fingerprints,
cloud_backup,
team_collaboration,
cookie_bot,
remote_interactive,
) = match plan {
"solo" => (false, false, true, false, true, false),
"team" | "enterprise" => (true, true, true, true, true, true),
_ => (true, true, true, false, true, true),
};
Entitlements {
active,
@@ -94,9 +113,8 @@ fn derive_entitlements(
cross_os_fingerprints,
cloud_backup,
team_collaboration,
// A bot run IS remote automation on leased hardware, so the two capabilities
// never diverge: a plan that cannot drive a browser cannot warm one either.
cookie_bot: browser_automation,
cookie_bot,
remote_interactive,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
@@ -155,6 +173,13 @@ impl CloudUser {
/// locally from the plan fields (keeps older cached state / backends working).
pub fn entitlements(&self) -> Entitlements {
if let Some(e) = &self.entitlements {
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
// backend older than this release leaves on `cookie_bot` /
// `remote_interactive`. Repairing it here is impossible anyway — serde's
// default erases the difference between "sent false" and "not sent" — and
// it is not this layer's job: nothing in Rust gates on either flag, and
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
// single point every UI consumer already goes through.
return e.clone();
}
derive_entitlements(
+58 -30
View File
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
0
}
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
/// header by seeking to the embedded ZIP. Shared with
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
/// from the metadata subset persisted on `Extension`.
pub(crate) fn read_manifest_from_archive(
file_data: &[u8],
file_type: &str,
) -> Option<serde_json::Value> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut contents = String::new();
{
let mut file = archive.by_name("manifest.json").ok()?;
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
}
serde_json::from_str(&contents).ok()
}
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
/// messages. Chromium extensions routinely localize `name`/`description`, and
/// showing the raw placeholder in a warning dialog reads as a bug.
pub(crate) fn resolve_archive_i18n(
file_data: &[u8],
file_type: &str,
manifest: &serde_json::Value,
value: &str,
) -> Option<String> {
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut contents = String::new();
{
let mut file = archive
.by_name(&format!("_locales/{default_locale}/messages.json"))
.ok()?;
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
}
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
crate::vpn_extension_detect::lookup_message(&messages, &key)
}
#[allow(clippy::type_complexity)]
fn extract_manifest_metadata(
file_data: &[u8],
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
Option<String>,
Option<String>,
) {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return (None, None, None, None, None),
};
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = match manifest_content {
Some(c) => c,
let manifest = match read_manifest_from_archive(file_data, file_type) {
Some(v) => v,
None => return (None, None, None, None, None),
};
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
Ok(v) => v,
Err(_) => return (None, None, None, None, None),
};
let name = manifest
.get("name")
.and_then(|v| v.as_str())
+417 -147
View File
@@ -1,11 +1,15 @@
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
//! with the bundled MaxMind database (the same source the fingerprint generator
//! uses), then compare its timezone and country against the profile
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
//! behind a German exit IP) is a strong anti-bot tell even though the real
//! device never leaks — so we warn the user after launch and offer to match the
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
//! a real mismatch always surfaces here.
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
//!
//! Resolve the exit IP through the upstream, geolocate it with the bundled
//! MaxMind database (the same source the fingerprint generator uses), then
//! compare its timezone and country against the fingerprint's timezone and
//! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a
//! strong anti-bot tell even though the real device never leaks.
//!
//! This module only measures. Deciding what a mismatch *means* for a launch —
//! block, warn, or ignore — belongs to `launch_gate`, which calls
//! `probe_and_check_consistency` before the browser is spawned. Launches never
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
/// on every launch is wasteful.
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
/// a 10s timeout each, which is fine for a background check but far longer
/// than a user will wait staring at a launch that has not started yet.
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
#[derive(Clone)]
struct CachedExit {
fetched_at: u64,
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
/// The endpoint this exit was measured through. Editing a stored proxy keeps
/// its id, so without this an entry outlives the endpoint it describes: the
/// check would compare a re-generated fingerprint against the *old* exit and
/// either warn about a correct profile or — worse — call a genuinely
/// mismatched one consistent, which is exactly the tell it exists to catch.
proxy_url: String,
///
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
/// random port and credentials each time, so keying on those would miss on
/// every relaunch and re-probe forever.
identity: String,
timezone: Option<String>,
country_code: Option<String>,
ip: Option<String>,
}
/// Identity of the exit a profile routes through, stable across worker
/// restarts.
///
/// `scope` keys the cache; `identity` detects that the endpoint behind that
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
/// so two profiles sharing one stored proxy correctly get different identities
/// and never inherit each other's verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitCacheKey {
pub scope: String,
pub identity: String,
}
/// Resolve the cache identity from the profile's *stored* configuration.
///
/// Deliberately not derived from the normalized upstream the launcher passes
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
/// port and credentials are regenerated per launch.
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
if let Some(proxy_id) = &profile.proxy_id {
let settings = PROXY_MANAGER
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
// call covers every transport.
return Some(ExitCacheKey {
scope: format!("proxy:{proxy_id}"),
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
});
}
if let Some(vpn_id) = &profile.vpn_id {
return Some(ExitCacheKey {
scope: format!("vpn:{vpn_id}"),
identity: vpn_id.clone(),
});
}
None
}
lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
}
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
}
impl ConsistencyResult {
fn skip() -> Self {
pub fn skip() -> Self {
Self {
consistent: true,
checked: false,
@@ -68,18 +120,15 @@ impl ConsistencyResult {
}
}
/// URL for handing this proxy to reqwest. VLESS is reached through the
/// authenticated loopback Xray-core worker already serving the profile.
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
/// Whether this upstream can carry a probe request at all.
///
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
/// rather than guessed at.
fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
),
"vless" => profile_id
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
.map(|worker| {
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
}),
_ => None,
}
}
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
(timezone, language)
}
/// Run the check for a profile. No-ops (consistent, unchecked) when the
/// profile has no proxy or the exit node can't be reached.
pub async fn check_profile_consistency(
/// A mutex whose poison is not fatal.
///
/// A panic anywhere under this lock used to brick the check process-wide.
/// That was tolerable when a failed check only skipped a warning; now a launch
/// consults it, so a poisoned lock must degrade rather than propagate.
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
pub fn compare_exit_to_fingerprint(
profile: &BrowserProfile,
) -> Result<ConsistencyResult, String> {
let Some(proxy_id) = &profile.proxy_id else {
return Ok(ConsistencyResult::skip());
};
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let profile_id = profile.id.to_string();
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
return Ok(ConsistencyResult::skip());
};
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
} else {
url.clone()
};
let now = crate::proxy_manager::now_secs();
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
// it was measured through the proxy's current endpoint and credentials.
let cached = {
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| {
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
};
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
(c.timezone, c.country_code, c.ip)
} else {
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
.await
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
let ip = Some(exit_ip);
EXIT_CACHE.lock().unwrap().insert(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: cache_identity,
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
},
);
(tz, cc, ip)
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
return Ok(ConsistencyResult::skip());
}
}
};
exit_timezone: Option<String>,
exit_country_code: Option<String>,
exit_ip: Option<String>,
) -> ConsistencyResult {
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut mismatches = Vec::new();
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
}
}
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
if language_matches_country(cc, lang) == Some(false) {
mismatches.push("language".to_string());
}
}
Ok(ConsistencyResult {
ConsistencyResult {
consistent: mismatches.is_empty(),
checked: true,
exit_ip,
exit_country_code: exit_cc,
exit_timezone: exit_tz,
exit_country_code,
exit_timezone,
fingerprint_timezone: fp_tz,
fingerprint_language: fp_lang,
mismatches,
})
}
}
#[tauri::command]
pub async fn check_profile_fingerprint_consistency(
profile_id: String,
/// Look up a still-valid cached exit for this profile.
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
let now = crate::proxy_manager::now_secs();
exit_cache()
.get(&key.scope)
.filter(|c| {
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
}
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
let Some(key) = exit_cache_key(profile) else {
return ConsistencyResult::skip();
};
let Some(cached) = cached_exit(&key) else {
return ConsistencyResult::skip();
};
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
}
/// Drop any cached exit for this profile, so the next check re-measures.
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
if let Some(key) = exit_cache_key(profile) {
exit_cache().remove(&key.scope);
}
}
/// Measure the exit through an already-normalized upstream and compare it to
/// the fingerprint.
///
/// `upstream` is what the launcher will actually hand the browser — a loopback
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
/// code path covers every transport. `None` means a genuine direct connection,
/// which has nothing to disagree with.
pub async fn probe_and_check_consistency(
profile: &BrowserProfile,
upstream: Option<&crate::browser::ProxySettings>,
key: &ExitCacheKey,
) -> Result<ConsistencyResult, String> {
let profiles = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
check_profile_consistency(&profile).await
if let Some(cached) = cached_exit(key) {
return Ok(compare_exit_to_fingerprint(
profile,
cached.timezone,
cached.country_code,
cached.ip,
));
}
let Some(settings) = upstream else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = probe_url(settings) else {
return Ok(ConsistencyResult::skip());
};
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
//
// Bounded independently of fetch_public_ip's own per-request timeout: that
// one races six endpoints and can add up to far longer than a user will wait
// in front of a launch.
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
.await
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs(),
identity: key.identity.clone(),
timezone: tz.clone(),
country_code: cc.clone(),
ip: Some(exit_ip.clone()),
},
);
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_ip)))
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
Ok(ConsistencyResult::skip())
}
}
}
/// Measure the exit this machine reaches without any proxy, and compare it to
/// the fingerprint.
///
/// Used when a profile declares a route that did not materialize: the browser
/// is about to connect directly, so the direct exit is the one that matters.
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
/// not to any stored proxy, and it changes without a config edit.
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
.await
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => Ok(compare_exit_to_fingerprint(
profile,
Some(geo.timezone),
geo.locale.region.clone(),
Some(exit_ip),
)),
Err(e) => {
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
Ok(ConsistencyResult::skip())
}
}
}
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
.to_string()
})?;
// The stored verdict was computed against the fingerprint we just rewrote.
// Leaving it would re-block the very launch this fix exists to unblock.
invalidate_exit_cache(&profile);
Ok(())
}
@@ -344,55 +451,218 @@ mod tests {
assert!(language_matches_country("CH", "de-CH").is_some());
}
#[test]
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
let http = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "h".into(),
fn settings(
proxy_type: &str,
user: Option<&str>,
pass: Option<&str>,
) -> crate::browser::ProxySettings {
crate::browser::ProxySettings {
proxy_type: proxy_type.into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
username: user.map(str::to_string),
password: pass.map(str::to_string),
vless_uri: None,
};
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
}
}
#[test]
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
assert_eq!(
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
Some("http://u:p@gw.provider.io:8080")
);
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
let reserved = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
vless_uri: None,
};
assert_eq!(
proxy_url(&reserved, None).as_deref(),
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
// Username-only proxies keep their auth.
let user_only = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "h".into(),
port: 1080,
username: Some("justuser".into()),
password: None,
vless_uri: None,
};
assert_eq!(
proxy_url(&user_only, None).as_deref(),
Some("socks5://justuser@h:1080")
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
Some("socks4://justuser@gw.provider.io:8080")
);
let ss = crate::browser::ProxySettings {
proxy_type: "ss".into(),
host: "h".into(),
port: 8080,
username: None,
password: None,
vless_uri: None,
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
// guessed at.
assert_eq!(probe_url(&settings("ss", None, None)), None);
}
#[test]
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
assert!(
url.starts_with("socks5h://"),
"probe must not resolve the echo host locally, got {url}"
);
// The browser-facing builder is deliberately left alone.
assert!(
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
"socks5",
Some("u"),
Some("p")
))
.starts_with("socks5://")
);
}
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "p".into(),
browser: "wayfern".into(),
..Default::default()
};
assert_eq!(proxy_url(&ss, None), None);
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
),
..Default::default()
});
profile
}
#[test]
fn compare_flags_a_timezone_mismatch() {
let profile = profile_with_fingerprint("America/New_York", "en-US");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.checked);
assert!(!result.consistent);
assert!(result.mismatches.contains(&"timezone".to_string()));
}
#[test]
fn compare_accepts_a_matching_exit() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
Some("1.2.3.4".into()),
);
assert!(result.consistent, "{:?}", result.mismatches);
}
#[test]
fn compare_is_case_insensitive_on_timezone() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = compare_exit_to_fingerprint(
&profile,
Some("europe/berlin".into()),
Some("DE".into()),
None,
);
assert!(result.consistent);
}
#[test]
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
// A profile with no fingerprint has nothing to contradict; it must not be
// reported as a mismatch and so must never block a launch.
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
let result = compare_exit_to_fingerprint(
&profile,
Some("Europe/Berlin".into()),
Some("DE".into()),
None,
);
assert!(result.consistent);
assert!(result.mismatches.is_empty());
}
#[test]
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
let result = check_profile_consistency_cached(&profile);
assert!(!result.checked);
assert!(result.consistent, "an unchecked profile must never block");
}
#[test]
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
assert_eq!(exit_cache_key(&profile), None);
}
#[test]
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
profile.vpn_id = Some("vpn-abc".into());
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
assert_eq!(key.scope, "vpn:vpn-abc");
assert_eq!(key.identity, "vpn-abc");
}
#[test]
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
let key = ExitCacheKey {
scope: "proxy:test-identity-change".into(),
identity: "http://old@host:1".into(),
};
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs(),
identity: key.identity.clone(),
timezone: Some("Europe/Berlin".into()),
country_code: Some("DE".into()),
ip: Some("1.2.3.4".into()),
},
);
assert!(cached_exit(&key).is_some());
// Editing a stored proxy keeps its id but changes the endpoint; the old
// measurement must not be reused for the new one.
let rotated = ExitCacheKey {
identity: "http://new@host:2".into(),
..key.clone()
};
assert!(cached_exit(&rotated).is_none());
exit_cache().remove(&key.scope);
}
#[test]
fn cached_entry_expires_after_the_ttl() {
let key: ExitCacheKey = ExitCacheKey {
scope: "proxy:test-ttl".into(),
identity: "http://host:1".into(),
};
exit_cache().insert(
key.scope.clone(),
CachedExit {
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
identity: key.identity.clone(),
timezone: Some("Europe/Berlin".into()),
country_code: Some("DE".into()),
ip: None,
},
);
assert!(cached_exit(&key).is_none());
exit_cache().remove(&key.scope);
}
#[test]
fn exit_cache_survives_a_poisoned_lock() {
// A panic under this lock must degrade the check, not brick every
// subsequent launch that consults it.
let _ = std::thread::spawn(|| {
let _guard = EXIT_CACHE.lock().unwrap();
panic!("poison the cache");
})
.join();
assert!(EXIT_CACHE.is_poisoned());
exit_cache().remove("nonexistent-scope");
}
}
+526
View File
@@ -0,0 +1,526 @@
//! The pre-spawn launch gate.
//!
//! Two findings can stop a launch being what the user expects:
//!
//! * a **VPN/proxy extension** in the profile, which can override the proxy
//! Donut configured and silently move the browser's exit away from the one
//! the fingerprint was generated for — a warning, since Donut cannot tell
//! from outside whether it is actually routing anything;
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
//! browser does not start until the user explicitly proceeds.
//!
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
//! after the upstream has been normalized (so VLESS and VPN profiles are
//! reachable at all) and before the local proxy starts or the browser spawns.
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
//! first, so a profile whose exit is already known blocks without starting a
//! single worker.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use crate::fingerprint_consistency::{self, ConsistencyResult};
use crate::profile::types::BrowserProfile;
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
/// How long a "launch anyway" decision stays redeemable. Long enough to read
/// the dialog, short enough that a token cannot sit around across a session.
const CONSENT_TTL_SECS: u64 = 10 * 60;
/// What the gate is allowed to do on this launch.
#[derive(Debug, Clone, Default)]
pub enum FingerprintGate {
/// Block on a measured mismatch. The default, and what the GUI uses.
#[default]
Enforce,
/// Measure only from cache and report; never block, never probe the network.
/// Automation runs here: a headless client has no dialog to answer and
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
/// warning into an outage for a whole fleet.
Advisory,
/// The user already said "launch anyway" and handed back a token.
Consented(String),
}
struct PendingConsent {
profile_id: String,
fingerprint_hash: String,
exit_identity: String,
issued_at: u64,
}
lazy_static::lazy_static! {
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
}
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
}
fn random_token() -> String {
use rand::Rng;
let mut rng = rand::rng();
let mut bytes = [0u8; 16];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Issue a single-use token authorizing one launch of this exact
/// (profile, fingerprint, exit) combination.
///
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
/// while looking at proxy A would silently authorize a launch through proxy B
/// if they changed it before the retry landed.
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
let token = random_token();
let now = crate::proxy_manager::now_secs();
let mut store = consents();
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
store.insert(
token.clone(),
PendingConsent {
profile_id: profile.id.to_string(),
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
exit_identity: exit_identity.to_string(),
issued_at: now,
},
);
token
}
/// Redeem a consent token. Single use — a redeemed token is removed whether or
/// not it validated, so a leaked token cannot be replayed.
pub fn redeem_consent(
token: &str,
profile: &BrowserProfile,
exit_identity: &str,
) -> Result<(), String> {
let now = crate::proxy_manager::now_secs();
let pending = {
let mut store = consents();
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
store.remove(token)
};
let Some(pending) = pending else {
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
};
if pending.profile_id != profile.id.to_string()
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|| pending.exit_identity != exit_identity
{
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
}
Ok(())
}
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
serde_json::json!({
"code": "FINGERPRINT_EXIT_MISMATCH",
"params": {
"token": token,
"exitIp": result.exit_ip.clone().unwrap_or_default(),
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
"mismatches": result.mismatches.join(","),
}
})
.to_string()
}
fn gate_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.fingerprint_gate_disabled)
.unwrap_or(false)
}
fn extension_warning_disabled() -> bool {
crate::settings_manager::SettingsManager::instance()
.load_settings()
.map(|s| s.vpn_extension_warning_disabled)
.unwrap_or(false)
}
/// Identity used for consent and acknowledgement when the browser will connect
/// directly. Distinct from any proxy identity, so accepting a direct-exit
/// mismatch never disarms the gate for a proxied one.
const DIRECT_EXIT_IDENTITY: &str = "direct";
/// Gate a launch that will connect directly despite the profile declaring a
/// route. Measures the exit the browser will really use.
async fn enforce_direct_exit(
profile: &BrowserProfile,
gate: &FingerprintGate,
) -> Result<(), String> {
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
return Ok(());
}
if let FingerprintGate::Consented(token) = gate {
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
}
// Automation never probes; without a cache to consult there is nothing to say.
if matches!(gate, FingerprintGate::Advisory) {
return Ok(());
}
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
profile.name
);
return Ok(());
}
};
if !result.checked || result.consistent {
return Ok(());
}
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
Err(mismatch_error(&result, &token))
}
/// The enforcing gate. Called from the launch pipeline once the upstream is
/// normalized and before anything expensive or user-visible happens.
///
/// Fails **open** on every degradation — probe failure, timeout, missing geo
/// database, private exit IP. The gate blocks only on a positively measured
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
pub async fn enforce_fingerprint_gate(
profile: &BrowserProfile,
upstream: Option<&crate::browser::ProxySettings>,
gate: &FingerprintGate,
) -> Result<(), String> {
// A profile that declares no route is genuinely direct: the browser's exit is
// this machine, which is what an un-proxied fingerprint should describe.
//
// But a profile that DOES declare one and still arrives here with no upstream
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
// `None` and the launch continues. That is the exact leak this gate exists to
// stop, so it must be measured, not waved through.
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
if upstream.is_none() && !declares_route {
return Ok(());
}
let Some(key) = fingerprint_consistency::exit_cache_key(profile) else {
// Declares a route we can no longer resolve at all (e.g. the stored proxy
// was deleted). Nothing identifies the exit, so measure the direct one.
if declares_route {
log::warn!(
"Fingerprint gate: {} declares a proxy/VPN that did not resolve; \
measuring the direct exit it will actually use",
profile.name
);
}
return enforce_direct_exit(profile, gate).await;
};
if gate_disabled() {
return Ok(());
}
// Ack first: a persisted acknowledgement already permits this launch, so a
// stale token must not turn it into a hard failure.
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
return Ok(());
}
if let FingerprintGate::Consented(token) = gate {
redeem_consent(token, profile, &key.identity)?;
return Ok(());
}
// The route is known but produced no usable upstream (e.g. a VPN worker that
// came up without a local port). The browser still launches, direct.
if upstream.is_none() {
log::warn!(
"Fingerprint gate: {} has a route that yielded no upstream; measuring the direct exit",
profile.name
);
return enforce_direct_exit(profile, gate).await;
}
let result = if matches!(gate, FingerprintGate::Advisory) {
// Automation: answer from a warm cache or say nothing. Probing here would
// add seconds to every profile in a batch run.
fingerprint_consistency::check_profile_consistency_cached(profile)
} else {
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
Ok(result) => result,
Err(e) => {
log::warn!(
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
profile.name
);
return Ok(());
}
}
};
if !result.checked || result.consistent {
return Ok(());
}
// Only now is the extension scan worth its disk walk. A confirmed
// proxy-permission extension can redirect the browser's traffic away from the
// upstream we just measured, so the measurement describes an exit the browser
// may not take. Report it, but do not hard-block on a number known to be
// unreliable.
let measurement_unreliable =
vpn_extension_detect::has_confirmed(&vpn_extension_detect::scan_profile(profile));
if matches!(gate, FingerprintGate::Advisory) || measurement_unreliable {
log::warn!(
"Fingerprint gate: {} launching with a {} exit mismatch ({})",
profile.name,
if measurement_unreliable {
"unverifiable"
} else {
"known"
},
result.mismatches.join(", ")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
log::warn!("Failed to emit fingerprint consistency warning: {e}");
}
return Ok(());
}
let token = mint_consent(profile, &key.identity);
Err(mismatch_error(&result, &token))
}
/// Everything the UI needs to decide whether to stop a launch, answered
/// without touching the network or starting any worker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreLaunchChecks {
pub vpn_extensions: Vec<DetectedVpnExtension>,
pub scan_state: String,
/// Cache-only; `checked` is false when the exit has not been measured yet.
pub consistency: ConsistencyResult,
/// True when the enforcing gate will still probe during the launch, so the
/// UI can say the check is not finished rather than implying it passed.
pub exit_probe_pending: bool,
/// A confirmed proxy-permission extension is present, so any exit
/// measurement describes a route the browser may not take.
pub exit_measurement_unreliable: bool,
/// Present only when a cached mismatch is already blocking, so "launch
/// anyway" can proceed without a second round trip.
pub consent_token: Option<String>,
}
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
}
#[tauri::command]
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
let profile = load_profile(&profile_id)?;
let scan = if extension_warning_disabled() {
vpn_extension_detect::ExtensionScan {
extensions: Vec::new(),
scan_state: "scanned".to_string(),
}
} else {
vpn_extension_detect::scan_profile(&profile)
};
// Drop anything the user has already acknowledged for this profile, so the
// dialog only ever opens for something new.
let vpn_extensions: Vec<DetectedVpnExtension> = scan
.extensions
.iter()
.filter(|e| {
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
})
.cloned()
.collect();
let exit_measurement_unreliable = vpn_extension_detect::has_confirmed(&scan);
let disabled = gate_disabled();
let key = fingerprint_consistency::exit_cache_key(&profile);
let consistency = if disabled {
ConsistencyResult::skip()
} else {
fingerprint_consistency::check_profile_consistency_cached(&profile)
};
let already_acked = key
.as_ref()
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
let blocking = consistency.checked && !consistency.consistent && !already_acked;
let consent_token = match (&key, blocking) {
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
_ => None,
};
Ok(PreLaunchChecks {
vpn_extensions,
scan_state: scan.scan_state,
consistency: if blocking {
consistency
} else {
ConsistencyResult::skip()
},
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
exit_measurement_unreliable,
consent_token,
})
}
/// Persist "don't ask me again" choices from the gate dialog.
#[tauri::command]
pub async fn ack_launch_gate(
profile_id: String,
ack_fingerprint: bool,
ack_extension_keys: Vec<String>,
) -> Result<(), String> {
let profile = load_profile(&profile_id)?;
if ack_fingerprint {
if let Some(key) = fingerprint_consistency::exit_cache_key(&profile) {
crate::launch_gate_prefs::ack_fingerprint(&profile, &key.identity);
}
}
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn consent_token_authorizes_exactly_one_launch() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
// Replaying it must fail, so a leaked token cannot re-authorize.
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_for_a_different_profile() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_after_the_fingerprint_changes() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
regenerated.id = profile.id;
assert!(redeem_consent(&token, &regenerated, "http://gw:1").is_err());
}
#[test]
fn consent_token_is_rejected_after_the_exit_changes() {
// The reason a bare `bypass: bool` is not enough: consent granted for one
// proxy must not authorize a launch through another.
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
}
#[test]
fn an_unknown_token_is_rejected() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
}
#[test]
fn expired_tokens_are_swept_and_rejected() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let token = mint_consent(&profile, "http://gw:1");
// Back-date it past the TTL.
{
let mut store = consents();
if let Some(pending) = store.get_mut(&token) {
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
}
}
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
}
#[test]
fn mismatch_error_carries_the_details_the_dialog_renders() {
let result = ConsistencyResult {
consistent: false,
checked: true,
exit_ip: Some("1.2.3.4".into()),
exit_country_code: Some("DE".into()),
exit_timezone: Some("Europe/Berlin".into()),
fingerprint_timezone: Some("America/New_York".into()),
fingerprint_language: Some("en-US".into()),
mismatches: vec!["timezone".into(), "language".into()],
};
let encoded = mismatch_error(&result, "tok");
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
assert_eq!(parsed["params"]["token"], "tok");
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
// params values must be strings for the frontend's interpolation.
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
}
#[tokio::test]
async fn gate_allows_a_direct_connection_without_measuring() {
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
.await
.is_ok()
);
}
#[tokio::test]
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
// No exit identity means nothing to compare against, so the launch must
// proceed rather than block on an unmeasurable profile.
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let upstream = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "127.0.0.1".into(),
port: 1080,
username: None,
password: None,
vless_uri: None,
};
assert!(
enforce_fingerprint_gate(&profile, Some(&upstream), &FingerprintGate::Enforce)
.await
.is_ok()
);
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
//!
//! Deliberately NOT synced. An acknowledgement is a statement about this
//! machine's operator ("I understand this profile's exit disagrees with its
//! fingerprint"), not a property of the profile. Syncing it would let one
//! teammate disarm another's gate, and writing it into profile metadata would
//! bump `updated_at` and make a local dismissal look like a remote edit.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::profile::types::BrowserProfile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FingerprintAck {
/// Hash of the fingerprint that was acknowledged.
pub fingerprint_hash: String,
/// Exit endpoint identity it was acknowledged against.
pub exit_identity: String,
pub acked_at: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaunchGatePrefs {
#[serde(default)]
pub fingerprint_acks: HashMap<String, FingerprintAck>,
/// Profile id -> acknowledged extension keys.
#[serde(default)]
pub vpn_extension_acks: HashMap<String, Vec<String>>,
}
lazy_static::lazy_static! {
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
/// run cannot clobber each other.
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
}
fn prefs_file() -> PathBuf {
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
}
pub fn load() -> LaunchGatePrefs {
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
return LaunchGatePrefs::default();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
LaunchGatePrefs::default()
})
}
fn save(prefs: &LaunchGatePrefs) {
let path = prefs_file();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create launch gate prefs dir: {e}");
return;
}
}
match serde_json::to_string_pretty(prefs) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("Failed to write launch gate prefs: {e}");
}
}
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
}
}
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut prefs = load();
mutate(&mut prefs);
save(&prefs);
}
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
/// applying the moment the fingerprint is regenerated or matched to a new exit.
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
use sha2::{Digest, Sha256};
let fingerprint = profile
.wayfern_config
.as_ref()
.and_then(|c| c.fingerprint.as_deref())
.unwrap_or("");
let mut hasher = Sha256::new();
hasher.update(fingerprint.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
let ack = FingerprintAck {
fingerprint_hash: fingerprint_hash(profile),
exit_identity: exit_identity.to_string(),
acked_at: crate::proxy_manager::now_secs(),
};
let profile_id = profile.id.to_string();
update(|prefs| {
prefs.fingerprint_acks.insert(profile_id, ack);
});
}
/// Whether the user already accepted the mismatch this profile currently has.
///
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
/// per-profile "don't warn again" flag never expired, so one dismissal left a
/// profile unprotected forever, including after its proxy was swapped for one
/// in a different country.
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
let prefs = load();
prefs
.fingerprint_acks
.get(&profile.id.to_string())
.is_some_and(|ack| {
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
})
}
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
if keys.is_empty() {
return;
}
let profile_id = profile_id.to_string();
let keys = keys.to_vec();
update(|prefs| {
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
for key in keys {
if !entry.contains(&key) {
entry.push(key);
}
}
});
}
/// True when every one of these extensions has already been acknowledged for
/// this profile. Installing a *different* VPN extension later re-warns, because
/// its key is not in the acknowledged set.
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
if keys.is_empty() {
return true;
}
let prefs = load();
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
return false;
};
keys.iter().all(|k| acked.contains(k))
}
/// Drop everything remembered for a profile, for use when it is deleted.
pub fn forget_profile(profile_id: &str) {
let profile_id = profile_id.to_string();
update(|prefs| {
prefs.fingerprint_acks.remove(&profile_id);
prefs.vpn_extension_acks.remove(&profile_id);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn fingerprint_hash_changes_with_the_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn a_profile_without_a_fingerprint_still_hashes() {
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
..Default::default()
};
assert!(!fingerprint_hash(&profile).is_empty());
}
#[test]
fn acks_round_trip_and_rearm_on_change() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
ack_fingerprint(&profile, "http://gw:1");
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
// not the mismatch they now have.
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
// Regenerating the fingerprint re-arms it too.
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
let mut same_id = regenerated.clone();
same_id.id = profile.id;
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
}
#[test]
fn extension_acks_are_per_key() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile_id = uuid::Uuid::new_v4().to_string();
let nord = vec!["crx:aaaa".to_string()];
let other = vec!["crx:bbbb".to_string()];
assert!(!extensions_acked(&profile_id, &nord));
ack_extensions(&profile_id, &nord);
assert!(extensions_acked(&profile_id, &nord));
// A different extension installed later must warn again.
assert!(!extensions_acked(&profile_id, &other));
assert!(!extensions_acked(
&profile_id,
&[nord[0].clone(), other[0].clone()]
));
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
// opens the dialog.
assert!(extensions_acked(&profile_id, &[]));
}
#[test]
fn forgetting_a_profile_clears_both_kinds_of_ack() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let profile_id = profile.id.to_string();
ack_fingerprint(&profile, "http://gw:1");
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
forget_profile(&profile_id);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
}
#[test]
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
let dir = tempfile::tempdir().expect("tempdir").keep();
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
std::fs::create_dir_all(dir.join("data")).unwrap();
std::fs::write(
dir.join("data").join("launch_gate_prefs.json"),
"{ not json",
)
.unwrap();
// Must not panic, and must fail closed (nothing acknowledged).
let prefs = load();
assert!(prefs.fingerprint_acks.is_empty());
}
}
+5 -1
View File
@@ -65,6 +65,8 @@ mod geolocation;
mod group_manager;
mod human_typing;
mod ip_utils;
mod launch_gate;
mod launch_gate_prefs;
mod log_redaction;
mod platform_browser;
mod profile;
@@ -95,6 +97,7 @@ mod tag_manager;
mod team_lock;
mod version_updater;
pub mod vpn;
mod vpn_extension_detect;
pub mod vpn_worker_runner;
pub mod vpn_worker_storage;
pub mod xray;
@@ -2673,8 +2676,9 @@ pub fn run_with_builder(
clear_all_traffic_stats,
clear_profile_traffic_stats,
get_traffic_stats_for_period,
fingerprint_consistency::check_profile_fingerprint_consistency,
fingerprint_consistency::match_profile_fingerprint_to_exit,
launch_gate::get_profile_pre_launch_checks,
launch_gate::ack_launch_gate,
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
+2 -6
View File
@@ -2493,9 +2493,7 @@ impl McpServer {
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
None,
headless,
true,
crate::browser_runner::LaunchOptions::automation(None, headless),
)
.await
.map_err(|e| McpError {
@@ -2651,9 +2649,7 @@ impl McpServer {
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
None,
headless,
true,
crate::browser_runner::LaunchOptions::automation(None, headless),
)
.await
{
+1 -1
View File
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
/// step since it regenerates — leaves a populated `Default/` without it. Such a
/// directory would then be treated as stale and removed wholesale, destroying the
/// Extensions and Bookmarks this feature exists to preserve.
fn is_profile_dir_name(name: &str) -> bool {
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
matches!(name, "Default" | "Guest Profile" | "System Profile")
|| name
.strip_prefix("Profile ")
+4
View File
@@ -479,6 +479,10 @@ impl ProfileManager {
);
}
// Launch-gate acknowledgements are keyed by profile id and are not synced,
// so nothing else would ever clean them up.
crate::launch_gate_prefs::forget_profile(profile_id);
// Remember sync mode before deleting local files
let was_sync_enabled = profile.is_sync_enabled();
+15
View File
@@ -1152,6 +1152,21 @@ impl ProxyManager {
url
}
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
/// opposed to `build_proxy_url`, which feeds the browser.
///
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
/// the exit rather than on this machine. Resolving locally would leak the
/// real DNS and, behind a split-horizon resolver, can reach a different host
/// than the browser would.
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
let url = Self::build_proxy_url(proxy_settings);
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
return url.replacen("socks5://", "socks5h://", 1);
}
url
}
// Check if a proxy is valid by routing through a temporary donut-proxy process.
// This tests the exact same code path the browser uses.
// Falls back to direct reqwest check if the proxy worker fails to start.
+12
View File
@@ -54,6 +54,14 @@ pub struct AppSettings {
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
/// Lives here rather than in localStorage because the Rust launch path is
/// what enforces the block and cannot read the frontend's storage.
#[serde(default)]
pub fingerprint_gate_disabled: bool,
/// Stop warning about VPN/proxy extensions found in a profile.
#[serde(default)]
pub vpn_extension_warning_disabled: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
@@ -96,6 +104,8 @@ impl Default for AppSettings {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
vpn_extension_warning_disabled: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
@@ -1190,6 +1200,8 @@ mod tests {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
fingerprint_gate_disabled: false,
vpn_extension_warning_disabled: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
+1 -1
View File
@@ -331,7 +331,7 @@ impl SyncProgressTracker {
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
// Cloud backup is a plan capability. Every paid plan (incl. the future
// "starter" tier) grants it, but gating on the capability — not just "is paid"
// "solo" tier) grants it, but gating on the capability — not just "is paid"
// — keeps this correct if a plan without cloud backup is ever added.
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
return true;
+2 -2
View File
@@ -168,7 +168,7 @@ impl SynchronizerManager {
);
// Launch leader first so it gets focus
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
.await
.map_err(|e| format!("Failed to launch leader: {e}"))?;
@@ -179,7 +179,7 @@ impl SynchronizerManager {
let ah = app_handle.clone();
let fp = fp.clone();
set.spawn(async move {
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
.await
.map_err(|e| (fp.name.clone(), e.to_string()))
});
+10 -4
View File
@@ -313,14 +313,17 @@ fn lock_conflict_error(
}
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
/// launch releases only what it acquired. Releasing unconditionally would drop
/// a lock a REST handler up the stack still owns.
pub async fn acquire_team_lock_if_needed(
profile: &crate::profile::BrowserProfile,
) -> Result<(), String> {
) -> Result<bool, String> {
if !profile.is_sync_enabled() {
return Ok(());
return Ok(false);
}
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Ok(());
return Ok(false);
}
// Ensure lock manager is connected
@@ -340,7 +343,10 @@ pub async fn acquire_team_lock_if_needed(
));
}
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
PROFILE_LOCK
.acquire_lock(&profile.id.to_string())
.await
.map(|()| true)
}
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
@@ -0,0 +1,580 @@
//! Enumerates extensions the user installed from inside the browser, by
//! walking the Chromium profile directory on disk.
//!
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
//! shared profile-directory-name predicate, so the directory-layout handling —
//! the riskiest part of detection — stays testable without app state.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::rules::{
classify, keyword_hit, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, DetectedVpnExtension,
};
/// Upper bound on extension directories walked per profile. A launch must not
/// stall behind a pathological profile; whatever was found is still reported,
/// flagged `partial`.
const MAX_EXTENSION_DIRS: usize = 300;
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
/// Chromium profile directories to search inside a user-data dir.
///
/// Two layouts are real here: Donut launches Wayfern with only
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
/// copied in as the profile directory itself, putting `Extensions/` at the
/// root. Checking only one layout misses every profile of the other kind.
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
dirs.push(user_data_dir.to_path_buf());
}
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if crate::profile::clear_on_close::is_profile_dir_name(name)
|| path.join("Preferences").exists()
{
dirs.push(path);
}
}
}
dirs.sort();
dirs.dedup();
dirs
}
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
return None;
}
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn resolve_dir_i18n(
version_dir: &Path,
manifest: &serde_json::Value,
value: &str,
) -> Option<String> {
let key = message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
let messages = read_json_file(
&version_dir
.join("_locales")
.join(default_locale)
.join("messages.json"),
Some(MAX_MANIFEST_BYTES),
)?;
lookup_message(&messages, &key)
}
/// What the profile's preference files say about installed extensions.
///
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
/// it invalidates the signature, which disables every extension in the profile.
#[derive(Default)]
struct PreferenceExtensions {
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
/// warning about it would be a false alarm.
disabled: HashSet<String>,
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
/// Sideloading is exactly how someone gets a VPN extension in without the
/// Web Store, so missing these would leave the obvious hole open.
unpacked: Vec<(String, PathBuf)>,
}
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
let mut out = PreferenceExtensions::default();
for file in ["Secure Preferences", "Preferences"] {
// Preference files are legitimately large, so they skip the manifest cap.
let Some(prefs) = read_json_file(&profile_dir.join(file), None) else {
continue;
};
let Some(settings) = prefs
.get("extensions")
.and_then(|e| e.get("settings"))
.and_then(|s| s.as_object())
else {
continue;
};
for (id, entry) in settings {
// Chromium's Extension::State: 0 = disabled.
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
out.disabled.insert(id.clone());
continue;
}
// A packed extension's `path` is relative to Extensions/; an unpacked
// one records an absolute path elsewhere on disk.
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
let path = PathBuf::from(path);
if path.is_absolute() {
out.unpacked.push((id.clone(), path));
}
}
}
}
out
}
/// Walk a user-data dir for VPN/proxy extensions.
///
/// Returns false when the walk was cut short by a cap or the deadline, so the
/// caller can report the scan as incomplete rather than clean.
pub(super) fn scan_browser_extensions(
user_data_dir: &Path,
out: &mut Vec<DetectedVpnExtension>,
started: Instant,
) -> bool {
let mut walked = 0usize;
for profile_dir in candidate_profile_dirs(user_data_dir) {
// Read preferences first: unpacked extensions live outside Extensions/, so
// a profile that has only sideloaded ones has no Extensions/ dir at all and
// must not be skipped before they are considered.
let prefs = preference_extensions(&profile_dir);
let disabled = &prefs.disabled;
for (crx_id, unpacked_dir) in &prefs.unpacked {
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
return false;
}
walked += 1;
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
out.push(found);
}
}
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
continue;
};
for entry in entries.flatten() {
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
return false;
}
walked += 1;
let ext_dir = entry.path();
if !ext_dir.is_dir() {
continue;
}
let Some(crx_id) = ext_dir
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
else {
continue;
};
if disabled.contains(&crx_id) {
continue;
}
// Several versions can coexist on disk; Chromium runs the highest.
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
continue;
};
let mut versions: Vec<PathBuf> = version_entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort_by_key(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(version_dir_sort_key)
.unwrap_or_default()
});
let Some(version_dir) = versions.last() else {
continue;
};
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
out.push(found);
}
}
}
true
}
/// Classify the extension whose unpacked files live in `version_dir`.
/// Shared by the packed walk and the unpacked (developer-mode) entries.
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
if message_placeholder_key(&raw_name).is_some() {
crx_id.to_string()
} else {
raw_name.clone()
}
});
let description = manifest_str(&manifest, "description").and_then(|d| {
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
None
} else {
Some(d)
})
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let confidence = classify(&signals, keyword)?;
Some(DetectedVpnExtension {
key: format!("crx:{crx_id}"),
name,
version: manifest_str(&manifest, "version"),
source: "browser".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
#[test]
fn candidate_dirs_accepts_the_default_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Default").join("Preferences"), "{}");
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
}
#[test]
fn candidate_dirs_accepts_the_imported_root_layout() {
// profile_importer copies a Chromium profile dir in as the root, so
// Preferences and Extensions/ sit at the top level.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Preferences"), "{}");
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
}
#[test]
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
}
#[test]
fn scan_finds_a_vpn_extension_in_the_default_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
assert_eq!(out[0].name, "Turbo VPN");
assert_eq!(out[0].confidence, "confirmed");
assert_eq!(out[0].source, "browser");
}
#[test]
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("Preferences"), "{}");
write(
&root
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
}
#[test]
fn scan_reads_the_highest_version_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let ext = root.join("Default").join("Extensions").join(CRX_ID);
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
write(
&ext.join("1.9.0_0").join("manifest.json"),
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
);
write(
&ext.join("1.10.0_0").join("manifest.json"),
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
}
#[test]
fn scan_skips_extensions_chromium_has_disabled() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let profile = root.join("Default");
write(
&profile
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
write(
&profile.join("Secure Preferences"),
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(
out.is_empty(),
"a disabled extension cannot touch the proxy"
);
}
#[test]
fn scan_keeps_enabled_extensions_listed_in_preferences() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let profile = root.join("Default");
write(
&profile
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
write(
&profile.join("Secure Preferences"),
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
}
#[test]
fn scan_resolves_a_localized_extension_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let version_dir = root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0");
write(
&version_dir.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
);
write(
&version_dir
.join("_locales")
.join("en")
.join("messages.json"),
r#"{"appName":{"message":"Nord VPN"}}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out[0].name, "Nord VPN");
}
#[test]
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
}
#[test]
fn scan_ignores_an_ordinary_extension() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("1.0.0_0")
.join("manifest.json"),
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn scan_reports_incomplete_when_the_deadline_has_passed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
VPN_MANIFEST,
);
let mut out = Vec::new();
// A start time already past the deadline stands in for a slow disk.
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
assert!(!scan_browser_extensions(root, &mut out, expired));
}
#[test]
fn scan_survives_a_malformed_manifest() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0")
.join("manifest.json"),
"{ not json",
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn scan_finds_an_unpacked_developer_mode_extension() {
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
// without the Web Store, and those files live outside Extensions/.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let unpacked = root.join("somewhere-else").join("my-vpn");
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
write(
&root.join("Default").join("Preferences"),
&format!(
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1,"path":"{}"}}}}}}}}"#,
unpacked.to_string_lossy()
),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
assert_eq!(out[0].confidence, "confirmed");
}
#[test]
fn scan_ignores_a_disabled_unpacked_extension() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let unpacked = root.join("somewhere-else").join("my-vpn");
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
write(
&root.join("Default").join("Preferences"),
&format!(
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0,"path":"{}"}}}}}}}}"#,
unpacked.to_string_lossy()
),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn packed_relative_paths_are_not_treated_as_unpacked() {
// A packed extension records a path relative to Extensions/; following it
// as if absolute would read the wrong place (or nothing).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root.join("Default").join("Preferences"),
&format!(
r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1,"path":"{CRX_ID}/2.1.0_0"}}}}}}}}"#
),
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert!(out.is_empty());
}
#[test]
fn scan_tolerates_a_missing_user_data_dir() {
let tmp = tempfile::tempdir().unwrap();
let mut out = Vec::new();
assert!(scan_browser_extensions(
&tmp.path().join("nope"),
&mut out,
Instant::now()
));
assert!(out.is_empty());
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Detects VPN/proxy browser extensions present in a profile.
//!
//! An extension holding Chromium's `proxy` permission can override the proxy
//! Donut passes on the command line, so the browser's real exit stops being the
//! one Donut measured and generated the fingerprint against. That produces
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
//! except Donut cannot observe it from the outside — hence a launch-time
//! warning rather than a measurement.
//!
//! Two sources, deliberately both: Donut-managed extensions live in the app's
//! own store and are handed to Chromium via `--load-extension` from *outside*
//! the profile directory, while extensions the user installed from the Web
//! Store live *inside* it. Neither set appears in the other.
mod browser_scan;
mod rules;
// `message_placeholder_key`/`lookup_message` are shared with
// `extension_manager`, which resolves the same placeholders out of a zip.
use rules::{classify, keyword_hit, manifest_str, signal_labels, signals_from_manifest};
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Instant;
use crate::profile::types::BrowserProfile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionScan {
pub extensions: Vec<DetectedVpnExtension>,
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
///
/// Reported honestly so the dialog can say the scan was incomplete rather
/// than implying a clean profile it never managed to read.
pub scan_state: String,
}
/// Donut-managed extensions, reached through the profile's extension group.
///
/// Read live from the stored archive rather than from the metadata cached on
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
/// behind. N is the group size — typically a handful.
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
let Some(group_id) = &profile.extension_group_id else {
return;
};
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
return;
};
let Ok(group) = manager.get_group(group_id) else {
return;
};
for ext_id in &group.extension_ids {
let Ok(ext) = manager.get_extension(ext_id) else {
continue;
};
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
let Ok(data) = std::fs::read(&path) else {
continue;
};
let Some(manifest) =
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
else {
continue;
};
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
let name =
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
.unwrap_or_else(|| {
// An unresolvable placeholder is not a name — fall back to the one
// the extension carries in Donut.
if message_placeholder_key(&raw_name).is_some() {
ext.name.clone()
} else {
raw_name.clone()
}
});
let description = manifest_str(&manifest, "description").and_then(|d| {
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
if message_placeholder_key(&d).is_some() {
None
} else {
Some(d)
},
)
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let Some(confidence) = classify(&signals, keyword) else {
continue;
};
out.push(DetectedVpnExtension {
key: format!("donut:{ext_id}"),
name,
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
source: "donut".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
});
}
}
/// Scan a profile for VPN/proxy extensions from both sources.
///
/// Never fails: an unreadable profile reports whatever it could see plus a
/// `scan_state` explaining why the picture is incomplete.
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
let started = Instant::now();
let mut extensions = Vec::new();
scan_donut_extensions(profile, &mut extensions);
let profiles_dir = crate::app_dirs::profiles_dir();
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
// `get_effective_profile_path` only returns the decrypted RAM copy while the
// profile is unlocked; locked, it falls back to the on-disk directory, which
// exists but is ciphertext. Walking that finds nothing — so the state has to
// be decided on whether we actually got a readable copy, not on the path
// existing, or a locked profile reports as verified-clean.
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
let scan_state = if !has_plaintext_dir {
if profile.password_protected {
"encrypted"
} else {
"ephemeral"
}
} else if !user_data_dir.is_dir() {
// Never launched, so there is no profile directory to inspect yet.
"missing"
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
"scanned"
} else {
"partial"
};
// The two sources are disjoint by construction, but an imported profile can
// carry its own copy of an extension Donut also manages. Collapse only on an
// exact name+version match, and never on an unresolved `__MSG_` placeholder —
// those are not identities and would fold unrelated extensions into one row.
let mut seen = HashSet::new();
extensions.retain(|e| {
message_placeholder_key(&e.name).is_some() || seen.insert((e.name.clone(), e.version.clone()))
});
ExtensionScan {
extensions,
scan_state: scan_state.to_string(),
}
}
/// True when at least one detection is `confirmed` — the extension holds the
/// `proxy` permission and can actually redirect the browser's traffic.
pub fn has_confirmed(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.confidence == "confirmed")
}
+335
View File
@@ -0,0 +1,335 @@
//! Pure classification rules for VPN/proxy extension detection.
//!
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
//! only): these rules are the heart of the feature and the part most worth
//! testing in isolation, so nothing here may reach for app state, the
//! filesystem, or the network. Enumerating the two extension sources and
//! reading them off disk lives in the parent module.
use serde::{Deserialize, Serialize};
/// Substrings that corroborate a request-blocking extension being a VPN.
/// Matched case-insensitively against name + description.
const KEYWORDS: &[&str] = &[
"vpn",
"proxy",
"tunnel",
"unblock",
"wireguard",
"shadowsocks",
"socks",
];
/// Matched as a whole token rather than a substring — too short to be safe
/// inside other words ("warped", "warpaint").
const TOKEN_KEYWORDS: &[&str] = &["warp"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DetectedVpnExtension {
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
pub key: String,
pub name: String,
pub version: Option<String>,
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
pub source: String,
/// `"confirmed"` or `"likely"`.
pub confidence: String,
/// Why it matched, for the dialog's detail line.
pub signals: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ManifestSignals {
pub proxy_permission: bool,
pub optional_proxy_permission: bool,
pub declarative_net_request: bool,
pub web_request_blocking: bool,
pub broad_host_permissions: bool,
}
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
manifest
.get(key)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default()
}
fn is_broad_host(pattern: &str) -> bool {
matches!(pattern, "<all_urls>" | "*://*/*")
}
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
let permissions = string_list(manifest, "permissions");
let optional_permissions = string_list(manifest, "optional_permissions");
let host_permissions = string_list(manifest, "host_permissions");
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
let has = |list: &[&str], name: &str| list.contains(&name);
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
// `host_permissions`. Look in both so one manifest version isn't silently
// under-detected.
let all_hosts: Vec<&str> = permissions
.iter()
.chain(host_permissions.iter())
.chain(optional_host_permissions.iter())
.copied()
.collect();
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
ManifestSignals {
proxy_permission: has(&permissions, "proxy"),
optional_proxy_permission: has(&optional_permissions, "proxy"),
declarative_net_request: has(&permissions, "declarativeNetRequest")
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
web_request_blocking: has(&permissions, "webRequest")
&& has(&permissions, "webRequestBlocking"),
broad_host_permissions: broad,
}
}
pub fn keyword_hit(name: &str, description: Option<&str>) -> bool {
let mut haystack = name.to_lowercase();
if let Some(d) = description {
haystack.push(' ');
haystack.push_str(&d.to_lowercase());
}
if KEYWORDS.iter().any(|k| haystack.contains(k)) {
return true;
}
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| TOKEN_KEYWORDS.contains(&token))
}
/// Classify an extension from its manifest signals.
///
/// The `proxy` permission is the only signal that *proves* the capability: it
/// is what Chromium requires to call `chrome.proxy`, and it stays in
/// `permissions` under both manifest versions because it is an API permission,
/// not a host pattern.
///
/// The request-blocking tier additionally requires a keyword, and that
/// corroboration is not optional: `declarativeNetRequest` plus `<all_urls>`
/// describes every content blocker in the ecosystem, so without it the warning
/// fires on uBlock Origin — which would teach users to dismiss the dialog on
/// sight, destroying the value of the mismatch block that shares it.
pub fn classify(signals: &ManifestSignals, keyword: bool) -> Option<&'static str> {
if signals.proxy_permission {
return Some("confirmed");
}
if signals.optional_proxy_permission {
return Some("likely");
}
if (signals.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions
&& keyword
{
return Some("likely");
}
None
}
pub fn signal_labels(signals: &ManifestSignals, keyword: bool) -> Vec<String> {
let mut out = Vec::new();
if signals.proxy_permission {
out.push("permissions:proxy".to_string());
}
if signals.optional_proxy_permission {
out.push("optionalPermissions:proxy".to_string());
}
if signals.declarative_net_request {
out.push("declarativeNetRequest".to_string());
}
if signals.web_request_blocking {
out.push("webRequestBlocking".to_string());
}
if signals.broad_host_permissions {
out.push("broadHostPermissions".to_string());
}
if keyword {
out.push("keyword".to_string());
}
out
}
/// `__MSG_someKey__` -> `someKey`.
pub fn message_placeholder_key(value: &str) -> Option<String> {
value
.strip_prefix("__MSG_")
.and_then(|rest| rest.strip_suffix("__"))
.map(str::to_string)
}
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
/// keys compared case-insensitively.
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
let obj = messages.as_object()?;
obj
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.get("message"))
.and_then(|v| v.as_str())
.map(str::to_string)
}
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
manifest
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Sort key for an extension version directory (`1.10.0_0`), compared
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
/// would put it below.
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
name
.split(['.', '_'])
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
signals_from_manifest(&manifest)
}
#[test]
fn classify_confirms_on_proxy_permission() {
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
assert!(s.proxy_permission);
assert_eq!(classify(&s, false), Some("confirmed"));
}
#[test]
fn classify_confirms_proxy_permission_in_mv2() {
// `proxy` is an API permission, so MV3's host_permissions split does not
// move it — the same key works for both manifest versions.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["proxy", "<all_urls>", "webRequest"]
}));
assert_eq!(classify(&s, false), Some("confirmed"));
}
#[test]
fn classify_likely_on_optional_proxy() {
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(classify(&s, false), Some("likely"));
}
#[test]
fn classify_ignores_content_blocker() {
// The regression guard: a content blocker declares exactly these and is
// not a VPN. Firing here would train users to dismiss the dialog.
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert!(s.declarative_net_request && s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("uBlock Origin", None)), None);
}
#[test]
fn classify_likely_on_dnr_plus_keyword() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["<all_urls>"]
}));
assert_eq!(
classify(&s, keyword_hit("Free VPN Proxy", None)),
Some("likely")
);
}
#[test]
fn classify_ignores_keyword_only() {
// A name alone proves nothing; without a capability signal this is noise.
let s = signals_of(json!({ "permissions": ["storage"] }));
assert_eq!(classify(&s, keyword_hit("VPN Deals Finder", None)), None);
}
#[test]
fn classify_requires_broad_hosts_for_the_blocking_tier() {
let s = signals_of(json!({
"permissions": ["declarativeNetRequest"],
"host_permissions": ["https://example.com/*"]
}));
assert_eq!(classify(&s, keyword_hit("Some VPN", None)), None);
}
#[test]
fn broad_hosts_detected_from_split_http_and_https() {
let s = signals_of(json!({
"permissions": ["webRequest", "webRequestBlocking"],
"host_permissions": ["http://*/*", "https://*/*"]
}));
assert!(s.broad_host_permissions);
assert!(s.web_request_blocking);
}
#[test]
fn broad_hosts_detected_from_mv2_permissions_array() {
// MV2 puts host patterns in `permissions`; the split-out key is absent.
let s = signals_of(json!({
"manifest_version": 2,
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("Turbo VPN", None)), Some("likely"));
}
#[test]
fn keyword_matching_is_substring_but_token_bound_for_short_terms() {
assert!(keyword_hit("TouchVPN", None));
assert!(keyword_hit("Unblock Sites", None));
assert!(keyword_hit("Cloudflare WARP", None));
// "warp" only matches as a whole token, so this must not hit.
assert!(!keyword_hit("Time Warped Clock", None));
assert!(keyword_hit(
"Anything",
Some("a fast tunnel for your browser")
));
}
#[test]
fn message_placeholder_round_trip() {
assert_eq!(
message_placeholder_key("__MSG_appName__").as_deref(),
Some("appName")
);
assert_eq!(message_placeholder_key("Plain Name"), None);
let messages = json!({ "appName": { "message": "Nord VPN" } });
assert_eq!(
lookup_message(&messages, "appName").as_deref(),
Some("Nord VPN")
);
// Chromium compares message keys case-insensitively.
assert_eq!(
lookup_message(&messages, "APPNAME").as_deref(),
Some("Nord VPN")
);
}
#[test]
fn version_dirs_sort_numerically_not_lexicographically() {
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
dirs.sort_by_key(|d| version_dir_sort_key(d));
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
}
#[test]
fn malformed_manifest_yields_no_signals() {
// Arrays of non-strings, wrong types, and missing keys must not panic.
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
assert_eq!(s, ManifestSignals::default());
assert_eq!(classify(&s, true), None);
}
}
+59 -3
View File
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
}
}
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
/// both observe "no worker" and both believe they created it. Benign until a
/// launch guard may stop one on failure; then double-ownership means a
/// cancelled launch tears down a tunnel another profile is using. Mirrors
/// `xray_worker_runner::XRAY_START_LOCK`.
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A started VPN worker plus whether *this* call spawned it.
pub struct VpnWorkerStart {
pub config: VpnWorkerConfig,
/// False when an already-running worker was adopted. Only the creator may
/// stop it while unwinding a failed launch.
pub created: bool,
}
/// Whether any profile with a live browser process is routing through this VPN.
///
/// Extracted from the startup sweep so the launch guard and the sweep agree on
/// what "in use" means instead of each carrying its own copy.
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
// Unable to tell — assume in use rather than tear down a live tunnel.
return true;
};
profiles
.iter()
.filter(|p| p.process_id.is_some_and(is_process_running))
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
}
/// Hold the start lock across an adopt-sensitive section (a launch guard
/// deciding whether to stop a worker it created).
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
VPN_START_LOCK.lock().await
}
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
}
pub async fn start_vpn_worker_tracked(
vpn_id: &str,
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
let _start_guard = VPN_START_LOCK.lock().await;
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() {
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
if let Some(pid) = existing.pid {
if is_process_running(pid) {
if vpn_worker_accepting_connections(&existing).await {
return Ok(existing);
return Ok(VpnWorkerStart {
config: existing,
created: false,
});
}
return wait_for_vpn_worker_ready(&existing.id).await;
return wait_for_vpn_worker_ready(&existing.id)
.await
.map(|config| VpnWorkerStart {
config,
created: false,
});
}
}
// Worker config exists but process is dead, clean up
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
drop(child);
}
wait_for_vpn_worker_ready(&id).await
wait_for_vpn_worker_ready(&id)
.await
.map(|config| VpnWorkerStart {
config,
created: true,
})
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
+321 -43
View File
@@ -13,11 +13,6 @@ import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import {
type ConsistencyResult,
ConsistencyWarningDialog,
isConsistencyWarningSuppressed,
} from "@/components/consistency-warning-dialog";
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -33,6 +28,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import {
type GateDecision,
type GateFindings,
PreLaunchGateDialog,
} from "@/components/pre-launch-gate-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
@@ -67,7 +67,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
@@ -88,7 +88,42 @@ import {
showSyncProgressToast,
showToast,
} from "@/lib/toast-utils";
import type { BrowserProfile, SyncSettings, WayfernConfig } from "@/types";
import type {
BrowserProfile,
ConsistencyResult,
PreLaunchChecks,
SyncSettings,
WayfernConfig,
} from "@/types";
type GateRequest = {
profile: BrowserProfile;
findings: GateFindings;
};
type LaunchResult = {
status: "launched" | "cancelled" | "blocked";
};
/**
* Rebuild the mismatch detail the gate dialog renders from a
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
* backend error params always are.
*/
function consistencyFromErrorParams(
params?: Record<string, string>,
): ConsistencyResult {
return {
consistent: false,
checked: true,
exit_ip: params?.exitIp || null,
exit_country_code: params?.exitCountry || null,
exit_timezone: params?.exitTimezone || null,
fingerprint_timezone: params?.fingerprintTimezone || null,
fingerprint_language: params?.fingerprintLanguage || null,
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
};
}
type BrowserTypeString = "wayfern";
@@ -252,7 +287,7 @@ export default function Home() {
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
// Bulk run/stop is a paid (browser automation) feature, matching the
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
// actions disabled with a Pro badge.
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
// The rail needs to show a live run from every page, so the shell subscribes
@@ -365,10 +400,30 @@ export default function Home() {
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const [consistencyWarning, setConsistencyWarning] = useState<{
profile: BrowserProfile;
result: ConsistencyResult;
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
// a bulk run enqueues one per profile, and every waiter must settle or the
// Promise.allSettled below it never resolves and the bulk spinner sticks.
const gateQueueRef = useRef<
Array<{ req: GateRequest; resolve: (decision: GateDecision) => void }>
>([]);
const [gateState, setGateState] = useState<{
req: GateRequest;
remaining: number;
} | null>(null);
// Set when the user ticks "apply to the remaining profiles" during a bulk
// run, so the rest are answered without prompting again. Scoped to one bulk
// run and to the severity it was given for.
const blanketGateDecisionRef = useRef<{
decision: GateDecision;
/// Only auto-answers gates no more severe than the one the user saw. A
/// choice made on an extension warning must never silently bypass a hard
/// block on a later profile.
coversBlocking: boolean;
/// Identifies the bulk run, so a single launch started while a bulk run is
/// in flight still gets its own dialog.
runId: number;
} | null>(null);
const bulkRunIdRef = useRef(0);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
@@ -933,8 +988,137 @@ export default function Home() {
[selectedGroupId, t],
);
// Show the queue's head, and how many are waiting behind it.
// The backend gate downgrades to advisory rather than blocking when it
// cannot trust its own measurement (a confirmed VPN extension can reroute
// traffic away from the proxy it just probed), and for unattended launches.
// Without a listener that finding was emitted into the void.
useEffect(() => {
const unlisten = listen<ConsistencyResult>(
"fingerprint-consistency-warning",
(event) => {
const { exit_timezone, fingerprint_timezone } = event.payload;
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
// The cause differs by path (an unverifiable measurement vs an
// unattended launch), so state the measurement rather than guess.
description:
exit_timezone && fingerprint_timezone
? t("consistencyWarning.timezoneDetail", {
exit: exit_timezone,
fingerprint: fingerprint_timezone,
})
: undefined,
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
});
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, [t]);
const syncGateUi = useCallback(() => {
const queue = gateQueueRef.current;
setGateState(
queue.length > 0
? { req: queue[0].req, remaining: queue.length - 1 }
: null,
);
}, []);
const requestGateDecision = useCallback(
(req: GateRequest, runId?: number): Promise<GateDecision> => {
const blanket = blanketGateDecisionRef.current;
const isBlocking = req.findings.fingerprint !== null;
if (
blanket &&
blanket.runId === runId &&
(blanket.coversBlocking || !isBlocking)
) {
// A blanket answer covers only whether to launch. The acknowledgements
// it carried were about the first profile's specific mismatch and
// extensions, and must not be persisted against profiles the user
// never saw.
return Promise.resolve({
...blanket.decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
return new Promise<GateDecision>((resolve) => {
gateQueueRef.current.push({ req, resolve });
syncGateUi();
});
},
[syncGateUi],
);
const settleGate = useCallback(
(decision: GateDecision) => {
const entry = gateQueueRef.current.shift();
entry?.resolve(decision);
if (decision.applyToRemaining) {
const coversBlocking = entry?.req.findings.fingerprint !== null;
blanketGateDecisionRef.current = {
decision,
coversBlocking,
runId: bulkRunIdRef.current,
};
// Drain the queue rather than leaving promises pending forever — but
// only those the blanket actually covers. A hard block still deserves
// its own dialog even after the user blanket-approved a warning.
const remaining = gateQueueRef.current.splice(0);
const kept = remaining.filter(
(queued) =>
!coversBlocking && queued.req.findings.fingerprint !== null,
);
for (const queued of remaining) {
if (kept.includes(queued)) {
continue;
}
queued.resolve({
...decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
gateQueueRef.current = kept;
}
syncGateUi();
},
[syncGateUi],
);
const persistGateAcks = useCallback(
async (profileId: string, decision: GateDecision) => {
// Only on proceed. Cancel is the autofocused default action, so a stray
// Enter would otherwise permanently disarm the gate for this profile.
if (!decision.proceed) {
return;
}
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
return;
}
try {
await invoke("ack_launch_gate", {
profileId,
ackFingerprint: decision.ackFingerprint,
ackExtensionKeys: decision.ackExtensionKeys,
});
} catch (err) {
console.warn("Failed to persist launch gate acknowledgement:", err);
}
},
[],
);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
async (
profile: BrowserProfile,
opts?: { bulkRunId?: number },
): Promise<LaunchResult> => {
console.log("Starting launch for profile:", profile.name);
// Password-protected: must be unlocked before launch
@@ -947,7 +1131,7 @@ export default function Home() {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return;
return { status: "cancelled" };
}
} catch (err) {
console.error("Failed to check profile lock state:", err);
@@ -966,7 +1150,7 @@ export default function Home() {
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
return { status: "cancelled" };
}
}
} catch (error) {
@@ -974,30 +1158,106 @@ export default function Home() {
}
}
// Tier 1: purely local checks — an extension scan and a cached exit
// verdict. No network, no worker started, so a profile whose exit is
// already known blocks before the launch touches anything.
let consentToken: string | null = null;
try {
// One-shot migration of the old per-profile "don't warn again" flag,
// so a user who already dismissed this profile isn't hard-blocked by
// the new gate. Granted against the profile's current exit, which is
// the mismatch they were looking at when they dismissed it.
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
if (localStorage.getItem(legacySkipKey) === "1") {
await invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [],
}).catch((err: unknown) => {
console.warn("Failed to migrate consistency skip flag:", err);
});
localStorage.removeItem(legacySkipKey);
}
const checks = await invoke<PreLaunchChecks>(
"get_profile_pre_launch_checks",
{ profileId: profile.id },
);
const blocked =
checks.consistency.checked && !checks.consistency.consistent;
if (blocked || checks.vpn_extensions.length > 0) {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: checks.vpn_extensions,
scanState: checks.scan_state,
fingerprint: blocked ? checks.consistency : null,
measurementUnreliable: checks.exit_measurement_unreliable,
probePending: checks.exit_probe_pending,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
consentToken = checks.consent_token;
}
} catch (err) {
// Same posture as the password and window-resize gates: a check that
// cannot run must not make profiles unlaunchable.
console.warn("Pre-launch checks failed, launching anyway:", err);
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken,
});
console.log("Successfully launched profile:", result.name);
// Non-blocking: after a successful launch, check that the proxy exit
// node's timezone/country agrees with the fingerprint. A mismatch is a
// strong anti-bot tell even though the real device never leaks.
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
void invoke<ConsistencyResult>(
"check_profile_fingerprint_consistency",
{ profileId: profile.id },
)
.then((res) => {
if (res.checked && !res.consistent) {
setConsistencyWarning({ profile, result: res });
}
})
.catch((e) => {
console.warn("Consistency check failed:", e);
});
}
return { status: "launched" };
} catch (err: unknown) {
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
// before spawning the browser. Offer the same decision, then retry
// exactly once with the token it minted — bounded, so a gate loop is
// structurally impossible.
const parsed = parseBackendError(err);
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: [],
scanState: "scanned",
fingerprint: consistencyFromErrorParams(parsed.params),
measurementUnreliable: false,
probePending: false,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
try {
await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken: parsed.params?.token ?? null,
});
return { status: "launched" };
} catch (retryErr: unknown) {
showErrorToast(
t("errors.launchBrowserFailed", {
error: translateBackendError(t, retryErr),
}),
);
return { status: "blocked" };
}
}
console.error("Failed to launch browser:", err);
const errorMessage = translateBackendError(t, err);
showErrorToast(
@@ -1006,7 +1266,7 @@ export default function Home() {
throw err;
}
},
[t],
[persistGateAcks, requestGateDecision, t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
@@ -1203,15 +1463,34 @@ export default function Home() {
const executeBulkRun = useCallback(
async (targets: BrowserProfile[]) => {
setIsBulkActing(true);
blanketGateDecisionRef.current = null;
bulkRunIdRef.current += 1;
const runId = bulkRunIdRef.current;
try {
await Promise.allSettled(targets.map((p) => launchProfile(p)));
const results = await Promise.allSettled(
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
);
const stopped = results.filter(
(r) => r.status === "fulfilled" && r.value.status !== "launched",
).length;
if (stopped > 0) {
// Previously a declined launch resolved to undefined, so allSettled
// reported success and the user was told nothing.
showErrorToast(
t("prelaunchGate.cancelledSummary", {
cancelled: stopped,
total: targets.length,
}),
);
}
setSelectedProfiles([]);
} finally {
blanketGateDecisionRef.current = null;
setIsBulkActing(false);
setPendingBulkAction(null);
}
},
[launchProfile],
[launchProfile, t],
);
const executeBulkStop = useCallback(
@@ -1898,14 +2177,13 @@ export default function Home() {
}}
/>
<ConsistencyWarningDialog
isOpen={consistencyWarning !== null}
onClose={() => {
setConsistencyWarning(null);
}}
profileName={consistencyWarning?.profile.name ?? ""}
profileId={consistencyWarning?.profile.id ?? ""}
result={consistencyWarning?.result ?? null}
<PreLaunchGateDialog
isOpen={gateState !== null}
profileName={gateState?.req.profile.name ?? ""}
profileId={gateState?.req.profile.id ?? ""}
findings={gateState?.req.findings ?? null}
remainingCount={gateState?.remaining ?? 0}
onResult={settleGate}
/>
{pendingUrls.map((pendingUrl) => (
@@ -1,176 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
export interface ConsistencyResult {
consistent: boolean;
checked: boolean;
exit_ip: string | null;
exit_country_code: string | null;
exit_timezone: string | null;
fingerprint_timezone: string | null;
fingerprint_language: string | null;
mismatches: string[];
}
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
export function isConsistencyWarningSuppressed(profileId: string): boolean {
try {
return (
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
localStorage.getItem(perProfileKey(profileId)) === "1"
);
} catch {
return false;
}
}
interface ConsistencyWarningDialogProps {
isOpen: boolean;
onClose: () => void;
profileName: string;
profileId: string;
result: ConsistencyResult | null;
}
export function ConsistencyWarningDialog({
isOpen,
onClose,
profileName,
profileId,
result,
}: ConsistencyWarningDialogProps) {
const { t } = useTranslation();
const [dontWarnAgain, setDontWarnAgain] = useState(false);
const [isMatching, setIsMatching] = useState(false);
const handleClose = () => {
if (dontWarnAgain) {
try {
localStorage.setItem(perProfileKey(profileId), "1");
} catch {
// localStorage unavailable — nothing to persist
}
}
setDontWarnAgain(false);
onClose();
};
const mismatches = result?.mismatches ?? [];
const exitIp = result?.exit_ip ?? null;
const handleMatch = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
handleClose();
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("consistencyWarning.intro", { name: profileName })}
</p>
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
{mismatches.includes("timezone") && (
<div>
<p className="font-medium">
{t("consistencyWarning.timezoneTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: result?.exit_timezone ?? "?",
fingerprint: result?.fingerprint_timezone ?? "?",
})}
</p>
</div>
)}
{mismatches.includes("language") && (
<div>
<p className="font-medium">
{t("consistencyWarning.languageTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: result?.exit_country_code ?? "?",
fingerprint: result?.fingerprint_language ?? "?",
})}
</p>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
<label
htmlFor="consistency-dont-warn"
className="flex cursor-pointer items-center gap-2 text-xs"
>
<Checkbox
id="consistency-dont-warn"
checked={dontWarnAgain}
onCheckedChange={(v) => setDontWarnAgain(v === true)}
/>
{t("consistencyWarning.dontWarnAgain")}
</label>
</div>
<div className="flex justify-end gap-2">
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isMatching}
>
{t("common.buttons.close")}
</RippleButton>
{exitIp && (
<RippleButton onClick={handleMatch} disabled={isMatching}>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
</div>
</DialogContent>
</Dialog>
);
}
+312
View File
@@ -0,0 +1,312 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ConsistencyResult, DetectedVpnExtension } from "@/types";
import { RippleButton } from "./ui/ripple";
export interface GateFindings {
/// Extensions that can reroute traffic. A warning: the user may proceed.
vpnExtensions: DetectedVpnExtension[];
scanState: string;
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
fingerprint: ConsistencyResult | null;
/// A confirmed proxy-permission extension makes any exit measurement suspect.
measurementUnreliable: boolean;
/// The exit has not been measured yet; the launch itself will still check.
probePending: boolean;
}
export interface GateDecision {
proceed: boolean;
ackFingerprint: boolean;
ackExtensionKeys: string[];
applyToRemaining: boolean;
}
interface PreLaunchGateDialogProps {
isOpen: boolean;
profileName: string;
profileId: string;
findings: GateFindings | null;
/// How many further profiles are queued behind this one; >0 offers to apply
/// the same decision to all of them.
remainingCount: number;
/// The single exit route. Every path out of this dialog calls it exactly
/// once, so a caller awaiting a decision can never be left hanging.
onResult: (decision: GateDecision) => void;
}
export function PreLaunchGateDialog({
isOpen,
profileName,
profileId,
findings,
remainingCount,
onResult,
}: PreLaunchGateDialogProps) {
const { t } = useTranslation();
const [ackFingerprint, setAckFingerprint] = useState(false);
const [ackExtensions, setAckExtensions] = useState(false);
const [applyToRemaining, setApplyToRemaining] = useState(false);
const [isMatching, setIsMatching] = useState(false);
// The dialog node is reused as the queue advances, so without this a double
// click would decide for the next profile too.
const [decided, setDecided] = useState(false);
// Keyed on profileId, not just isOpen: a queued gate promotes the next
// profile without ever closing the dialog, so an isOpen-only reset would
// carry the previous profile's ticked boxes — and persist an acknowledgement
// against a profile the user never saw.
useEffect(() => {
setAckFingerprint(false);
setAckExtensions(false);
setIsMatching(false);
setDecided(false);
}, []);
useEffect(() => {
if (isOpen) {
setApplyToRemaining(false);
}
}, [isOpen]);
const fingerprint = findings?.fingerprint ?? null;
const extensions = findings?.vpnExtensions ?? [];
const mismatches = fingerprint?.mismatches ?? [];
const exitIp = fingerprint?.exit_ip ?? null;
const isBlocked = fingerprint !== null;
const decide = (proceed: boolean) => {
if (decided) {
return;
}
setDecided(true);
onResult({
proceed,
ackFingerprint: ackFingerprint && isBlocked,
ackExtensionKeys: ackExtensions ? extensions.map((e) => e.key) : [],
applyToRemaining,
});
};
const handleMatchFingerprint = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
// The fingerprint the block was measured against no longer exists, so
// this launch is abandoned rather than forced through with a stale
// consent token; the user relaunches against the corrected profile.
decide(false);
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
}
};
const scanNotice = (() => {
switch (findings?.scanState) {
case "encrypted":
return t("prelaunchGate.scanIncompleteEncrypted");
case "ephemeral":
return t("prelaunchGate.scanIncompleteEphemeral");
case "partial":
return t("prelaunchGate.scanIncompletePartial");
case "missing":
return t("prelaunchGate.scanIncompleteMissing");
default:
return null;
}
})();
return (
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-md" dismissible={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{isBlocked
? t("prelaunchGate.titleBlocked")
: t("prelaunchGate.titleWarning")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("prelaunchGate.intro", { name: profileName })}
</p>
{isBlocked && (
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="font-medium">
{t("prelaunchGate.fingerprintHeading")}
</p>
{mismatches.includes("timezone") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: fingerprint?.exit_timezone ?? "?",
fingerprint: fingerprint?.fingerprint_timezone ?? "?",
})}
</p>
)}
{mismatches.includes("language") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: fingerprint?.exit_country_code ?? "?",
fingerprint: fingerprint?.fingerprint_language ?? "?",
})}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
</div>
)}
{extensions.length > 0 && (
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="font-medium">
{t("prelaunchGate.vpnExtensionHeading")}
</p>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.vpnExtensionIntro")}
</p>
<ul className="space-y-1">
{extensions.map((ext) => (
<li key={ext.key} className="text-xs">
<span className="font-medium">{ext.name}</span>
<span className="text-muted-foreground">
{t("prelaunchGate.vpnExtensionEntry", {
version: ext.version ?? "",
capability:
ext.confidence === "confirmed"
? t("prelaunchGate.vpnExtensionConfirmed")
: t("prelaunchGate.vpnExtensionLikely"),
source:
ext.source === "donut"
? t("prelaunchGate.sourceDonut")
: t("prelaunchGate.sourceBrowser"),
})}
</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.vpnExtensionExplainer")}
</p>
</div>
)}
{findings?.measurementUnreliable && isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.measurementUnreliable")}
</p>
)}
{findings?.probePending && !isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.probePending")}
</p>
)}
{scanNotice && (
<p className="text-xs text-muted-foreground">{scanNotice}</p>
)}
<div className="space-y-2">
{isBlocked && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-fingerprint"
checked={ackFingerprint}
onCheckedChange={(v) => setAckFingerprint(v === true)}
/>
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
{t("prelaunchGate.dontBlockAgain")}
</Label>
</div>
)}
{extensions.length > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-extensions"
checked={ackExtensions}
onCheckedChange={(v) => setAckExtensions(v === true)}
/>
<Label htmlFor="gate-ack-extensions" className="text-xs">
{t("prelaunchGate.dontWarnExtensions")}
</Label>
</div>
)}
{remainingCount > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-apply-remaining"
checked={applyToRemaining}
onCheckedChange={(v) => setApplyToRemaining(v === true)}
/>
<Label htmlFor="gate-apply-remaining" className="text-xs">
{t("prelaunchGate.applyToRemaining")}
</Label>
</div>
)}
</div>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
{/* Cancel is the default action: the browser has not started, and
not starting it is the safe outcome. */}
<RippleButton
variant="outline"
onClick={() => decide(false)}
disabled={isMatching || decided}
autoFocus
>
{t("common.buttons.cancel")}
</RippleButton>
<div className="flex gap-2">
{isBlocked && exitIp && (
<RippleButton
variant="outline"
onClick={() => void handleMatchFingerprint()}
disabled={isMatching || decided}
>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
<RippleButton
variant={isBlocked ? "destructive" : "default"}
onClick={() => decide(true)}
disabled={isMatching || decided}
>
{t("prelaunchGate.launchAnyway")}
</RippleButton>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -242,7 +242,7 @@ interface TableMeta {
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
@@ -1394,7 +1394,7 @@ BotCell.displayName = "BotCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
+8 -1
View File
@@ -28,7 +28,9 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
await invoke("open_url_with_profile", {
profileId: selected.id,
url,
consentToken: null,
});
onClose();
} catch (error) {
console.error("Failed to open URL with profile:", error);
// This path reaches the browser without going through page.tsx's gate,
// so a launch the gate blocks surfaces here. Without a toast the deep
// link would simply appear to do nothing.
showErrorToast(translateBackendError(t, error));
} finally {
setIsLaunching(false);
if (selected) {
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
});
}
}
}, [selectedProfile, url, onClose, profiles]);
}, [selectedProfile, url, onClose, profiles, t]);
const handleCancel = useCallback(() => {
setSelectedProfile(null);
+54 -27
View File
@@ -73,6 +73,8 @@ interface AppSettings {
api_token?: string;
disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
fingerprint_gate_disabled?: boolean;
vpn_extension_warning_disabled?: boolean;
}
interface CustomThemeState {
@@ -127,15 +129,6 @@ export function SettingsDialog({
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
const [consistencyWarningEnabled, setConsistencyWarningEnabled] = useState(
() => {
try {
return localStorage.getItem("consistency-warn-disabled") !== "1";
} catch {
return true;
}
},
);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [requestingPermission, setRequestingPermission] =
@@ -267,9 +260,28 @@ export function SettingsDialog({
? normalizeThemeColors(appSettings.custom_theme)
: tokyoNightTheme.colors,
};
setSettings(merged);
setOriginalSettings(merged);
originalSettingsRef.current = merged;
// One-shot migration off the old localStorage flag. Without it, a user
// who explicitly turned the warning off would start getting hard blocks
// after updating — the single most likely support complaint here.
let migrated = merged;
try {
if (
localStorage.getItem("consistency-warn-disabled") === "1" &&
!merged.fingerprint_gate_disabled
) {
migrated = { ...merged, fingerprint_gate_disabled: true };
await invoke<AppSettings>("save_app_settings", {
settings: migrated,
});
}
localStorage.removeItem("consistency-warn-disabled");
} catch (err) {
console.warn("Failed to migrate consistency warning preference:", err);
}
setSettings(migrated);
setOriginalSettings(migrated);
originalSettingsRef.current = migrated;
hasLoadedSettingsRef.current = true;
setHasLoadedSettings(true);
@@ -687,7 +699,11 @@ export function SettingsDialog({
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
settings.disable_auto_updates !== originalSettings.disable_auto_updates ||
settings.fingerprint_gate_disabled !==
originalSettings.fingerprint_gate_disabled ||
settings.vpn_extension_warning_disabled !==
originalSettings.vpn_extension_warning_disabled;
return (
<>
@@ -1392,21 +1408,32 @@ export function SettingsDialog({
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={consistencyWarningEnabled}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
setConsistencyWarningEnabled(v === true);
try {
if (v === true) {
localStorage.removeItem("consistency-warn-disabled");
} else {
localStorage.setItem(
"consistency-warn-disabled",
"1",
);
}
} catch {
// localStorage unavailable
}
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
"privacy": {
"consistencyWarning": "Fingerprint consistency warning",
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
"consistencyWarning": "Block on fingerprint mismatch",
"consistencyWarningDescription": "Stop the browser from starting when a profile's timezone or language doesn't match its proxy exit node. You can still choose to launch.",
"clearTraffic": "Clear all traffic history",
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
"clearTrafficSuccess": "Traffic history cleared"
"clearTrafficSuccess": "Traffic history cleared",
"vpnExtensionWarning": "VPN extension warning",
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Too many requests. Wait a moment and try again.",
"remoteNoCapacity": "No remote host is free right now. Try again in a few minutes.",
"remoteNotEntitled": "Your plan does not include remote execution.",
"remoteInteractiveNotEntitled": "Your plan includes remote hours for the Cookie Bot only, not for hands-on remote sessions.",
"remoteSessionRefused": "The remote host refused this session.",
"remoteSessionNotFound": "That remote session no longer exists.",
"remoteSessionConflict": "This profile is already open somewhere else.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.",
"profileLockedByMember": "This profile is in use by {{email}}.",
"profileLockedElsewhere": "This profile is in use on another device.",
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again."
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again.",
"fingerprintExitMismatch": "The proxy exit node doesn't match this profile's fingerprint.",
"launchConsentExpired": "That confirmation is no longer valid. Try launching again.",
"vpnWorkerStartFailed": "Couldn't start the VPN connection: {{detail}}",
"exitProbeFailed": "Couldn't reach the proxy exit node to check its location."
},
"rail": {
"profiles": "Profiles",
@@ -2135,14 +2142,9 @@
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
},
"consistencyWarning": {
"title": "Fingerprint mismatch",
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
"timezoneTitle": "Timezone mismatch",
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
"languageTitle": "Language mismatch",
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
"dontWarnAgain": "Don't warn again for this profile",
"matchToProxy": "Match fingerprint to proxy",
"matching": "Matching…",
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Stopped by hand",
"unknown": "Unknown reason ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Launch blocked",
"titleWarning": "Before you launch",
"intro": "Review these issues with \"{{name}}\" before starting the browser.",
"fingerprintHeading": "Proxy exit doesn't match the fingerprint",
"vpnExtensionHeading": "VPN extension detected",
"vpnExtensionIntro": "Extensions in this profile that can reroute the browser's traffic:",
"vpnExtensionConfirmed": "Can change the proxy",
"vpnExtensionLikely": "May change the proxy",
"vpnExtensionExplainer": "If one of these routes your traffic elsewhere, the browser's real location will no longer match the timezone, language and geolocation this profile was created with, and Donut cannot detect that from the outside.",
"sourceDonut": "Managed by Donut",
"sourceBrowser": "Installed in the profile",
"measurementUnreliable": "Because a VPN extension can override the proxy, the exit check may not describe the route the browser actually takes.",
"scanIncompleteEncrypted": "This profile is encrypted, so only Donut-managed extensions could be checked.",
"scanIncompleteEphemeral": "This profile has no data yet, so only Donut-managed extensions could be checked.",
"scanIncompletePartial": "The extension scan was cut short, so some extensions may not be listed.",
"probePending": "The proxy exit hasn't been measured yet. Donut will check it while starting and stop if it doesn't match.",
"launchAnyway": "Launch anyway",
"dontBlockAgain": "Don't block again for this exact mismatch",
"dontWarnExtensions": "Don't warn again about these extensions",
"applyToRemaining": "Apply this choice to the remaining profiles",
"cancelledSummary": "{{cancelled}} of {{total}} launches cancelled",
"cancelled": "Launch cancelled",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "This profile has not been launched yet, so only Donut-managed extensions could be checked."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
"privacy": {
"consistencyWarning": "Advertencia de consistencia de huella digital",
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
"consistencyWarning": "Bloquear si la huella digital no coincide",
"consistencyWarningDescription": "Impide que el navegador se inicie cuando la zona horaria o el idioma de un perfil no coinciden con su nodo de salida del proxy. Aun así podrás iniciarlo.",
"clearTraffic": "Borrar todo el historial de tráfico",
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
"clearTrafficSuccess": "Historial de tráfico borrado"
"clearTrafficSuccess": "Historial de tráfico borrado",
"vpnExtensionWarning": "Aviso de extensión VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.",
"remoteNoCapacity": "Ahora mismo no hay ninguna máquina remota libre. Inténtalo de nuevo en unos minutos.",
"remoteNotEntitled": "Tu plan no incluye la ejecución remota.",
"remoteInteractiveNotEntitled": "Tu plan incluye horas remotas solo para el Cookie Bot, no para sesiones remotas interactivas.",
"remoteSessionRefused": "La máquina remota rechazó esta sesión.",
"remoteSessionNotFound": "Esa sesión remota ya no existe.",
"remoteSessionConflict": "Este perfil ya está abierto en otro sitio.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.",
"profileLockedByMember": "Este perfil está siendo usado por {{email}}.",
"profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.",
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo."
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo.",
"fingerprintExitMismatch": "El nodo de salida del proxy no coincide con la huella digital de este perfil.",
"launchConsentExpired": "Esa confirmación ya no es válida. Vuelve a iniciar.",
"vpnWorkerStartFailed": "No se pudo iniciar la conexión VPN: {{detail}}",
"exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación."
},
"rail": {
"profiles": "Perfiles",
@@ -2142,14 +2149,9 @@
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
},
"consistencyWarning": {
"title": "Discrepancia de huella digital",
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
"timezoneTitle": "Discrepancia de zona horaria",
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
"languageTitle": "Discrepancia de idioma",
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
"dontWarnAgain": "No volver a advertir para este perfil",
"matchToProxy": "Ajustar huella al proxy",
"matching": "Ajustando…",
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Detenido a mano",
"unknown": "Motivo desconocido ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Inicio bloqueado",
"titleWarning": "Antes de iniciar",
"intro": "Revisa estos problemas de \"{{name}}\" antes de iniciar el navegador.",
"fingerprintHeading": "La salida del proxy no coincide con la huella digital",
"vpnExtensionHeading": "Extensión VPN detectada",
"vpnExtensionIntro": "Extensiones de este perfil que pueden redirigir el tráfico del navegador:",
"vpnExtensionConfirmed": "Puede cambiar el proxy",
"vpnExtensionLikely": "Podría cambiar el proxy",
"vpnExtensionExplainer": "Si alguna de ellas redirige tu tráfico a otro lugar, la ubicación real del navegador dejará de coincidir con la zona horaria, el idioma y la geolocalización con los que se creó este perfil, y Donut no puede detectarlo desde fuera.",
"sourceDonut": "Gestionada por Donut",
"sourceBrowser": "Instalada en el perfil",
"measurementUnreliable": "Como una extensión VPN puede anular el proxy, la comprobación de salida podría no reflejar la ruta que el navegador usa realmente.",
"scanIncompleteEncrypted": "Este perfil está cifrado, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompleteEphemeral": "Este perfil aún no tiene datos, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompletePartial": "El análisis de extensiones se interrumpió, así que puede que falten algunas.",
"probePending": "Todavía no se ha medido la salida del proxy. Donut la comprobará al iniciar y se detendrá si no coincide.",
"launchAnyway": "Iniciar de todos modos",
"dontBlockAgain": "No bloquear de nuevo por esta discrepancia exacta",
"dontWarnExtensions": "No volver a avisar sobre estas extensiones",
"applyToRemaining": "Aplicar esta decisión a los perfiles restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicios cancelados",
"cancelled": "Inicio cancelado",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil aún no se ha iniciado, así que solo se pudieron comprobar las extensiones gestionadas por Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
"privacy": {
"consistencyWarning": "Avertissement de cohérence d'empreinte",
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
"consistencyWarning": "Bloquer en cas d'empreinte incohérente",
"consistencyWarningDescription": "Empêche le navigateur de démarrer lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie. Vous pourrez tout de même lancer.",
"clearTraffic": "Effacer tout l'historique de trafic",
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
"clearTrafficSuccess": "Historique de trafic effacé"
"clearTrafficSuccess": "Historique de trafic effacé",
"vpnExtensionWarning": "Avertissement d'extension VPN",
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Trop de requêtes. Patientez un instant et réessayez.",
"remoteNoCapacity": "Aucune machine distante n'est libre pour le moment. Réessayez dans quelques minutes.",
"remoteNotEntitled": "Votre forfait n'inclut pas l'exécution à distance.",
"remoteInteractiveNotEntitled": "Votre forfait inclut des heures distantes uniquement pour le Cookie Bot, pas pour les sessions distantes interactives.",
"remoteSessionRefused": "La machine distante a refusé cette session.",
"remoteSessionNotFound": "Cette session distante n'existe plus.",
"remoteSessionConflict": "Ce profil est déjà ouvert ailleurs.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.",
"profileLockedByMember": "Ce profil est utilisé par {{email}}.",
"profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.",
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez."
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez.",
"fingerprintExitMismatch": "Le nœud de sortie du proxy ne correspond pas à l'empreinte de ce profil.",
"launchConsentExpired": "Cette confirmation n'est plus valide. Relancez le profil.",
"vpnWorkerStartFailed": "Impossible de démarrer la connexion VPN : {{detail}}",
"exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation."
},
"rail": {
"profiles": "Profils",
@@ -2142,14 +2149,9 @@
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
},
"consistencyWarning": {
"title": "Incohérence d'empreinte",
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
"timezoneTitle": "Incohérence de fuseau horaire",
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
"languageTitle": "Incohérence de langue",
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
"dontWarnAgain": "Ne plus avertir pour ce profil",
"matchToProxy": "Aligner l'empreinte sur le proxy",
"matching": "Alignement…",
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Arrêté à la main",
"unknown": "Raison inconnue ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Lancement bloqué",
"titleWarning": "Avant de lancer",
"intro": "Examinez ces problèmes concernant « {{name}} » avant de démarrer le navigateur.",
"fingerprintHeading": "La sortie du proxy ne correspond pas à l'empreinte",
"vpnExtensionHeading": "Extension VPN détectée",
"vpnExtensionIntro": "Extensions de ce profil pouvant rerouter le trafic du navigateur :",
"vpnExtensionConfirmed": "Peut changer le proxy",
"vpnExtensionLikely": "Pourrait changer le proxy",
"vpnExtensionExplainer": "Si l'une d'elles redirige votre trafic ailleurs, la position réelle du navigateur ne correspondra plus au fuseau horaire, à la langue et à la géolocalisation avec lesquels ce profil a été créé, et Donut ne peut pas le détecter de l'extérieur.",
"sourceDonut": "Gérée par Donut",
"sourceBrowser": "Installée dans le profil",
"measurementUnreliable": "Comme une extension VPN peut remplacer le proxy, la vérification de la sortie peut ne pas refléter la route réellement empruntée par le navigateur.",
"scanIncompleteEncrypted": "Ce profil est chiffré : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompleteEphemeral": "Ce profil n'a pas encore de données : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompletePartial": "L'analyse des extensions a été interrompue, certaines peuvent manquer.",
"probePending": "La sortie du proxy n'a pas encore été mesurée. Donut la vérifiera au démarrage et s'arrêtera si elle ne correspond pas.",
"launchAnyway": "Lancer quand même",
"dontBlockAgain": "Ne plus bloquer pour cette incohérence exacte",
"dontWarnExtensions": "Ne plus m'avertir à propos de ces extensions",
"applyToRemaining": "Appliquer ce choix aux profils restants",
"cancelledSummary": "{{cancelled}} lancements sur {{total}} annulés",
"cancelled": "Lancement annulé",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Ce profil n'a jamais été lancé : seules les extensions gérées par Donut ont pu être vérifiées."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
"privacy": {
"consistencyWarning": "フィンガープリント整合性の警告",
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
"consistencyWarning": "フィンガープリント不一致時に起動をブロック",
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、ブラウザーの起動を停止します。それでも起動を選択できます。",
"clearTraffic": "すべてのトラフィック履歴を消去",
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
"clearTrafficSuccess": "トラフィック履歴を消去しました"
"clearTrafficSuccess": "トラフィック履歴を消去しました",
"vpnExtensionWarning": "VPN拡張機能の警告",
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。"
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "リクエストが多すぎます。少し待ってからもう一度お試しください。",
"remoteNoCapacity": "現在空いているリモートマシンがありません。数分後にもう一度お試しください。",
"remoteNotEntitled": "ご利用のプランにはリモート実行が含まれていません。",
"remoteInteractiveNotEntitled": "ご利用のプランのリモート時間は Cookie Bot 専用で、手動のリモートセッションには使えません。",
"remoteSessionRefused": "リモートマシンがこのセッションを拒否しました。",
"remoteSessionNotFound": "そのリモートセッションはすでに存在しません。",
"remoteSessionConflict": "このプロファイルはすでに別の場所で開かれています。",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "リモートセッションが終了しました。この profile をここで開く前に、変更のダウンロードを待っています。",
"profileLockedByMember": "このプロファイルは {{email}} が使用中です。",
"profileLockedElsewhere": "このプロファイルは別のデバイスで使用中です。",
"profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。"
"profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。",
"fingerprintExitMismatch": "プロキシの出口ノードがこのプロファイルのフィンガープリントと一致しません。",
"launchConsentExpired": "この確認は無効になりました。もう一度起動してください。",
"vpnWorkerStartFailed": "VPN接続を開始できませんでした: {{detail}}",
"exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -2135,14 +2142,9 @@
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
},
"consistencyWarning": {
"title": "フィンガープリントの不一致",
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
"timezoneTitle": "タイムゾーンの不一致",
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
"languageTitle": "言語の不一致",
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
"dontWarnAgain": "このプロファイルでは今後警告しない",
"matchToProxy": "フィンガープリントをプロキシに合わせる",
"matching": "調整中…",
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
@@ -2428,5 +2430,31 @@
"cancelledByUser": "手動で停止しました",
"unknown": "不明な理由 ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "起動をブロックしました",
"titleWarning": "起動する前に",
"intro": "ブラウザーを起動する前に、「{{name}}」に関する次の問題を確認してください。",
"fingerprintHeading": "プロキシの出口がフィンガープリントと一致しません",
"vpnExtensionHeading": "VPN拡張機能を検出しました",
"vpnExtensionIntro": "このプロファイル内で、ブラウザーの通信を経路変更できる拡張機能:",
"vpnExtensionConfirmed": "プロキシを変更できます",
"vpnExtensionLikely": "プロキシを変更する可能性があります",
"vpnExtensionExplainer": "いずれかが通信を別の経路に変えると、ブラウザーの実際の所在地は、このプロファイルの作成時に設定されたタイムゾーン・言語・位置情報と一致しなくなります。Donutは外部からそれを検出できません。",
"sourceDonut": "Donutが管理",
"sourceBrowser": "プロファイルにインストール済み",
"measurementUnreliable": "VPN拡張機能はプロキシを上書きできるため、出口の確認結果がブラウザーの実際の経路を表していない可能性があります。",
"scanIncompleteEncrypted": "このプロファイルは暗号化されているため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompleteEphemeral": "このプロファイルにはまだデータがないため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompletePartial": "拡張機能のスキャンが途中で終了したため、一部が表示されていない可能性があります。",
"probePending": "プロキシの出口はまだ測定されていません。Donutは起動中に確認し、一致しない場合は停止します。",
"launchAnyway": "このまま起動",
"dontBlockAgain": "この不一致では今後ブロックしない",
"dontWarnExtensions": "これらの拡張機能について今後警告しない",
"applyToRemaining": "この選択を残りのプロファイルにも適用",
"cancelledSummary": "{{total}}件中{{cancelled}}件の起動をキャンセルしました",
"cancelled": "起動をキャンセルしました",
"vpnExtensionEntry": " {{version}}{{capability}}、{{source}}",
"scanIncompleteMissing": "このプロファイルはまだ起動されていないため、Donutが管理する拡張機能のみ確認できました。"
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
"privacy": {
"consistencyWarning": "핑거프린트 일관성 경고",
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
"consistencyWarning": "핑거프린트 불일치 시 차단",
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 브라우저 시작을 중단합니다. 그래도 실행을 선택할 수 있습니다.",
"clearTraffic": "모든 트래픽 기록 지우기",
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다",
"vpnExtensionWarning": "VPN 확장 프로그램 경고",
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "요청이 너무 많습니다. 잠시 기다렸다가 다시 시도하세요.",
"remoteNoCapacity": "지금은 사용 가능한 원격 머신이 없습니다. 몇 분 후에 다시 시도하세요.",
"remoteNotEntitled": "현재 요금제에는 원격 실행이 포함되어 있지 않습니다.",
"remoteInteractiveNotEntitled": "현재 플랜의 원격 시간은 Cookie Bot 전용이며, 직접 조작하는 원격 세션에는 사용할 수 없습니다.",
"remoteSessionRefused": "원격 머신이 이 세션을 거부했습니다.",
"remoteSessionNotFound": "해당 원격 세션은 더 이상 존재하지 않습니다.",
"remoteSessionConflict": "이 프로필은 이미 다른 곳에서 열려 있습니다.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "원격 세션이 방금 끝났습니다. 이 프로필을 여기서 열기 전에 변경 사항을 내려받는 중입니다.",
"profileLockedByMember": "이 프로필은 {{email}} 님이 사용 중입니다.",
"profileLockedElsewhere": "이 프로필은 다른 기기에서 사용 중입니다.",
"profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요."
"profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요.",
"fingerprintExitMismatch": "프록시 출구 노드가 이 프로필의 핑거프린트와 일치하지 않습니다.",
"launchConsentExpired": "해당 확인이 더 이상 유효하지 않습니다. 다시 실행해 보세요.",
"vpnWorkerStartFailed": "VPN 연결을 시작하지 못했습니다: {{detail}}",
"exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다."
},
"rail": {
"profiles": "프로필",
@@ -2135,14 +2142,9 @@
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
},
"consistencyWarning": {
"title": "핑거프린트 불일치",
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
"timezoneTitle": "시간대 불일치",
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
"languageTitle": "언어 불일치",
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
"matchToProxy": "지문을 프록시에 맞추기",
"matching": "맞추는 중…",
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "직접 중지했습니다",
"unknown": "알 수 없는 이유 ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "실행이 차단됨",
"titleWarning": "실행하기 전에",
"intro": "브라우저를 시작하기 전에 \"{{name}}\"의 다음 문제를 확인하세요.",
"fingerprintHeading": "프록시 출구가 핑거프린트와 일치하지 않음",
"vpnExtensionHeading": "VPN 확장 프로그램 감지됨",
"vpnExtensionIntro": "이 프로필에서 브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램:",
"vpnExtensionConfirmed": "프록시를 변경할 수 있음",
"vpnExtensionLikely": "프록시를 변경할 수 있음(추정)",
"vpnExtensionExplainer": "이 중 하나가 트래픽을 다른 곳으로 보내면 브라우저의 실제 위치가 이 프로필을 만들 때 사용한 시간대, 언어, 지리 정보와 더 이상 일치하지 않으며, Donut은 외부에서 이를 감지할 수 없습니다.",
"sourceDonut": "Donut이 관리",
"sourceBrowser": "프로필에 설치됨",
"measurementUnreliable": "VPN 확장 프로그램이 프록시를 덮어쓸 수 있으므로, 출구 확인 결과가 브라우저의 실제 경로와 다를 수 있습니다.",
"scanIncompleteEncrypted": "이 프로필은 암호화되어 있어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompleteEphemeral": "이 프로필에는 아직 데이터가 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompletePartial": "확장 프로그램 검사가 중단되어 일부가 표시되지 않을 수 있습니다.",
"probePending": "프록시 출구를 아직 측정하지 않았습니다. Donut이 시작 중에 확인하고 일치하지 않으면 중단합니다.",
"launchAnyway": "그래도 실행",
"dontBlockAgain": "이 불일치에 대해 다시 차단하지 않기",
"dontWarnExtensions": "이 확장 프로그램에 대해 다시 경고하지 않기",
"applyToRemaining": "이 선택을 나머지 프로필에 적용",
"cancelledSummary": "{{total}}개 중 {{cancelled}}개의 실행이 취소됨",
"cancelled": "실행이 취소됨",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "이 프로필은 아직 실행된 적이 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
"privacy": {
"consistencyWarning": "Aviso de consistência de impressão digital",
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
"consistencyWarning": "Bloquear quando a impressão digital divergir",
"consistencyWarningDescription": "Impede que o navegador inicie quando o fuso horário ou o idioma de um perfil não corresponde ao seu nó de saída do proxy. Você ainda pode optar por iniciar.",
"clearTraffic": "Limpar todo o histórico de tráfego",
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
"clearTrafficSuccess": "Histórico de tráfego limpo"
"clearTrafficSuccess": "Histórico de tráfego limpo",
"vpnExtensionWarning": "Aviso de extensão VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Solicitações demais. Aguarde um momento e tente novamente.",
"remoteNoCapacity": "Nenhuma máquina remota está livre agora. Tente novamente em alguns minutos.",
"remoteNotEntitled": "Seu plano não inclui execução remota.",
"remoteInteractiveNotEntitled": "Seu plano inclui horas remotas apenas para o Cookie Bot, não para sessões remotas interativas.",
"remoteSessionRefused": "A máquina remota recusou esta sessão.",
"remoteSessionNotFound": "Essa sessão remota não existe mais.",
"remoteSessionConflict": "Este perfil já está aberto em outro lugar.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.",
"profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.",
"profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.",
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente."
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente.",
"fingerprintExitMismatch": "O nó de saída do proxy não corresponde à impressão digital deste perfil.",
"launchConsentExpired": "Essa confirmação não é mais válida. Tente iniciar novamente.",
"vpnWorkerStartFailed": "Não foi possível iniciar a conexão VPN: {{detail}}",
"exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização."
},
"rail": {
"profiles": "Perfis",
@@ -2142,14 +2149,9 @@
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
},
"consistencyWarning": {
"title": "Divergência de impressão digital",
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
"timezoneTitle": "Divergência de fuso horário",
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
"languageTitle": "Divergência de idioma",
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
"dontWarnAgain": "Não avisar novamente para este perfil",
"matchToProxy": "Ajustar impressão ao proxy",
"matching": "Ajustando…",
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Parado à mão",
"unknown": "Motivo desconhecido ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Inicialização bloqueada",
"titleWarning": "Antes de iniciar",
"intro": "Revise estes problemas de \"{{name}}\" antes de iniciar o navegador.",
"fingerprintHeading": "A saída do proxy não corresponde à impressão digital",
"vpnExtensionHeading": "Extensão VPN detectada",
"vpnExtensionIntro": "Extensões neste perfil que podem redirecionar o tráfego do navegador:",
"vpnExtensionConfirmed": "Pode alterar o proxy",
"vpnExtensionLikely": "Talvez altere o proxy",
"vpnExtensionExplainer": "Se alguma delas redirecionar seu tráfego, a localização real do navegador deixará de corresponder ao fuso horário, ao idioma e à geolocalização com que este perfil foi criado, e o Donut não consegue detectar isso de fora.",
"sourceDonut": "Gerenciada pelo Donut",
"sourceBrowser": "Instalada no perfil",
"measurementUnreliable": "Como uma extensão VPN pode substituir o proxy, a verificação de saída pode não refletir a rota que o navegador realmente usa.",
"scanIncompleteEncrypted": "Este perfil está criptografado, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompleteEphemeral": "Este perfil ainda não tem dados, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompletePartial": "A verificação de extensões foi interrompida, então algumas podem não estar listadas.",
"probePending": "A saída do proxy ainda não foi medida. O Donut vai verificá-la durante a inicialização e parar se não corresponder.",
"launchAnyway": "Iniciar mesmo assim",
"dontBlockAgain": "Não bloquear novamente para esta divergência exata",
"dontWarnExtensions": "Não avisar novamente sobre estas extensões",
"applyToRemaining": "Aplicar esta escolha aos perfis restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicializações canceladas",
"cancelled": "Inicialização cancelada",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil ainda não foi iniciado, portanto só foi possível verificar as extensões gerenciadas pelo Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
"privacy": {
"consistencyWarning": "Предупреждение о согласованности отпечатка",
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
"consistencyWarning": "Блокировать при несовпадении отпечатка",
"consistencyWarningDescription": "Не запускать браузер, если часовой пояс или язык профиля не совпадают с выходным узлом прокси. Запустить всё равно можно вручную.",
"clearTraffic": "Очистить всю историю трафика",
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
"clearTrafficSuccess": "История трафика очищена"
"clearTrafficSuccess": "История трафика очищена",
"vpnExtensionWarning": "Предупреждение о VPN-расширении",
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера."
}
},
"header": {
@@ -1877,6 +1879,7 @@
"remoteRateLimited": "Слишком много запросов. Подождите немного и попробуйте снова.",
"remoteNoCapacity": "Сейчас нет свободных удалённых машин. Попробуйте через несколько минут.",
"remoteNotEntitled": "Ваш тариф не включает удалённый запуск.",
"remoteInteractiveNotEntitled": "В вашем тарифе удалённые часы доступны только для Cookie Bot, но не для интерактивных удалённых сессий.",
"remoteSessionRefused": "Удалённая машина отклонила эту сессию.",
"remoteSessionNotFound": "Этой удалённой сессии больше не существует.",
"remoteSessionConflict": "Этот профиль уже открыт в другом месте.",
@@ -1903,7 +1906,11 @@
"profileRemoteSyncPending": "Удалённый сеанс только что завершился. Дождитесь загрузки его изменений, прежде чем открывать профиль здесь.",
"profileLockedByMember": "Этот профиль используется пользователем {{email}}.",
"profileLockedElsewhere": "Этот профиль используется на другом устройстве.",
"profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова."
"profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова.",
"fingerprintExitMismatch": "Выходной узел прокси не совпадает с отпечатком этого профиля.",
"launchConsentExpired": "Это подтверждение больше не действует. Запустите профиль ещё раз.",
"vpnWorkerStartFailed": "Не удалось запустить VPN-подключение: {{detail}}",
"exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение."
},
"rail": {
"profiles": "Профили",
@@ -2149,14 +2156,9 @@
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
},
"consistencyWarning": {
"title": "Несовпадение отпечатка",
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
"timezoneTitle": "Несовпадение часового пояса",
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
"languageTitle": "Несовпадение языка",
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
"dontWarnAgain": "Больше не предупреждать для этого профиля",
"matchToProxy": "Подогнать отпечаток под прокси",
"matching": "Подгонка…",
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
@@ -2482,5 +2484,31 @@
"cancelledByUser": "Остановлено вручную",
"unknown": "Неизвестная причина ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Запуск заблокирован",
"titleWarning": "Перед запуском",
"intro": "Проверьте эти проблемы профиля «{{name}}» перед запуском браузера.",
"fingerprintHeading": "Выходной узел прокси не совпадает с отпечатком",
"vpnExtensionHeading": "Обнаружено VPN-расширение",
"vpnExtensionIntro": "Расширения в этом профиле, способные перенаправить трафик браузера:",
"vpnExtensionConfirmed": "Может изменить прокси",
"vpnExtensionLikely": "Возможно, изменит прокси",
"vpnExtensionExplainer": "Если одно из них направит трафик в другое место, реальное местоположение браузера перестанет совпадать с часовым поясом, языком и геолокацией, с которыми создавался профиль, а Donut не сможет это обнаружить извне.",
"sourceDonut": "Управляется Donut",
"sourceBrowser": "Установлено в профиле",
"measurementUnreliable": "Поскольку VPN-расширение может переопределить прокси, проверка выходного узла может не отражать реальный маршрут браузера.",
"scanIncompleteEncrypted": "Профиль зашифрован, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompleteEphemeral": "В профиле ещё нет данных, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompletePartial": "Проверка расширений была прервана, поэтому некоторые могут отсутствовать в списке.",
"probePending": "Выходной узел прокси ещё не измерен. Donut проверит его при запуске и остановится, если он не совпадёт.",
"launchAnyway": "Всё равно запустить",
"dontBlockAgain": "Больше не блокировать при этом несовпадении",
"dontWarnExtensions": "Больше не предупреждать об этих расширениях",
"applyToRemaining": "Применить этот выбор к остальным профилям",
"cancelledSummary": "Отменено запусков: {{cancelled}} из {{total}}",
"cancelled": "Запуск отменён",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Профиль ещё ни разу не запускался, поэтому удалось проверить только расширения, управляемые Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Şifresi Çözülmüş Profilleri RAM'de Tut",
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır.",
"privacy": {
"consistencyWarning": "Parmak izi tutarlılık uyarısı",
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde başlatma sırasında uyar.",
"consistencyWarning": "Parmak izi uyuşmazlığında engelle",
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde tarayıcının başlamasını durdurur. Yine de başlatmayı seçebilirsiniz.",
"clearTraffic": "Tüm trafik geçmişini temizle",
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
"clearTrafficSuccess": "Trafik geçmişi temizlendi"
"clearTrafficSuccess": "Trafik geçmişi temizlendi",
"vpnExtensionWarning": "VPN uzantısı uyarısı",
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Çok fazla istek. Biraz bekleyip tekrar deneyin.",
"remoteNoCapacity": "Şu anda boş uzak makine yok. Birkaç dakika sonra tekrar deneyin.",
"remoteNotEntitled": "Planınız uzaktan çalıştırmayı içermiyor.",
"remoteInteractiveNotEntitled": "Planınızdaki uzak saatler yalnızca Cookie Bot için geçerlidir, elle kullanılan uzak oturumlar için değil.",
"remoteSessionRefused": "Uzak makine bu oturumu reddetti.",
"remoteSessionNotFound": "Bu uzak oturum artık mevcut değil.",
"remoteSessionConflict": "Bu profil başka bir yerde zaten açık.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.",
"profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.",
"profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.",
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin."
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin.",
"fingerprintExitMismatch": "Proxy çıkış düğümü bu profilin parmak iziyle eşleşmiyor.",
"launchConsentExpired": "Bu onay artık geçerli değil. Yeniden başlatmayı deneyin.",
"vpnWorkerStartFailed": "VPN bağlantısı başlatılamadı: {{detail}}",
"exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı."
},
"rail": {
"profiles": "Profiller",
@@ -2135,14 +2142,9 @@
"description": "Tarayıcı kapanırken çerezleri, geçmişi ve önbelleği siler. Uzantılar ve yer imleri korunur."
},
"consistencyWarning": {
"title": "Parmak izi uyuşmazlığı",
"intro": "\"{{name}}\" için proxy çıkışı bu profilin parmak iziyle eşleşmiyor:",
"timezoneTitle": "Saat dilimi uyuşmazlığı",
"timezoneDetail": "Çıkış düğümü {{exit}} konumunda, ancak parmak izi {{fingerprint}} bildiriyor.",
"languageTitle": "Dil uyuşmazlığı",
"languageDetail": "Çıkış ülkesi {{country}}, ancak parmak izi dili {{fingerprint}}.",
"explainer": "Çıkış IP'nizle uyuşmayan bir saat dilimi veya dil, gerçek cihazınız hiç sızdırmasa bile güçlü bir anti-bot sinyalidir. Şüpheli muameleyi azaltmak için parmak izini proxy konumuyla hizalayın.",
"dontWarnAgain": "Bu profil için bir daha uyarma",
"matchToProxy": "Parmak izini proxy'ye eşle",
"matching": "Eşleniyor…",
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Elle durduruldu",
"unknown": "Bilinmeyen neden ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Başlatma engellendi",
"titleWarning": "Başlatmadan önce",
"intro": "Tarayıcıyı başlatmadan önce \"{{name}}\" ile ilgili şu sorunları inceleyin.",
"fingerprintHeading": "Proxy çıkışı parmak iziyle eşleşmiyor",
"vpnExtensionHeading": "VPN uzantısı algılandı",
"vpnExtensionIntro": "Bu profildeki, tarayıcı trafiğini yeniden yönlendirebilecek uzantılar:",
"vpnExtensionConfirmed": "Proxy'yi değiştirebilir",
"vpnExtensionLikely": "Proxy'yi değiştirebilir (olası)",
"vpnExtensionExplainer": "Bunlardan biri trafiğinizi başka bir yere yönlendirirse, tarayıcının gerçek konumu artık bu profilin oluşturulduğu saat dilimi, dil ve coğrafi konumla eşleşmez ve Donut bunu dışarıdan algılayamaz.",
"sourceDonut": "Donut tarafından yönetiliyor",
"sourceBrowser": "Profile yüklenmiş",
"measurementUnreliable": "Bir VPN uzantısı proxy'yi geçersiz kılabileceğinden, çıkış kontrolü tarayıcının gerçekte kullandığı rotayı yansıtmayabilir.",
"scanIncompleteEncrypted": "Bu profil şifreli olduğundan yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompleteEphemeral": "Bu profilde henüz veri olmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompletePartial": "Uzantı taraması yarıda kesildi, bu nedenle bazıları listelenmemiş olabilir.",
"probePending": "Proxy çıkışı henüz ölçülmedi. Donut başlatma sırasında kontrol edecek ve eşleşmezse duracak.",
"launchAnyway": "Yine de başlat",
"dontBlockAgain": "Bu tam uyuşmazlık için bir daha engelleme",
"dontWarnExtensions": "Bu uzantılar için bir daha uyarma",
"applyToRemaining": "Bu seçimi kalan profillere uygula",
"cancelledSummary": "{{total}} başlatmadan {{cancelled}} tanesi iptal edildi",
"cancelled": "Başlatma iptal edildi",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Bu profil henüz başlatılmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
"privacy": {
"consistencyWarning": "Cảnh báo nhất quán vân tay",
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
"consistencyWarning": "Chặn khi dấu vân tay không khớp",
"consistencyWarningDescription": "Ngăn trình duyệt khi động khi múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát của proxy. Bạn vẫn có thể chọn khởi chạy.",
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng"
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng",
"vpnExtensionWarning": "Cảnh báo tiện ích VPN",
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Quá nhiều yêu cầu. Hãy đợi một lát rồi thử lại.",
"remoteNoCapacity": "Hiện không có máy từ xa nào rảnh. Hãy thử lại sau vài phút.",
"remoteNotEntitled": "Gói của bạn không bao gồm chạy từ xa.",
"remoteInteractiveNotEntitled": "Gói của bạn chỉ bao gồm giờ từ xa cho Cookie Bot, không dùng cho phiên từ xa thao tác trực tiếp.",
"remoteSessionRefused": "Máy từ xa đã từ chối phiên này.",
"remoteSessionNotFound": "Phiên từ xa đó không còn tồn tại.",
"remoteSessionConflict": "Hồ sơ này đang được mở ở nơi khác.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.",
"profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.",
"profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.",
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại."
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại.",
"fingerprintExitMismatch": "Nút thoát của proxy không khớp với dấu vân tay của hồ sơ này.",
"launchConsentExpired": "Xác nhận đó không còn hiệu lực. Hãy thử khởi chạy lại.",
"vpnWorkerStartFailed": "Không thể khởi động kết nối VPN: {{detail}}",
"exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí."
},
"rail": {
"profiles": "Profile",
@@ -2135,14 +2142,9 @@
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
},
"consistencyWarning": {
"title": "Vân tay không khớp",
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
"timezoneTitle": "Múi giờ không khớp",
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
"languageTitle": "Ngôn ngữ không khớp",
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
"matchToProxy": "Khớp vân tay với proxy",
"matching": "Đang khớp…",
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Đã dừng thủ công",
"unknown": "Lý do không xác định ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Đã chặn khởi chạy",
"titleWarning": "Trước khi khởi chạy",
"intro": "Hãy xem lại các vấn đề của \"{{name}}\" trước khi khởi động trình duyệt.",
"fingerprintHeading": "Điểm ra của proxy không khớp với dấu vân tay",
"vpnExtensionHeading": "Đã phát hiện tiện ích VPN",
"vpnExtensionIntro": "Các tiện ích trong hồ sơ này có thể định tuyến lại lưu lượng của trình duyệt:",
"vpnExtensionConfirmed": "Có thể thay đổi proxy",
"vpnExtensionLikely": "Có khả năng thay đổi proxy",
"vpnExtensionExplainer": "Nếu một trong số đó chuyển lưu lượng của bạn đi nơi khác, vị trí thực của trình duyệt sẽ không còn khớp với múi giờ, ngôn ngữ và vị trí địa lý mà hồ sơ này được tạo ra, và Donut không thể phát hiện điều đó từ bên ngoài.",
"sourceDonut": "Do Donut quản lý",
"sourceBrowser": "Đã cài trong hồ sơ",
"measurementUnreliable": "Vì tiện ích VPN có thể ghi đè proxy, kết quả kiểm tra điểm ra có thể không phản ánh tuyến đường mà trình duyệt thực sự dùng.",
"scanIncompleteEncrypted": "Hồ sơ này được mã hóa nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompleteEphemeral": "Hồ sơ này chưa có dữ liệu nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompletePartial": "Quá trình quét tiện ích bị ngắt giữa chừng nên có thể thiếu một số tiện ích.",
"probePending": "Điểm ra của proxy chưa được đo. Donut sẽ kiểm tra trong lúc khởi động và dừng lại nếu không khớp.",
"launchAnyway": "Vẫn khởi chạy",
"dontBlockAgain": "Không chặn lại với đúng sai lệch này",
"dontWarnExtensions": "Không cảnh báo lại về các tiện ích này",
"applyToRemaining": "Áp dụng lựa chọn này cho các hồ sơ còn lại",
"cancelledSummary": "Đã hủy {{cancelled}} trên {{total}} lượt khởi chạy",
"cancelled": "Đã hủy khởi chạy",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Hồ sơ này chưa từng được khởi chạy nên chỉ có thể kiểm tra các tiện ích do Donut quản lý."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
"privacy": {
"consistencyWarning": "指纹一致性警告",
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
"consistencyWarning": "指纹不匹配时阻止启动",
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不一致时,阻止浏览器启动。你仍可以选择启动。",
"clearTraffic": "清除所有流量历史",
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
"clearTrafficSuccess": "流量历史已清除"
"clearTrafficSuccess": "流量历史已清除",
"vpnExtensionWarning": "VPN 扩展警告",
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。"
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "请求过于频繁。请稍候再试。",
"remoteNoCapacity": "当前没有空闲的远程机器。请几分钟后再试。",
"remoteNotEntitled": "你的套餐不包含远程运行。",
"remoteInteractiveNotEntitled": "您的套餐中的远程时长仅供 Cookie Bot 使用,不能用于手动远程会话。",
"remoteSessionRefused": "远程机器拒绝了此会话。",
"remoteSessionNotFound": "该远程会话已不存在。",
"remoteSessionConflict": "此配置文件已在别处打开。",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "远程会话刚刚结束。正在等待其更改下载完成后才能在此打开该配置文件。",
"profileLockedByMember": "该配置文件正在被 {{email}} 使用。",
"profileLockedElsewhere": "该配置文件正在另一台设备上使用。",
"profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。"
"profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。",
"fingerprintExitMismatch": "代理出口节点与此配置文件的指纹不匹配。",
"launchConsentExpired": "该确认已失效。请重新启动。",
"vpnWorkerStartFailed": "无法启动 VPN 连接:{{detail}}",
"exitProbeFailed": "无法连接代理出口节点以检查其位置。"
},
"rail": {
"profiles": "配置文件",
@@ -2135,14 +2142,9 @@
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
},
"consistencyWarning": {
"title": "指纹不匹配",
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
"timezoneTitle": "时区不匹配",
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
"languageTitle": "语言不匹配",
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
"dontWarnAgain": "不再为此配置文件发出警告",
"matchToProxy": "将指纹匹配到代理",
"matching": "匹配中…",
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
@@ -2428,5 +2430,31 @@
"cancelledByUser": "已手动停止",
"unknown": "未知原因({{code}}"
}
},
"prelaunchGate": {
"titleBlocked": "启动已阻止",
"titleWarning": "启动前请注意",
"intro": "启动浏览器前,请检查“{{name}}”的以下问题。",
"fingerprintHeading": "代理出口与指纹不匹配",
"vpnExtensionHeading": "检测到 VPN 扩展",
"vpnExtensionIntro": "此配置文件中可能改变浏览器流量路径的扩展:",
"vpnExtensionConfirmed": "可以更改代理",
"vpnExtensionLikely": "可能会更改代理",
"vpnExtensionExplainer": "如果其中之一将流量转发到别处,浏览器的真实位置将不再与创建此配置文件时使用的时区、语言和地理位置一致,而 Donut 无法从外部察觉。",
"sourceDonut": "由 Donut 管理",
"sourceBrowser": "已安装在配置文件中",
"measurementUnreliable": "由于 VPN 扩展可以覆盖代理设置,出口检测结果可能并非浏览器实际使用的线路。",
"scanIncompleteEncrypted": "此配置文件已加密,因此只能检查由 Donut 管理的扩展。",
"scanIncompleteEphemeral": "此配置文件尚无数据,因此只能检查由 Donut 管理的扩展。",
"scanIncompletePartial": "扩展扫描被中断,可能有部分扩展未列出。",
"probePending": "尚未测量代理出口。Donut 会在启动过程中检查,如不匹配则停止。",
"launchAnyway": "仍要启动",
"dontBlockAgain": "不再因这一完全相同的不匹配而阻止",
"dontWarnExtensions": "不再就这些扩展发出警告",
"applyToRemaining": "将此选择应用于其余配置文件",
"cancelledSummary": "已取消 {{total}} 次启动中的 {{cancelled}} 次",
"cancelled": "已取消启动",
"vpnExtensionEntry": " {{version}} — {{capability}}、{{source}}",
"scanIncompleteMissing": "此配置文件尚未启动过,因此只能检查由 Donut 管理的扩展。"
}
}
+24
View File
@@ -73,6 +73,7 @@ export type BackendErrorCode =
| "REMOTE_RATE_LIMITED"
| "REMOTE_NO_CAPACITY"
| "REMOTE_NOT_ENTITLED"
| "REMOTE_INTERACTIVE_NOT_ENTITLED"
| "REMOTE_SESSION_REFUSED"
| "REMOTE_SESSION_NOT_FOUND"
| "REMOTE_SESSION_CONFLICT"
@@ -105,6 +106,10 @@ export type BackendErrorCode =
// rendered as the raw machine identifier.
| "COOKIE_BOT_REQUIRES_PROXY"
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
| "FINGERPRINT_EXIT_MISMATCH"
| "LAUNCH_CONSENT_EXPIRED"
| "VPN_WORKER_START_FAILED"
| "EXIT_PROBE_FAILED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -306,6 +311,12 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.remoteNoCapacity");
case "REMOTE_NOT_ENTITLED":
return t("backendErrors.remoteNotEntitled");
// Distinct from the above: the plan HAS remote hours, it just may not spend
// them by hand (solo funds a nightly Cookie Bot only). Telling such a user
// "your plan does not include remote execution" while their bot visibly
// runs every night is the confusing case this code exists to avoid.
case "REMOTE_INTERACTIVE_NOT_ENTITLED":
return t("backendErrors.remoteInteractiveNotEntitled");
case "REMOTE_SESSION_REFUSED":
return t("backendErrors.remoteSessionRefused");
case "REMOTE_SESSION_NOT_FOUND":
@@ -380,6 +391,19 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieBotRequiresExitNode");
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
// The launch gate's block. The dialog renders the mismatch detail from
// `params` itself; this string is the fallback for anywhere that only has
// room for one sentence.
case "FINGERPRINT_EXIT_MISMATCH":
return t("backendErrors.fingerprintExitMismatch");
case "LAUNCH_CONSENT_EXPIRED":
return t("backendErrors.launchConsentExpired");
case "VPN_WORKER_START_FAILED":
return t("backendErrors.vpnWorkerStartFailed", {
detail: parsed.params?.detail ?? "",
});
case "EXIT_PROBE_FAILED":
return t("backendErrors.exitProbeFailed");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+28 -10
View File
@@ -8,6 +8,7 @@ interface Capabilities {
cloudBackup: boolean;
teamCollaboration: boolean;
cookieBot: boolean;
remoteInteractive: boolean;
}
const NONE: Entitlements = {
@@ -17,6 +18,7 @@ const NONE: Entitlements = {
cloudBackup: false,
teamCollaboration: false,
cookieBot: false,
remoteInteractive: false,
profileLimit: 0,
requestsPerHour: 0,
remoteBrowserHours: 0,
@@ -25,12 +27,16 @@ const NONE: Entitlements = {
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
// sync — a new plan must be declared here too, or it falls back to DEFAULT_PAID.
const PLAN_CAPABILITIES: Record<string, Capabilities> = {
starter: {
// The one row where cookieBot, browserAutomation and remoteInteractive all
// disagree: solo pays for a nightly bot and nothing else that drives a
// browser. No fingerprint editing either.
solo: {
browserAutomation: false,
crossOsFingerprints: true,
crossOsFingerprints: false,
cloudBackup: true,
teamCollaboration: false,
cookieBot: false,
cookieBot: true,
remoteInteractive: false,
},
pro: {
browserAutomation: true,
@@ -38,6 +44,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
remoteInteractive: true,
},
team: {
browserAutomation: true,
@@ -45,6 +52,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
remoteInteractive: true,
},
enterprise: {
browserAutomation: true,
@@ -52,6 +60,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
remoteInteractive: true,
},
};
@@ -62,6 +71,7 @@ const DEFAULT_PAID: Capabilities = {
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
remoteInteractive: true,
};
/**
@@ -75,16 +85,23 @@ export function getEntitlements(
): Entitlements {
if (user?.entitlements) {
const server = user.entitlements;
// A backend (or a cached login) older than the cookie-bot release omits
// these two keys. Reading them as `undefined` would hide a paid feature
// from a paying customer with nothing logged anywhere, so resolve them
// here — the one place every caller already goes through. Cookie Bot is
// remote automation on leased hardware, so it tracks `browserAutomation`
// exactly; `remoteBrowserHours` stays 0 because the spendable figure is
// whatever `get_remote_hours_quota` reports, never a client guess.
// A backend (or a cached login) older than the current release omits these
// keys. Reading them as `undefined` would hide a paid feature from a paying
// customer with nothing logged anywhere, so resolve them here — the one
// place every caller already goes through.
//
// Both absent flags fall back to `browserAutomation`, which is what they
// were derived from before solo existed: on every plan a pre-solo backend
// knows about, automation implied both the bot and interactive remote
// control. A solo user never hits this branch — the backend that can put
// them on solo is by definition new enough to send both keys.
//
// `remoteBrowserHours` stays 0 because the spendable figure is whatever
// `get_remote_hours_quota` reports, never a client guess.
return {
...server,
cookieBot: server.cookieBot ?? server.browserAutomation,
remoteInteractive: server.remoteInteractive ?? server.browserAutomation,
remoteBrowserHours: server.remoteBrowserHours ?? 0,
};
}
@@ -103,6 +120,7 @@ export function getEntitlements(
cloudBackup: caps.cloudBackup,
teamCollaboration: caps.teamCollaboration,
cookieBot: caps.cookieBot,
remoteInteractive: caps.remoteInteractive,
profileLimit: user.profileLimit,
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
remoteBrowserHours: 0,
+60 -10
View File
@@ -85,11 +85,11 @@ export interface SyncSettings {
/**
* Capability/limit set derived from the plan by the backend. Features are gated
* on these flags instead of a single "is paid?" check, so a plan like the future
* "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
* data. Mirrors `apps/backend/src/plans/entitlements.ts`. Resolve via
* `getEntitlements()` the desktop populates it, but it stays optional for
* safety on older state.
* on these flags instead of a single "is paid?" check, so a plan like "solo"
* (cloud backup + nightly cookie bot, no automation, no fingerprint editing, no
* hands-on remote session) is just data. Mirrors
* `apps/backend/src/plans/entitlements.ts`. Resolve via `getEntitlements()`
* the desktop populates it, but it stays optional for safety on older state.
*/
export interface Entitlements {
active: boolean;
@@ -99,6 +99,13 @@ export interface Entitlements {
teamCollaboration: boolean;
/** Overnight profile warming on a leased remote host. */
cookieBot: boolean;
/**
* May open a HANDS-ON remote session. Not implied by `cookieBot` or by a
* non-zero `remoteBrowserHours`: solo funds a nightly bot out of its hours and
* may not drive a remote browser itself, so any UI offering interactive remote
* control must read this flag.
*/
remoteInteractive: boolean;
profileLimit: number;
requestsPerHour: number;
/**
@@ -110,15 +117,22 @@ export interface Entitlements {
}
/**
* What a backend older than the cookie-bot release actually sends. Read it
* through `getEntitlements()`, which fills the gap never off `CloudUser`
* directly, or a paying customer's Cookie Bot silently reads `false`.
* What a backend older than the current release actually sends. Read it through
* `getEntitlements()`, which fills the gaps never off `CloudUser` directly, or
* a paying customer's Cookie Bot silently reads `false`.
*
* `remoteInteractive` joins the optional set for the same reason `cookieBot`
* did: a backend predating the solo tier omits it, and reading the absent key as
* `false` would take interactive remote sessions away from a Pro customer whose
* only mistake was a stale cached login.
*/
export type ServerEntitlements = Omit<
Entitlements,
"cookieBot" | "remoteBrowserHours"
"cookieBot" | "remoteBrowserHours" | "remoteInteractive"
> &
Partial<Pick<Entitlements, "cookieBot" | "remoteBrowserHours">>;
Partial<
Pick<Entitlements, "cookieBot" | "remoteBrowserHours" | "remoteInteractive">
>;
export interface CloudUser {
id: string;
@@ -645,3 +659,39 @@ export interface VpnStatus {
bytes_received?: number;
last_handshake?: number;
}
/** Result of comparing a proxy's exit node against a profile's fingerprint. */
export interface ConsistencyResult {
consistent: boolean;
checked: boolean;
exit_ip: string | null;
exit_country_code: string | null;
exit_timezone: string | null;
fingerprint_timezone: string | null;
fingerprint_language: string | null;
/** Which dimensions disagree: "timezone", "language". */
mismatches: string[];
}
/** A VPN/proxy extension found in a profile, which can reroute browser traffic. */
export interface DetectedVpnExtension {
/** Acknowledgement identity: `donut:<uuid>` or `crx:<id>`. */
key: string;
name: string;
version: string | null;
/** "donut" (managed by Donut) or "browser" (installed in the profile). */
source: string;
/** "confirmed" (holds the proxy permission) or "likely". */
confidence: string;
signals: string[];
}
/** Local-only checks answered before a launch starts any worker. */
export interface PreLaunchChecks {
vpn_extensions: DetectedVpnExtension[];
scan_state: string;
consistency: ConsistencyResult;
exit_probe_pending: boolean;
exit_measurement_unreliable: boolean;
consent_token: string | null;
}