mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-08 07:53:57 +02:00
refactor: deprecate camoufox
This commit is contained in:
@@ -27,9 +27,7 @@ donutbrowser/
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
│ │ ├── downloader.rs # Browser binary downloader
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
@@ -60,9 +58,8 @@ donutbrowser/
|
||||
|
||||
Three log surfaces, in order of usefulness:
|
||||
|
||||
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
|
||||
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
|
||||
|
||||
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## Features
|
||||
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), with advanced fingerprint spoofing
|
||||
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
|
||||
@@ -419,6 +419,9 @@ impl ApiServer {
|
||||
let api = ApiDoc::openapi();
|
||||
|
||||
let v1_routes = v1_routes
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
@@ -570,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
||||
response
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit. The limit
|
||||
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
|
||||
/// this middleware is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated requests per rolling hour and
|
||||
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
|
||||
async fn rate_limit_middleware(
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
lazy_static! {
|
||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
@@ -953,10 +970,10 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
// Editing a profile's fingerprint config is a paid feature everywhere
|
||||
// (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
|
||||
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1779,7 +1796,7 @@ async fn run_profile(
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1865,7 +1882,7 @@ async fn open_url_in_profile(
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1907,7 +1924,7 @@ async fn kill_profile(
|
||||
// Programmatically launching and stopping profiles is a paid feature; the
|
||||
// run/open-url handlers gate the same way.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
|
||||
+163
-21
@@ -21,6 +21,76 @@ use crate::sync;
|
||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
|
||||
/// rate-limit chokepoints in api_server / mcp_server.
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
#[serde(rename = "browserAutomation", default)]
|
||||
pub browser_automation: bool,
|
||||
#[serde(rename = "crossOsFingerprints", default)]
|
||||
pub cross_os_fingerprints: bool,
|
||||
#[serde(rename = "cloudBackup", default)]
|
||||
pub cloud_backup: bool,
|
||||
#[serde(rename = "teamCollaboration", default)]
|
||||
pub team_collaboration: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
pub requests_per_hour: i64,
|
||||
}
|
||||
|
||||
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
||||
/// the server hasn't sent an entitlements object (older cached state / backend).
|
||||
fn derive_entitlements(
|
||||
plan: &str,
|
||||
plan_period: Option<&str>,
|
||||
subscription_status: &str,
|
||||
profile_limit: i64,
|
||||
) -> Entitlements {
|
||||
let active =
|
||||
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
|
||||
if !active {
|
||||
return Entitlements {
|
||||
active: false,
|
||||
browser_automation: false,
|
||||
cross_os_fingerprints: false,
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 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),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudUser {
|
||||
pub id: String,
|
||||
@@ -56,6 +126,26 @@ pub struct CloudUser {
|
||||
pub device_count: Option<i64>,
|
||||
#[serde(rename = "isPrimaryDevice", default)]
|
||||
pub is_primary_device: Option<bool>,
|
||||
/// Capability/limit set derived from the plan by the backend. `default` (None)
|
||||
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
|
||||
#[serde(default)]
|
||||
pub entitlements: Option<Entitlements>,
|
||||
}
|
||||
|
||||
impl CloudUser {
|
||||
/// Authoritative entitlements: the server-sent set when present, else derived
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
&self.plan,
|
||||
self.plan_period.as_deref(),
|
||||
&self.subscription_status,
|
||||
self.profile_limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -658,39 +748,83 @@ impl CloudAuthManager {
|
||||
state.is_some()
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
/// Resolve this session's entitlements (server-sent or locally derived).
|
||||
pub async fn entitlements(&self) -> Option<Entitlements> {
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
state.as_ref().map(|auth| auth.user.entitlements())
|
||||
}
|
||||
|
||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||
/// (sync token, wayfern token); per-feature access uses the capability helpers.
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
||||
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().active)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.browser_automation)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cross_os_fingerprints)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (async).
|
||||
pub async fn can_use_cloud_backup(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cloud_backup)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
|
||||
pub fn can_use_cloud_backup_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().cloud_backup)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-hour cap on automation requests (0 when automation is unavailable).
|
||||
/// Carried for the future local rate limiter; read by the inert chokepoints.
|
||||
pub async fn requests_per_hour(&self) -> i64 {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.requests_per_hour)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||
let host_os = crate::profile::types::get_host_os();
|
||||
match fingerprint_os {
|
||||
None => true,
|
||||
Some(os) if os == host_os => true,
|
||||
Some(_) => self.has_active_paid_subscription().await,
|
||||
Some(_) => self.can_use_cross_os_fingerprints().await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
|
||||
app_handle: tauri::AppHandle,
|
||||
code: String,
|
||||
) -> Result<CloudAuthState, String> {
|
||||
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
|
||||
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||
log::info!(
|
||||
@@ -1243,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
let _ = &app_handle;
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await)
|
||||
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
||||
// Always hand the frontend a resolved entitlements object so it never has to
|
||||
// derive capabilities itself (covers older cached state with no entitlements).
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
state
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
CLOUD_AUTH.fetch_profile().await
|
||||
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||
user.entitlements = Some(user.entitlements());
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+108
-27
@@ -152,11 +152,11 @@ impl McpServer {
|
||||
self.is_running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
||||
// support to diagnose without leaking secrets.
|
||||
/// Gate an MCP tool on a capability the caller already resolved (e.g.
|
||||
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
|
||||
/// with enough state for support to diagnose, without leaking secrets.
|
||||
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
|
||||
if !allowed {
|
||||
let summary = match CLOUD_AUTH.get_user().await {
|
||||
Some(state) => format!(
|
||||
"logged_in=true plan={} status={} period={:?}",
|
||||
@@ -164,10 +164,10 @@ impl McpServer {
|
||||
),
|
||||
None => "logged_in=false".to_string(),
|
||||
};
|
||||
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
|
||||
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!("{feature} requires an active paid subscription"),
|
||||
message: format!("{feature} requires a plan that includes this feature"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -286,6 +286,9 @@ impl McpServer {
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route("/health", get(Self::handle_health))
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(Self::rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
Self::auth_middleware,
|
||||
@@ -316,6 +319,17 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit, mirroring the
|
||||
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
|
||||
/// entitlements; this is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated tool calls per rolling hour
|
||||
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
|
||||
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
State(state): State<McpHttpState>,
|
||||
req: Request<Body>,
|
||||
@@ -1647,10 +1661,21 @@ impl McpServer {
|
||||
"list_profiles" => self.handle_list_profiles().await,
|
||||
"get_profile" => self.handle_get_profile(arguments).await,
|
||||
"run_profile" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_run_profile(arguments).await
|
||||
}
|
||||
"kill_profile" => self.handle_kill_profile(arguments).await,
|
||||
"kill_profile" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_kill_profile(arguments).await
|
||||
}
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
@@ -1684,7 +1709,11 @@ impl McpServer {
|
||||
// editing requires a paid plan.
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
Self::require_capability(
|
||||
"Fingerprint editing",
|
||||
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_update_profile_fingerprint(arguments).await
|
||||
}
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
@@ -1713,7 +1742,11 @@ impl McpServer {
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||
// Synchronizer tools
|
||||
"start_sync_session" => {
|
||||
Self::require_paid_subscription("Synchronizer").await?;
|
||||
Self::require_capability(
|
||||
"Synchronizer",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_start_sync_session(arguments).await
|
||||
}
|
||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||
@@ -1721,43 +1754,83 @@ impl McpServer {
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||
// Browser interaction tools (require paid subscription)
|
||||
"navigate" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_navigate(arguments).await
|
||||
}
|
||||
"screenshot" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_screenshot(arguments).await
|
||||
}
|
||||
"evaluate_javascript" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_evaluate_javascript(arguments).await
|
||||
}
|
||||
"click_element" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_element(arguments).await
|
||||
}
|
||||
"type_text" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_text(arguments).await
|
||||
}
|
||||
"get_page_content" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_content(arguments).await
|
||||
}
|
||||
"get_page_info" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
"get_interactive_elements" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_interactive_elements(arguments).await
|
||||
}
|
||||
"click_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_by_index(arguments).await
|
||||
}
|
||||
"type_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
@@ -1836,8 +1909,12 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Launching profiles programmatically is a paid feature.
|
||||
Self::require_paid_subscription("Launching a profile").await?;
|
||||
// Launching profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Launching a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
@@ -1920,8 +1997,12 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Stopping profiles programmatically is a paid feature.
|
||||
Self::require_paid_subscription("Killing a profile").await?;
|
||||
// Stopping profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Killing a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
@@ -3259,10 +3340,10 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Fingerprint editing requires an active Pro subscription".to_string(),
|
||||
message: "Fingerprint editing requires a plan that includes it".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2516,7 +2516,7 @@ pub async fn update_camoufox_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
@@ -2544,7 +2544,7 @@ pub async fn update_wayfern_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
|
||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
|
||||
}
|
||||
|
||||
fn map_browser_type(browser: &str) -> &str {
|
||||
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
|
||||
// detected for import; the mapping is kept only so the import command can
|
||||
// recognize and REJECT them. Everything else maps to Wayfern.
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
||||
"chromium" | "brave" => "wayfern",
|
||||
"camoufox" => "camoufox",
|
||||
"wayfern" => "wayfern",
|
||||
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
|
||||
_ => "wayfern",
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
profile_manager: &'static ProfileManager,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
|
||||
@@ -44,7 +43,6 @@ impl ProfileImporter {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
}
|
||||
@@ -58,12 +56,12 @@ impl ProfileImporter {
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
||||
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
|
||||
// which is deprecated — they can no longer be imported. Only Chromium-based
|
||||
// sources (mapping to Wayfern) are detected.
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
@@ -74,80 +72,6 @@ impl ProfileImporter {
|
||||
Ok(unique_profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_developer_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dev_alt_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
||||
|
||||
if firefox_dev_alt_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dev_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".mozilla/firefox-dev-edition");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -235,191 +159,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let zen_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let zen_dir = self.base_dirs.home_dir().join(".zen");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
if !profiles_dir.exists() {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
let profiles_ini = profiles_dir
|
||||
.parent()
|
||||
.unwrap_or(profiles_dir)
|
||||
.join("profiles.ini");
|
||||
if profiles_ini.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&profiles_ini) {
|
||||
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let prefs_file = path.join("prefs.js");
|
||||
if prefs_file.exists() {
|
||||
let profile_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown Profile");
|
||||
|
||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
||||
if !already_added {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} Profile - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
description: format!("Profile folder: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn parse_firefox_profiles_ini(
|
||||
&self,
|
||||
content: &str,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
let mut current_section = String::new();
|
||||
let mut profile_name = String::new();
|
||||
let mut profile_path = String::new();
|
||||
let mut is_relative = true;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
current_section = line[1..line.len() - 1].to_string();
|
||||
profile_name.clear();
|
||||
profile_path.clear();
|
||||
is_relative = true;
|
||||
} else if line.contains('=') {
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Name" => profile_name = value.to_string(),
|
||||
"Path" => profile_path = value.to_string(),
|
||||
"IsRelative" => is_relative = value == "1",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_chrome_profiles_dir(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
@@ -493,7 +232,7 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
_camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let source_path = Path::new(source_path);
|
||||
@@ -529,88 +268,9 @@ impl ProfileImporter {
|
||||
|
||||
let version = self.get_default_version_for_browser(mapped)?;
|
||||
|
||||
let final_camoufox_config = if mapped == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_default();
|
||||
|
||||
if let Some(ref proxy_id_val) = proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
||||
let proxy_url = if let (Some(username), Some(password)) =
|
||||
(&proxy_settings.username, &proxy_settings.password)
|
||||
{
|
||||
format!(
|
||||
"{}://{}:{}@{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
username,
|
||||
password,
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}://{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
};
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
if config.fingerprint.is_none() {
|
||||
let temp_profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: new_profile_name.to_string(),
|
||||
browser: mapped.to_string(),
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.proxy = None;
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Camoufox import is removed; only Wayfern profiles are imported now, so the
|
||||
// imported profile never carries a Camoufox config.
|
||||
let final_camoufox_config: Option<CamoufoxConfig> = None;
|
||||
|
||||
let final_wayfern_config = if mapped == "wayfern" {
|
||||
let mut config = wayfern_config.unwrap_or_default();
|
||||
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), String> {
|
||||
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
|
||||
// no longer be imported. Reject them before doing any work.
|
||||
if map_browser_type(&browser_type) == "camoufox" {
|
||||
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
|
||||
}
|
||||
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.os.as_deref())
|
||||
@@ -897,24 +563,6 @@ mod tests {
|
||||
let _profiles = result.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_firefox_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
||||
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent directory gracefully"
|
||||
);
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for nonexistent directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
@@ -933,51 +581,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_empty() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
let empty_content = "";
|
||||
let profiles_dir = Path::new("/tmp");
|
||||
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should handle empty profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for empty content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_valid() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let profiles_dir = temp_dir.path().join("profiles");
|
||||
let profile_dir = profiles_dir.join("test.profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
let prefs_file = profile_dir.join("prefs.js");
|
||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
||||
|
||||
let profiles_ini_content = r#"
|
||||
[Profile0]
|
||||
Name=Test Profile
|
||||
IsRelative=1
|
||||
Path=test.profile
|
||||
"#;
|
||||
|
||||
let result =
|
||||
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should parse valid profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
||||
assert_eq!(profiles[0].browser, "firefox");
|
||||
assert_eq!(profiles[0].mapped_browser, "camoufox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_directory_recursive() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
|
||||
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
|
||||
// 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"
|
||||
// — 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;
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
|
||||
+4
-4
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
@@ -59,6 +60,7 @@ 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 { getEntitlements } from "@/lib/entitlements";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
@@ -225,10 +227,7 @@ export default function Home() {
|
||||
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked =
|
||||
cloudUser?.plan !== "free" &&
|
||||
(cloudUser?.subscriptionStatus === "active" ||
|
||||
cloudUser?.planPeriod === "lifetime");
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
@@ -1527,6 +1526,7 @@ export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||
<CloseConfirmDialog />
|
||||
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
@@ -298,7 +299,7 @@ export function AccountPage({
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
@@ -306,7 +307,7 @@ export function AccountPage({
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxDeprecationDialogProps {
|
||||
profiles: BrowserProfile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns users who still have Camoufox profiles that Camoufox support is ending.
|
||||
* Shown once per app session (this component mounts for the app lifetime), only
|
||||
* when at least one Camoufox profile exists. Not a toast — a blocking dialog so
|
||||
* the deprecation can't be missed.
|
||||
*/
|
||||
export function CamoufoxDeprecationDialog({
|
||||
profiles,
|
||||
}: CamoufoxDeprecationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) return;
|
||||
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
|
||||
if (hasCamoufox) {
|
||||
setIsOpen(true);
|
||||
setShown(true);
|
||||
}
|
||||
}, [profiles, shown]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("camoufoxDeprecation.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("camoufoxDeprecation.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void openUrl(
|
||||
"https://github.com/zhom/donutbrowser/discussions/426",
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.learnMore")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("camoufoxDeprecation.acknowledge")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
|
||||
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
CamoufoxConfig,
|
||||
CamoufoxOS,
|
||||
WayfernConfig,
|
||||
WayfernOS,
|
||||
} from "@/types";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
const getCurrentOS = (): CamoufoxOS => {
|
||||
const getCurrentOS = (): WayfernOS => {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
if (platform.includes("win")) return "windows";
|
||||
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
@@ -105,10 +96,6 @@ interface BrowserOption {
|
||||
}
|
||||
|
||||
const browserOptions: BrowserOption[] = [
|
||||
{
|
||||
value: "camoufox",
|
||||
label: "Camoufox",
|
||||
},
|
||||
{
|
||||
value: "wayfern",
|
||||
label: "Wayfern",
|
||||
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
|
||||
const proxyListboxIdAntiDetect = useId();
|
||||
const proxyListboxIdRegular = useId();
|
||||
const [profileName, setProfileName] = useState("");
|
||||
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
|
||||
// opens straight into the Wayfern config step (no browser-selection screen).
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
"browser-selection" | "browser-config"
|
||||
>("browser-selection");
|
||||
>("browser-config");
|
||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||
|
||||
// Browser selection states
|
||||
// Browser selection states. Defaults to Wayfern — the only creatable browser.
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>(null);
|
||||
useState<BrowserTypeString>("wayfern");
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||
const [launchHook, setLaunchHook] = useState("");
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
geoip: true, // Default to automatic geoip
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Wayfern anti-detect states
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
||||
os: getCurrentOS() as WayfernOS, // Default to current OS
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Handle browser selection from the initial screen
|
||||
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
|
||||
setCurrentStep("browser-config");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
// Reset the form fields without leaving the Wayfern config step — Camoufox is
|
||||
// deprecated, so there is no browser-selection screen to go back to.
|
||||
const resetForm = () => {
|
||||
setSelectedBrowser("wayfern");
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
// Load downloaded versions for both anti-detect browsers up front so the
|
||||
// selection-screen availability gate is accurate before either is picked.
|
||||
// Load downloaded Wayfern versions up front so the availability gate is
|
||||
// accurate. Camoufox is deprecated and no longer creatable.
|
||||
void loadDownloadedVersions("wayfern");
|
||||
void loadDownloadedVersions("camoufox");
|
||||
// Load release types when a browser is selected
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
// Check and download GeoIP database if needed for Camoufox or Wayfern
|
||||
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
|
||||
// Wayfern needs the GeoIP database for fingerprint generation.
|
||||
if (selectedBrowser === "wayfern") {
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
}
|
||||
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
|
||||
: undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
if (selectedBrowser === "wayfern") {
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
const bestCamoufoxVersion = getCreatableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Regular browser
|
||||
if (!selectedBrowser) {
|
||||
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
|
||||
// Cancel any ongoing loading
|
||||
loadingBrowserRef.current = null;
|
||||
|
||||
// Reset all states
|
||||
// Reset all states. Stay on the Wayfern config step — Camoufox is
|
||||
// deprecated, so the browser-selection screen is gone.
|
||||
setProfileName("");
|
||||
setCurrentStep("browser-selection");
|
||||
setCurrentStep("browser-config");
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedBrowser("wayfern");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
setReleaseTypes({});
|
||||
setIsLoadingReleaseTypes(false);
|
||||
setReleaseTypesError(null);
|
||||
setCamoufoxConfig({
|
||||
geoip: true, // Reset to automatic geoip
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setWayfernConfig({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
setEnablePassword(false);
|
||||
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleBrowserSelect("camoufox");
|
||||
}}
|
||||
disabled={!getCreatableVersion("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
{isBrowserCurrentlyDownloading("camoufox") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("createProfile.downloadingSubtitle")
|
||||
: t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Camoufox is deprecated — no longer offered for new
|
||||
profiles. Only Wayfern can be created. */}
|
||||
|
||||
{!getCreatableVersion("wayfern") &&
|
||||
!getCreatableVersion("camoufox") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
{!getCreatableVersion("wayfern") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -996,162 +908,9 @@ export function CreateProfileDialog({
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
<RippleButton
|
||||
onClick={() =>
|
||||
selectedBrowser &&
|
||||
loadReleaseTypes(selectedBrowser)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!getCreatableVersion("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getCreatableVersion("camoufox")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
{
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossOsUnlocked && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertDescription className="text-sm">
|
||||
{t("createProfile.camoufoxWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getCreatableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
||||
// Regular Browser Configuration (should not happen in
|
||||
// the anti-detect tab; Camoufox creation is removed).
|
||||
<div className="space-y-4">
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
@@ -34,9 +33,10 @@ import {
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
||||
import type { DetectedProfile, WayfernConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
||||
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
|
||||
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
||||
"select",
|
||||
);
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
||||
|
||||
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
|
||||
useBrowserSupport();
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
const importableBrowsers = supportedBrowsers;
|
||||
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
|
||||
// imported (the backend rejects them); only offer Chromium-family sources.
|
||||
const importableBrowsers = supportedBrowsers.filter(
|
||||
(browser) => getMappedBrowser(browser) === "wayfern",
|
||||
);
|
||||
|
||||
const loadDetectedProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
|
||||
|
||||
const mappedBrowser =
|
||||
importMode === "auto-detect" && selectedProfile
|
||||
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
|
||||
? getMappedBrowser(selectedProfile.mapped_browser)
|
||||
: getMappedBrowser(browserType);
|
||||
|
||||
setIsImporting(true);
|
||||
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
|
||||
browserType,
|
||||
newProfileName,
|
||||
proxyId: selectedProxyId ?? null,
|
||||
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
|
||||
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
|
||||
camoufoxConfig: null,
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
if (parseBackendError(error)) {
|
||||
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
|
||||
toast.error(translateBackendError(t, error));
|
||||
} else if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
selectedProxyId,
|
||||
camoufoxConfig,
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep("select");
|
||||
setCamoufoxConfig({});
|
||||
setWayfernConfig({});
|
||||
setSelectedProxyId(undefined);
|
||||
setSelectedDetectedProfile(null);
|
||||
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
|
||||
|
||||
const currentMappedBrowser = useMemo(() => {
|
||||
if (importMode === "auto-detect" && selectedProfile) {
|
||||
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(selectedProfile.mapped_browser);
|
||||
}
|
||||
if (importMode === "manual" && manualBrowserType) {
|
||||
return manualBrowserType as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(manualBrowserType);
|
||||
}
|
||||
return null;
|
||||
}, [importMode, selectedProfile, manualBrowserType]);
|
||||
@@ -577,27 +582,17 @@ export function ImportProfileDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentMappedBrowser === "camoufox" ? (
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
|
||||
import is deprecated and blocked). */}
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||
import { isSyncEnabled } from "@/types";
|
||||
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
|
||||
}: ProfileSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const isCloudSyncEligible =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime");
|
||||
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
||||
// Encryption available to everyone except team members who aren't owners
|
||||
const canUseEncryption =
|
||||
cloudUser == null ||
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
|
||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.",
|
||||
"camoufoxImportDeprecated": "Importing Firefox-based (Camoufox) profiles is no longer supported. Camoufox is being deprecated — please use Wayfern instead."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "Browser support ending soon",
|
||||
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
|
||||
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern profiles."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Browser automation paused",
|
||||
"description": "Your account was temporarily restricted from Pro browser features, usually from signing in on multiple devices at once. Sign out of other devices, then relaunch the profile to restore it."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox support is ending",
|
||||
"description": "Support for Camoufox profiles is ending on July 8, 2026. You have one or more Camoufox profiles. Please migrate them to Wayfern before then — after that date, Camoufox profiles may stop working.",
|
||||
"acknowledge": "Got it"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
|
||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.",
|
||||
"camoufoxImportDeprecated": "La importación de perfiles basados en Firefox (Camoufox) ya no es compatible. Camoufox está en desuso; usa Wayfern en su lugar."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "El soporte del navegador finalizará pronto",
|
||||
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
|
||||
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Automatización del navegador en pausa",
|
||||
"description": "Tu cuenta fue restringida temporalmente de las funciones Pro del navegador, normalmente por iniciar sesión en varios dispositivos a la vez. Cierra sesión en los demás dispositivos y vuelve a iniciar el perfil para restaurarla."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "El soporte para Camoufox está terminando",
|
||||
"description": "El soporte para los perfiles de Camoufox terminará el 8 de julio de 2026. Tienes uno o más perfiles de Camoufox. Migra a Wayfern antes de esa fecha; después, los perfiles de Camoufox podrían dejar de funcionar.",
|
||||
"acknowledge": "Entendido"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Afficher ou modifier l'empreinte nécessite un forfait payant actif. La protection est incluse dans tous les forfaits.",
|
||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"camoufoxImportDeprecated": "L'importation de profils basés sur Firefox (Camoufox) n'est plus prise en charge. Camoufox est en cours d'abandon — utilisez Wayfern à la place."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
|
||||
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
|
||||
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Automatisation du navigateur en pause",
|
||||
"description": "Votre compte a été temporairement privé des fonctionnalités Pro du navigateur, généralement à cause d'une connexion sur plusieurs appareils à la fois. Déconnectez-vous des autres appareils, puis relancez le profil pour la rétablir."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "La prise en charge de Camoufox prend fin",
|
||||
"description": "La prise en charge des profils Camoufox prendra fin le 8 juillet 2026. Vous avez un ou plusieurs profils Camoufox. Migrez-les vers Wayfern avant cette date — après quoi, les profils Camoufox pourraient cesser de fonctionner.",
|
||||
"acknowledge": "Compris"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
||||
"camoufoxImportDeprecated": "Firefox ベース(Camoufox)のプロファイルのインポートはサポートされなくなりました。Camoufox は廃止予定です。代わりに Wayfern をご利用ください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
|
||||
"endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
|
||||
"endingSoonDescription": "以下のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern プロファイルに移行してください。"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "ブラウザの自動化が一時停止しました",
|
||||
"description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。"
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox のサポートが終了します",
|
||||
"description": "Camoufox プロファイルのサポートは 2026 年 7 月 8 日に終了します。Camoufox プロファイルが 1 つ以上あります。それまでに Wayfern へ移行してください。その後、Camoufox プロファイルは動作しなくなる可能性があります。",
|
||||
"acknowledge": "了解しました"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "핑거프린트를 보거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"camoufoxImportDeprecated": "Firefox 기반(Camoufox) 프로필 가져오기는 더 이상 지원되지 않습니다. Camoufox는 지원 종료 예정입니다. 대신 Wayfern을 사용하세요."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
|
||||
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
|
||||
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 프로필로 이전하세요."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "브라우저 자동화가 일시 중지됨",
|
||||
"description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox 지원이 종료됩니다",
|
||||
"description": "Camoufox 프로필 지원이 2026년 7월 8일에 종료됩니다. Camoufox 프로필이 하나 이상 있습니다. 그 전에 Wayfern으로 이전하세요. 이후에는 Camoufox 프로필이 작동하지 않을 수 있습니다.",
|
||||
"acknowledge": "확인"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Visualizar ou editar a impressão digital requer um plano pago ativo. A proteção está incluída em todos os planos.",
|
||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
||||
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.",
|
||||
"camoufoxImportDeprecated": "A importação de perfis baseados em Firefox (Camoufox) não é mais suportada. O Camoufox está sendo descontinuado — use o Wayfern."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "O suporte ao navegador terminará em breve",
|
||||
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
|
||||
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Automação do navegador pausada",
|
||||
"description": "Sua conta foi temporariamente restringida dos recursos Pro do navegador, geralmente por entrar em vários dispositivos ao mesmo tempo. Saia dos outros dispositivos e reinicie o perfil para restaurá-la."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "O suporte ao Camoufox está terminando",
|
||||
"description": "O suporte aos perfis do Camoufox terminará em 8 de julho de 2026. Você tem um ou mais perfis do Camoufox. Migre-os para o Wayfern antes dessa data — depois disso, os perfis do Camoufox podem parar de funcionar.",
|
||||
"acknowledge": "Entendi"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
||||
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox (Camoufox) больше не поддерживается. Camoufox выводится из эксплуатации — используйте Wayfern."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "Поддержка браузера скоро завершится",
|
||||
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
|
||||
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 года: {{profiles}}. Перейдите на профили Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Автоматизация браузера приостановлена",
|
||||
"description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Поддержка Camoufox прекращается",
|
||||
"description": "Поддержка профилей Camoufox прекращается 8 июля 2026 года. У вас есть один или несколько профилей Camoufox. Перенесите их на Wayfern до этой даты — после неё профили Camoufox могут перестать работать.",
|
||||
"acknowledge": "Понятно"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "Xem hoặc chỉnh sửa vân tay yêu cầu gói trả phí đang hoạt động. Tính năng bảo vệ được bao gồm trong mọi gói.",
|
||||
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"proxyPaymentRequired": "Proxy đã chọn yêu cầu thanh toán (402) — gói đăng ký của nó có thể đã hết hạn — nên profile chưa được tạo.",
|
||||
"vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo."
|
||||
"vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
"camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox (Camoufox). Camoufox đang bị ngừng hỗ trợ — vui lòng dùng Wayfern."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "Hỗ trợ trình duyệt sắp kết thúc",
|
||||
"endingSoonDescription": "Hỗ trợ cho các profile sau sẽ bị gỡ bỏ vào ngày 15 tháng 3 năm 2026: {{profiles}}. Vui lòng chuyển sang profile Wayfern hoặc Camoufox."
|
||||
"endingSoonDescription": "Hỗ trợ cho các profile sau sẽ bị gỡ bỏ vào ngày 15 tháng 3 năm 2026: {{profiles}}. Vui lòng chuyển sang profile Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "Tự động hóa trình duyệt đã tạm dừng",
|
||||
"description": "Tài khoản của bạn tạm thời bị hạn chế các tính năng Pro của trình duyệt, thường do đăng nhập trên nhiều thiết bị cùng lúc. Hãy đăng xuất khỏi các thiết bị khác rồi khởi chạy lại profile để khôi phục."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Hỗ trợ Camoufox sắp kết thúc",
|
||||
"description": "Hỗ trợ cho các profile Camoufox sẽ kết thúc vào ngày 8 tháng 7 năm 2026. Bạn có một hoặc nhiều profile Camoufox. Hãy chuyển chúng sang Wayfern trước thời điểm đó — sau ngày này, các profile Camoufox có thể ngừng hoạt động.",
|
||||
"acknowledge": "Đã hiểu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1832,7 +1832,8 @@
|
||||
"fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
||||
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的 (Camoufox) 配置文件。Camoufox 即将停用——请改用 Wayfern。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -1961,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "浏览器支持即将结束",
|
||||
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
|
||||
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 配置文件。"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2043,5 +2044,10 @@
|
||||
"wayfernBlocked": {
|
||||
"title": "浏览器自动化已暂停",
|
||||
"description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。"
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox 支持即将结束",
|
||||
"description": "Camoufox 配置文件的支持将于 2026 年 7 月 8 日结束。您有一个或多个 Camoufox 配置文件。请在此之前迁移到 Wayfern——之后 Camoufox 配置文件可能会停止工作。",
|
||||
"acknowledge": "知道了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type BackendErrorCode =
|
||||
| "PROXY_NOT_WORKING"
|
||||
| "PROXY_PAYMENT_REQUIRED"
|
||||
| "VPN_NOT_WORKING"
|
||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -132,6 +133,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.proxyPaymentRequired");
|
||||
case "VPN_NOT_WORKING":
|
||||
return t("backendErrors.vpnNotWorking");
|
||||
case "CAMOUFOX_IMPORT_DEPRECATED":
|
||||
return t("backendErrors.camoufoxImportDeprecated");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CloudUser, Entitlements } from "@/types";
|
||||
|
||||
const DEFAULT_REQUESTS_PER_HOUR = 100;
|
||||
|
||||
interface Capabilities {
|
||||
browserAutomation: boolean;
|
||||
crossOsFingerprints: boolean;
|
||||
cloudBackup: boolean;
|
||||
teamCollaboration: boolean;
|
||||
}
|
||||
|
||||
const NONE: Entitlements = {
|
||||
active: false,
|
||||
browserAutomation: false,
|
||||
crossOsFingerprints: false,
|
||||
cloudBackup: false,
|
||||
teamCollaboration: false,
|
||||
profileLimit: 0,
|
||||
requestsPerHour: 0,
|
||||
};
|
||||
|
||||
// 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: {
|
||||
browserAutomation: false,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
},
|
||||
pro: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
},
|
||||
team: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
},
|
||||
enterprise: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Unknown paid plan -> pro-level (never team), matching the backend default.
|
||||
const DEFAULT_PAID: Capabilities = {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's effective entitlements. Prefers the backend-resolved object the
|
||||
* desktop attaches to CloudUser; only falls back to deriving from the plan
|
||||
* fields when it's missing (older cached state). The fallback mirrors the
|
||||
* backend matrix in `apps/backend/src/plans/entitlements.ts`.
|
||||
*/
|
||||
export function getEntitlements(
|
||||
user: CloudUser | null | undefined,
|
||||
): Entitlements {
|
||||
if (user?.entitlements) return user.entitlements;
|
||||
if (!user) return NONE;
|
||||
|
||||
const active =
|
||||
user.plan !== "free" &&
|
||||
(user.subscriptionStatus === "active" || user.planPeriod === "lifetime");
|
||||
if (!active) return NONE;
|
||||
|
||||
const caps = PLAN_CAPABILITIES[user.plan] ?? DEFAULT_PAID;
|
||||
return {
|
||||
active: true,
|
||||
browserAutomation: caps.browserAutomation,
|
||||
crossOsFingerprints: caps.crossOsFingerprints,
|
||||
cloudBackup: caps.cloudBackup,
|
||||
teamCollaboration: caps.teamCollaboration,
|
||||
profileLimit: user.profileLimit,
|
||||
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,24 @@ export interface SyncSettings {
|
||||
sync_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface Entitlements {
|
||||
active: boolean;
|
||||
browserAutomation: boolean;
|
||||
crossOsFingerprints: boolean;
|
||||
cloudBackup: boolean;
|
||||
teamCollaboration: boolean;
|
||||
profileLimit: number;
|
||||
requestsPerHour: number;
|
||||
}
|
||||
|
||||
export interface CloudUser {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -95,6 +113,9 @@ export interface CloudUser {
|
||||
deviceOrdinal?: number | null;
|
||||
deviceCount?: number | null;
|
||||
isPrimaryDevice?: boolean | null;
|
||||
// Plan-derived capabilities. The desktop resolves this before handing CloudUser
|
||||
// to the UI; optional to stay safe on older cached state.
|
||||
entitlements?: Entitlements;
|
||||
}
|
||||
|
||||
export interface ProfileLockInfo {
|
||||
|
||||
Reference in New Issue
Block a user