diff --git a/.github/workflows/publish-repos.yml b/.github/workflows/publish-repos.yml index 8451e94..8af4e1c 100644 --- a/.github/workflows/publish-repos.yml +++ b/.github/workflows/publish-repos.yml @@ -59,4 +59,19 @@ jobs: R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}" + run: | + # GitHub injects secrets verbatim. If a value was pasted with + # surrounding quotes or a trailing newline — the local .env wraps all + # four R2_* values in double quotes — it reaches the script malformed: + # e.g. an endpoint of https://"host" yields + # `Could not connect to the endpoint URL`, and a quoted key yields + # `Unauthorized`. The local run is unaffected because publish-repo.sh + # sources .env through bash, which strips the quotes; CI has no .env, + # so strip here. No-op when the secrets are already clean. The script + # itself is intentionally left untouched. + strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; } + export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")" + export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")" + export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")" + export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")" + bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5352daa..923c755 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,3 +22,6 @@ jobs: stale-pr-label: "stale" days-before-stale: 30 days-before-close: 7 + # Never let the maintainer's own assigned issues go stale or get + # closed, regardless of inactivity. + exempt-issue-assignees: "zhom" diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index e23088b..2b76548 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { type CanActivate, type ExecutionContext, @@ -10,6 +11,13 @@ import type { Request } from "express"; import * as jwt from "jsonwebtoken"; import type { UserContext } from "./user-context.interface.js"; +/** Constant-time string compare; false on length mismatch (no early return). */ +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + @Injectable() export class AuthGuard implements CanActivate { private readonly logger = new Logger(AuthGuard.name); @@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate { // Try SYNC_TOKEN first (self-hosted mode) const expectedToken = this.configService.get("SYNC_TOKEN"); - if (expectedToken && token === expectedToken) { + if (expectedToken && safeEqual(token, expectedToken)) { (request as unknown as Record).user = { mode: "self-hosted", prefix: "", @@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate { algorithms: ["RS256"], }) as jwt.JwtPayload; + // Validate the scope claims' SHAPE before trusting them as S3 key + // prefixes. An empty/over-broad prefix would make validateKeyAccess + // (`key.startsWith(prefix)`) authorize the entire bucket, so a signer + // bug or permissive claim must not silently widen scope. + const prefix = decoded.prefix || `users/${decoded.sub}/`; + if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) { + throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`); + } + const teamPrefix = + decoded.teamPrefix === undefined || decoded.teamPrefix === null + ? null + : decoded.teamPrefix; + if ( + teamPrefix !== null && + !/^teams\/[^/]+\/$/.test(String(teamPrefix)) + ) { + throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`); + } + (request as unknown as Record).user = { mode: "cloud", - prefix: decoded.prefix || `users/${decoded.sub}/`, - teamPrefix: decoded.teamPrefix || null, + prefix, + teamPrefix, profileLimit: decoded.profileLimit || 0, teamProfileLimit: decoded.teamProfileLimit || 0, } satisfies UserContext; diff --git a/donut-sync/src/sync/internal.controller.ts b/donut-sync/src/sync/internal.controller.ts index d5f71f2..df5a558 100644 --- a/donut-sync/src/sync/internal.controller.ts +++ b/donut-sync/src/sync/internal.controller.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { Body, Controller, @@ -9,6 +10,13 @@ import { import { ConfigService } from "@nestjs/config"; import { SyncService } from "./sync.service.js"; +/** Constant-time string compare; false on length mismatch. */ +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + @Controller("v1/internal") export class InternalController { private readonly internalKey: string | undefined; @@ -26,7 +34,7 @@ export class InternalController { @Headers("x-internal-key") key: string, @Body() body: { userId: string; maxProfiles: number }, ) { - if (!this.internalKey || key !== this.internalKey) { + if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) { throw new UnauthorizedException("Invalid internal key"); } diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 97e95e2..f0677b0 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -54,6 +54,29 @@ import type { */ const MANIFEST_KEY = ".donut-sync-manifest"; +/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that + * outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */ +const MAX_PRESIGN_EXPIRES_IN = 3600; + +/** Clamp a client-supplied expiresIn to a sane positive range. */ +function clampExpiresIn(requested: number | undefined): number { + const v = typeof requested === "number" && requested > 0 ? requested : 3600; + return Math.min(v, MAX_PRESIGN_EXPIRES_IN); +} + +/** Only this metadata key is meaningful to sync (LWW conflict resolution). + * Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */ +function sanitizeMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) return undefined; + const out: Record = {}; + if (typeof metadata["updated-at"] === "string") { + out["updated-at"] = metadata["updated-at"]; + } + return Object.keys(out).length > 0 ? out : undefined; +} + @Injectable() export class SyncService implements OnModuleInit { private readonly logger = new Logger(SyncService.name); @@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit { await this.checkProfileLimit(ctx); } - const expiresIn = dto.expiresIn || 3600; + const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); + // Whitelist metadata to the single key sync relies on, so a client can't + // sign arbitrary x-amz-meta-* values into its objects. + const metadata = sanitizeMetadata(dto.metadata); const command = new PutCmd({ Bucket: this.bucket, Key: key, ContentType: dto.contentType || "application/octet-stream", // Signed into the presigned URL as `x-amz-meta-*`. The client must send // exactly these headers on the PUT, so we echo them in the response. - Metadata: dto.metadata, + Metadata: metadata, }); const url = await getSignedUrl(this.s3Client, command, { expiresIn }); @@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit { return { url, expiresAt: expiresAt.toISOString(), + // Echo the metadata we actually signed so the client sends matching + // x-amz-meta-* headers on the PUT (S3 rejects unsigned ones). + metadata, }; } @@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit { const key = this.scopeKey(ctx, dto.key); this.validateKeyAccess(ctx, key); - const expiresIn = dto.expiresIn || 3600; + const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); const command = new GetObjectCommand({ @@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit { await this.checkProfileLimit(ctx); } - const expiresIn = dto.expiresIn || 3600; + const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); const items = await Promise.all( @@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit { dto: PresignDownloadBatchRequestDto, ctx: UserContext, ): Promise { - const expiresIn = dto.expiresIn || 3600; + const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); const items = await Promise.all( diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7516bf4..b37f9b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1847,6 +1847,7 @@ dependencies = [ "sha2 0.11.0", "shadowsocks", "smoltcp", + "subtle", "sys-locale", "sysinfo", "tar", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2948d9f..1f1d706 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -81,6 +81,7 @@ aes-gcm = "0.10" aes = "0.9" cbc = "0.2" ring = "0.17" +subtle = "2" sha2 = "0.11" shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] } hyper = { version = "1.10", features = ["full"] } diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 5e98c2b..9621d20 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -58,13 +58,25 @@ pub struct ApiProfileResponse { pub struct CreateProfileRequest { pub name: String, pub browser: String, - pub version: String, + /// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded + /// version of the chosen browser. A concrete version must already be + /// downloaded; the create path does not fetch new versions. + #[serde(default)] + pub version: Option, pub proxy_id: Option, pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, + /// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`. + /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint + /// generated automatically at creation. Provide a `fingerprint` field to + /// pin a specific one. #[schema(value_type = Object)] pub camoufox_config: Option, + /// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`. + /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint + /// generated automatically at creation. Provide a `fingerprint` field to + /// pin a specific one. #[schema(value_type = Object)] pub wayfern_config: Option, pub group_id: Option, @@ -74,7 +86,9 @@ pub struct CreateProfileRequest { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateProfileRequest { pub name: Option, - pub browser: Option, + // No `browser` field: a profile's engine is fixed at creation (changing it + // would invalidate the generated fingerprint and on-disk profile dir). + // Accepting it here only to silently ignore it misled API clients. pub version: Option, pub proxy_id: Option, pub vpn_id: Option, @@ -508,8 +522,14 @@ async fn auth_middleware( } }; - // Compare tokens - if token != stored_token { + // Constant-time comparison so the auth check doesn't leak the shared-prefix + // length via timing. `ConstantTimeEq` on equal-length byte slices; differing + // lengths simply compare unequal. + use subtle::ConstantTimeEq; + let token_bytes = token.as_bytes(); + let stored_bytes = stored_token.as_bytes(); + let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into(); + if !matches { log::warn!("[api] Rejected {path}: token mismatch"); return Err(StatusCode::UNAUTHORIZED); } @@ -694,14 +714,24 @@ async fn get_profile( } } +/// Create a profile. +/// +/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected +/// with 400. +/// - `version` is optional: omit it or pass `"latest"` to use the newest +/// already-downloaded version of that browser. The version must be present +/// locally (this endpoint does not download new versions); 400 if none is. +/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an +/// empty object `{}`, generates a fresh fingerprint automatically. #[utoipa::path( post, path = "/v1/profiles", request_body = CreateProfileRequest, responses( (status = 200, description = "Profile created successfully", body = ApiProfileResponse), - (status = 400, description = "Bad request"), + (status = 400, description = "Invalid browser, or no downloaded version available"), (status = 401, description = "Unauthorized"), + (status = 402, description = "Selected proxy requires payment"), (status = 500, description = "Internal server error") ), security( @@ -715,6 +745,34 @@ async fn create_profile( ) -> Result, StatusCode> { let profile_manager = ProfileManager::instance(); + // Only Wayfern and Camoufox profiles are launchable; the rest of the system + // (fingerprint generation, launch, run) supports nothing else. Reject anything + // else up front — otherwise the profile is created with no fingerprint and an + // unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP + // create_profile validation. + if request.browser != "wayfern" && request.browser != "camoufox" { + return Err(StatusCode::BAD_REQUEST); + } + + // Resolve the version. Omitted, empty, or "latest" means "newest version + // already downloaded for this browser". The create path generates the + // fingerprint by launching that binary, so the version must be present + // locally — we don't fetch new versions here. 400 if none is downloaded. + let version = match request.version.as_deref() { + Some(v) if !v.is_empty() && v != "latest" => v.to_string(), + _ => { + let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + let mut versions = registry.get_downloaded_versions(&request.browser); + // browsers is a HashMap, so keys are unordered — sort newest-first by + // semver before taking the latest. + versions.sort_by(|a, b| crate::api_client::compare_versions(b, a)); + match versions.into_iter().next() { + Some(v) => v, + None => return Err(StatusCode::BAD_REQUEST), + } + } + }; + // Parse camoufox config if provided let camoufox_config = if let Some(config) = &request.camoufox_config { serde_json::from_value(config.clone()).ok() @@ -747,7 +805,7 @@ async fn create_profile( &state.app_handle, &request.name, &request.browser, - &request.version, + &version, request.release_type.as_deref().unwrap_or("stable"), request.proxy_id.clone(), request.vpn_id.clone(), @@ -2090,3 +2148,57 @@ async fn refresh_wayfern_token( let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; Ok(Json(WayfernTokenResponse { token })) } + +#[cfg(test)] +mod tests { + use super::*; + + // Removing `browser` from UpdateProfileRequest, and rejecting invalid + // `browser` values on create, must NOT make the API reject requests that + // carry extra/unknown fields — old clients still send them. serde ignores + // unknown fields by default; these tests lock that in so a future + // `#[serde(deny_unknown_fields)]` can't silently break compatibility. + #[test] + fn update_profile_request_ignores_unknown_fields() { + // `browser` is no longer a field, plus a wholly unknown field. Both must + // be accepted and ignored, not rejected. + let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#; + let parsed: UpdateProfileRequest = + serde_json::from_str(json).expect("unknown fields must be ignored, not rejected"); + assert_eq!(parsed.name.as_deref(), Some("p")); + } + + #[test] + fn create_profile_request_ignores_unknown_fields() { + let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#; + let parsed: CreateProfileRequest = + serde_json::from_str(json).expect("unknown fields must be ignored, not rejected"); + assert_eq!(parsed.browser, "wayfern"); + } + + #[test] + fn create_profile_request_allows_omitting_version_and_configs() { + // Minimal body: no version, no wayfern_config/camoufox_config. Must + // deserialize (version resolves to latest-downloaded at the handler; an + // absent config triggers fresh-fingerprint generation). + let json = r#"{"name": "p", "browser": "wayfern"}"#; + let parsed: CreateProfileRequest = + serde_json::from_str(json).expect("version and configs are optional"); + assert_eq!(parsed.browser, "wayfern"); + assert!(parsed.version.is_none()); + assert!(parsed.wayfern_config.is_none()); + assert!(parsed.camoufox_config.is_none()); + } + + #[test] + fn create_profile_browser_validation_matches_supported_engines() { + // The handler rejects anything that isn't a launchable engine; this is the + // same predicate it uses, kept in lockstep with MCP's create_profile. + let is_valid = |b: &str| b == "wayfern" || b == "camoufox"; + assert!(is_valid("wayfern")); + assert!(is_valid("camoufox")); + assert!(!is_valid("chromium")); + assert!(!is_valid("firefox")); + assert!(!is_valid("")); + } +} diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index ae16e5a..bebeb19 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1016,7 +1016,7 @@ impl CloudAuthManager { return Ok(()); } - let token = self + let result = self .api_call_with_retry(|access_token| { let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start"); // Bound the request: without a timeout, an unreachable @@ -1050,7 +1050,31 @@ impl CloudAuthManager { Ok(result.token) } }) - .await?; + .await; + + let token = match result { + Ok(token) => token, + Err(e) => { + // The backend returns 403 (ForbiddenException) for paid-feature blocks: + // token-reuse throttle, "active subscription required", and the + // primary-device restriction (see donutbrowser-infra wayfern.service.ts). + // This is distinct from a 401 (dead access token) — the session is still + // valid, the user is just temporarily/conditionally not entitled. So we + // do NOT invalidate the session. Instead: drop the stale wayfern token so + // no browser launches half-authenticated, re-fetch the profile so the + // cached plan reflects the backend's real state (it may have changed), + // and signal the UI so the user learns why automation stopped working. + if e.contains("(403") || e.contains("Forbidden") { + log::warn!("Wayfern token blocked by backend (403): {e}"); + self.clear_wayfern_token().await; + if let Err(fetch_err) = self.fetch_profile().await { + log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}"); + } + let _ = crate::events::emit_empty("wayfern-paid-blocked"); + } + return Err(e); + } + }; let mut wt = self.wayfern_token.lock().await; *wt = Some(token); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 4639961..3eafa4d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -339,8 +339,16 @@ impl McpServer { .and_then(|h| h.to_str().ok()) .and_then(|h| h.strip_prefix("Bearer ")); - let valid = - path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str()); + // Constant-time comparison to avoid leaking the token prefix via timing. + use subtle::ConstantTimeEq; + let expected = state.token.as_bytes(); + let ct_eq = |t: Option<&str>| { + t.is_some_and(|t| { + let b = t.as_bytes(); + b.len() == expected.len() && b.ct_eq(expected).into() + }) + }; + let valid = ct_eq(path_token) || ct_eq(header_token); if !valid { return Err(StatusCode::UNAUTHORIZED); @@ -1671,11 +1679,10 @@ impl McpServer { "connect_vpn" => self.handle_connect_vpn(arguments).await, "disconnect_vpn" => self.handle_disconnect_vpn(arguments).await, "get_vpn_status" => self.handle_get_vpn_status(arguments).await, - // Fingerprint management — viewing and editing both require a paid plan. - "get_profile_fingerprint" => { - Self::require_paid_subscription("Fingerprint").await?; - self.handle_get_profile_fingerprint(arguments).await - } + // Fingerprint management — viewing is free everywhere (matches the REST + // API and the get_profile tool, which already expose the config); only + // 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.handle_update_profile_fingerprint(arguments).await @@ -2592,6 +2599,15 @@ impl McpServer { message: "Missing proxy_type".to_string(), })?; + // The tool schema declares an enum, but JSON-Schema enums are advisory only; + // enforce it here so a bad value can't produce a non-functional proxy. + if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") { + return Err(McpError { + code: -32602, + message: "proxy_type must be one of: http, https, socks4, socks5".to_string(), + }); + } + let host = arguments .get("host") .and_then(|v| v.as_str()) diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index cd75da2..5517419 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -3,6 +3,38 @@ use crate::profile::BrowserProfile; use std::path::Path; use std::process::Command; +/// True if a process command line refers to `profile_path` as a real browser +/// profile/data-dir argument, NOT merely a substring. A bare `contains` match +/// force-killed unrelated processes that happened to mention the path (editors, +/// `tail`, a terminal that `cd`'d there, or another profile whose path has this +/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager. +fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool { + let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect(); + for (i, arg) in args.iter().enumerate() { + // Exact argument equality (Firefox/Camoufox: `-profile `; some launchers + // pass the path as its own arg). + if *arg == profile_path { + return true; + } + // `--user-data-dir=` (Chromium/Wayfern) or `-profile=`. + if let Some(val) = arg + .strip_prefix("--user-data-dir=") + .or_else(|| arg.strip_prefix("-profile=")) + { + if val == profile_path { + return true; + } + } + // Flag followed by the path as the next argument. + if (*arg == "-profile" || *arg == "--user-data-dir") + && args.get(i + 1).is_some_and(|next| *next == profile_path) + { + return true; + } + } + false +} + // Platform-specific modules #[cfg(target_os = "macos")] #[allow(dead_code)] @@ -215,16 +247,7 @@ pub mod macos { continue; } - // Check if any command line argument contains the profile path - let has_profile = cmd.iter().any(|arg| { - if let Some(arg_str) = arg.to_str() { - arg_str.contains(profile_path) - } else { - false - } - }); - - if has_profile { + if cmd_matches_profile_path(cmd, profile_path) { pids.push(pid.as_u32()); } } @@ -832,15 +855,7 @@ pub mod linux { continue; } - let has_profile = cmd.iter().any(|arg| { - if let Some(arg_str) = arg.to_str() { - arg_str.contains(profile_path) - } else { - false - } - }); - - if has_profile { + if cmd_matches_profile_path(cmd, profile_path) { pids.push(pid.as_u32()); } } diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index a42da1c..9295c72 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -1035,7 +1035,7 @@ impl ProfileManager { fs::create_dir_all(&dest_dir)?; } - let new_profile = BrowserProfile { + let mut new_profile = BrowserProfile { id: new_id, name: clone_name, browser: source.browser, @@ -1071,6 +1071,21 @@ impl ProfileManager { updated_at: Some(crate::proxy_manager::now_secs()), }; + // Donut: a clone must NOT be linkable to its source. The source + // wayfern_config embeds the persisted fingerprint JSON (including the + // canvas_noise_seed), so copying it verbatim makes the clone emit + // BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device + // signals as the source — trivially linkable if both run concurrently. Clear + // the fingerprint so the launch path mints a fresh one (a new + // canvas_noise_seed via RandBytes + an independent device fingerprint), + // exactly as create_profile does when fingerprint.is_none(). NOTE: the + // user-data-dir copy above still duplicates cookies/localStorage/TLS state — + // a separate storage-linkage vector the user must clear if they want full + // isolation between a clone and its source. + if let Some(cfg) = new_profile.wayfern_config.as_mut() { + cfg.fingerprint = None; + } + self.save_profile(&new_profile)?; if let Err(e) = events::emit_empty("profiles-changed") { diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index fe96554..fee46fe 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -774,6 +774,17 @@ impl ProxyManager { list } + /// Insert/replace a stored proxy in the in-memory map. Used by sync's + /// download_proxy after it writes the file to disk, mirroring how + /// download_group/download_vpn/download_extension keep their managers' + /// in-memory state in sync. Without this, get_stored_proxies (which reads + /// only the map) never sees a downloaded proxy until restart, so sync keeps + /// re-downloading it indefinitely. + pub fn upsert_stored_proxy(&self, proxy: StoredProxy) { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.insert(proxy.id.clone(), proxy); + } + // Get a stored proxy by ID // Update a stored proxy @@ -1730,12 +1741,18 @@ impl ProxyManager { .arg("--id") .arg(&proxy_id); - let output = proxy_cmd.output().await.unwrap(); - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::warn!("Proxy stop error: {stderr}"); - // We still return Ok since we've already removed the proxy from our tracking + // A failed spawn (sidecar missing, permission denied, fd exhaustion) must + // not panic the cleanup task — the proxy is already removed from tracking, + // so degrade gracefully like the non-success branch below. + match proxy_cmd.output().await { + Ok(output) if !output.status.success() => { + log::warn!( + "Proxy stop error: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(_) => {} + Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"), } // Clear profile-to-proxy mapping if it references this proxy @@ -1795,11 +1812,16 @@ impl ProxyManager { .arg("--id") .arg(&proxy_id); - let output = proxy_cmd.output().await.unwrap(); - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - log::warn!("Proxy stop error: {stderr}"); + // Don't panic if the sidecar can't be spawned — still clear the mapping. + match proxy_cmd.output().await { + Ok(output) if !output.status.success() => { + log::warn!( + "Proxy stop error: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(_) => {} + Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"), } // Clear profile-to-proxy mapping diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index ddd7c84..b4728b7 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -509,47 +509,20 @@ async fn handle_http_via_socks4( } }; - // Resolve target host to IP (SOCKS4 requires IP addresses) - let target_ip = match tokio::net::lookup_host((target_host, target_port)).await { - Ok(mut addrs) => { - if let Some(addr) = addrs.next() { - match addr.ip() { - std::net::IpAddr::V4(ipv4) => ipv4.octets(), - std::net::IpAddr::V6(_) => { - log::error!("SOCKS4 does not support IPv6"); - let mut response = Response::new(Full::new(Bytes::from( - "SOCKS4 does not support IPv6 addresses", - ))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - } - } else { - log::error!("Failed to resolve target host: {}", target_host); - let mut response = Response::new(Full::new(Bytes::from(format!( - "Failed to resolve target host: {}", - target_host - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - } - Err(e) => { - log::error!("Failed to resolve target host {}: {}", target_host, e); - let mut response = Response::new(Full::new(Bytes::from(format!( - "Failed to resolve target host: {}", - e - )))); - *response.status_mut() = StatusCode::BAD_GATEWAY; - return Ok(response); - } - }; - - // Build SOCKS4 CONNECT request + // Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target + // hostname locally: tokio::net::lookup_host would call the HOST resolver + // (getaddrinfo), leaking the destination domain to the host's DNS server and + // defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name — + // send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then + // the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy + // SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext + // HTTP — prefer SOCKS5 there.) let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT socks_request.extend_from_slice(&target_port.to_be_bytes()); - socks_request.extend_from_slice(&target_ip); - socks_request.push(0); // NULL terminator for userid + socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker + socks_request.push(0); // empty userid, NULL-terminated + socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve + socks_request.push(0); // NULL-terminated hostname // Send SOCKS4 CONNECT request if let Err(e) = socks_stream.write_all(&socks_request).await { @@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy( Proxy::http(upstream_url)? } "socks5" => { - // For SOCKS5, reqwest supports it directly - Proxy::all(upstream_url)? + // Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5 + // upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local, + // which resolves the destination hostname on the HOST (getaddrinfo) BEFORE + // connecting — leaking the destination domain to the host's DNS resolver + // and defeating the per-profile proxy. The `socks5h` scheme maps to + // DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks. + // (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's + // AddrKind::Domain.) + let remote_dns_url = match upstream_url.strip_prefix("socks5://") { + Some(rest) => format!("socks5h://{rest}"), + None => upstream_url.to_string(), + }; + Proxy::all(remote_dns_url)? } "socks4" => { // SOCKS4 is handled manually in handle_http_via_socks4 diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index fa8b8a8..d7e5533 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1597,6 +1597,13 @@ impl SyncEngine { )) })?; + // Keep the in-memory cache in sync with disk. Without this, get_stored_proxies + // (which reads only the in-memory map) never sees the downloaded proxy until + // restart, so check_for_missing_synced_entities/sync_proxy treat it as + // missing every pass and re-download it forever. Mirrors download_group/ + // download_vpn/download_extension. + proxy_manager.upsert_stored_proxy(proxy.clone()); + // Emit event for UI update if let Some(_handle) = app_handle { let _ = events::emit("stored-proxies-changed", ()); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index c20e149..9712f95 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -651,7 +651,12 @@ impl WayfernManager { "--disable-session-crashed-bubble".to_string(), "--hide-crash-restore-bubble".to_string(), "--disable-infobars".to_string(), - "--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(), + // Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses + // an isolated NetworkContext that defaults to DIRECT egress (real host IP + // leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be + // re-enabled by an imported/synced network_prediction_options pref (which a + // compile-time pref default could be). + "--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(), "--use-mock-keychain".to_string(), "--password-store=basic".to_string(), ]; diff --git a/src/app/page.tsx b/src/app/page.tsx index d337a46..8bac09d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1168,11 +1168,14 @@ export default function Home() { profileId: profile.id, syncMode: enabling ? "Regular" : "Disabled", }); - showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", { - description: enabling - ? "Profile sync has been enabled" - : "Profile sync has been disabled", - }); + showSuccessToast( + t(enabling ? "sync.enabledToast" : "sync.disabledToast"), + { + description: t( + enabling ? "sync.enabledDescription" : "sync.disabledDescription", + ), + }, + ); } catch (error) { console.error("Failed to toggle sync:", error); showErrorToast(t("errors.updateSyncSettingsFailed")); @@ -1325,6 +1328,7 @@ export default function Home() { let unlistenStarted: (() => void) | undefined; let unlistenProgress: (() => void) | undefined; let unlistenCompleted: (() => void) | undefined; + let unlistenWayfernBlocked: (() => void) | undefined; void (async () => { unlistenRequired = await listen( @@ -1386,6 +1390,16 @@ export default function Home() { duration: 5000, }); }); + + unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => { + showToast({ + id: "wayfern-paid-blocked", + type: "error", + title: t("wayfernBlocked.title"), + description: t("wayfernBlocked.description"), + duration: 15000, + }); + }); })(); return () => { @@ -1393,6 +1407,7 @@ export default function Home() { unlistenStarted?.(); unlistenProgress?.(); unlistenCompleted?.(); + unlistenWayfernBlocked?.(); }; }, [t]); diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index cb06452..d0f91ec 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps { interface DownloadToastProps extends BaseToastProps { type: "download"; - stage?: - | "downloading" - | "extracting" - | "verifying" - | "completed" - | "downloading (twilight rolling release)"; + stage?: "downloading" | "extracting" | "verifying" | "completed"; progress?: { percentage: number; speed?: string; @@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps { browserName?: string; } -interface TwilightUpdateToastProps extends BaseToastProps { - type: "twilight-update"; - browserName?: string; - hasUpdate?: boolean; -} - interface SyncProgressToastProps extends BaseToastProps { type: "sync-progress"; progress?: { @@ -138,7 +127,6 @@ type ToastProps = | DownloadToastProps | VersionUpdateToastProps | FetchingToastProps - | TwilightUpdateToastProps | SyncProgressToastProps; function formatBytesCompact(bytes: number): string { @@ -191,10 +179,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) { return ( ); - case "twilight-update": - return ( - - ); case "sync-progress": return ( @@ -246,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {

{progress.percentage.toFixed(1)}% {progress.speed && ` • ${progress.speed} MB/s`} - {progress.eta && ` • ${progress.eta} remaining`} + {progress.eta && + ` • ${t("toasts.progress.remaining", { time: progress.eta })}`}

@@ -264,9 +249,10 @@ export function UnifiedToast(props: ToastProps) { "current_browser" in progress && (

- {progress.current_browser && ( - <>Looking for updates for {progress.current_browser} - )} + {progress.current_browser && + t("versionUpdater.toast.lookingForUpdates", { + browser: progress.current_browser, + })}

@@ -293,7 +279,10 @@ export function UnifiedToast(props: ToastProps) { {progress.phase === "uploading" ? t("appUpdate.toast.uploading") : t("appUpdate.toast.downloading")}{" "} - {progress.completed_files}/{progress.total_files} files + {t("toasts.progress.filesProgress", { + completed: progress.completed_files, + total: progress.total_files, + })} {" \u2022 "} {formatBytesCompact(progress.completed_bytes)} /{" "} {formatBytesCompact(progress.total_bytes)} @@ -304,37 +293,21 @@ export function UnifiedToast(props: ToastProps) { )} {progress.eta_seconds > 0 && - progress.completed_files < progress.total_files && ( - <> - {" \u2022 ~"} - {formatEtaCompact(progress.eta_seconds)} remaining - - )} + progress.completed_files < progress.total_files && + ` \u2022 ${t("toasts.progress.remaining", { + time: `~${formatEtaCompact(progress.eta_seconds)}`, + })}`}

{progress.failed_count > 0 && (

- {progress.failed_count} file(s) failed + {t("toasts.progress.filesFailed", { + count: progress.failed_count, + })}

)}
)} - {/* Twilight update progress */} - {type === "twilight-update" && ( -
-

- {"hasUpdate" in props && props.hasUpdate - ? "New twilight build available for download" - : "Checking for twilight updates..."} -

- {props.browserName && ( -

- {props.browserName} • Rolling Release -

- )} -
- )} - {/* Description */} {description && (

@@ -355,11 +328,6 @@ export function UnifiedToast(props: ToastProps) { {t("browserDownload.toast.verifying")}

)} - {stage === "downloading (twilight rolling release)" && ( -

- {t("browserDownload.toast.downloadingRolling")} -

- )} )} {action && diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 0427519..f839a4c 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -2039,12 +2039,12 @@ export function ProfilesDataTable({ if (isDisabled) { const tooltipMessage = isRunning - ? "Can't modify running profile" + ? t("profiles.table.cantModifyRunning") : isLaunching - ? "Can't modify profile while launching" + ? t("profiles.table.cantModifyLaunching") : isStopping - ? "Can't modify profile while stopping" - : "Can't modify profile while browser is updating"; + ? t("profiles.table.cantModifyStopping") + : t("profiles.table.cantModifyUpdating"); return ( diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index e9ec1e0..53c3a88 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -263,9 +263,9 @@ export function ProfileInfoDialog({ ? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name : null; const networkLabel = vpnName - ? `VPN: ${vpnName}` + ? t("profileInfo.network.vpnLabel", { name: vpnName }) : proxyName - ? `Proxy: ${proxyName}` + ? t("profileInfo.network.proxyLabel", { name: proxyName }) : t("profileInfo.values.none"); const syncStatus = syncStatuses[profile.id]; @@ -299,6 +299,10 @@ export function ProfileInfoDialog({ // `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely // a navigation hub. interface ActionItem { + // Stable, language-independent key used to map sidebar sections to actions. + // The sidebar must NOT match on `label` — labels are translated, so English + // substring matching hides sections for every non-English user. + id?: string; icon: React.ReactNode; label: string; onClick: () => void; @@ -311,6 +315,7 @@ export function ProfileInfoDialog({ const actions: ActionItem[] = [ { + id: "network", icon: , label: t("profiles.actions.viewNetwork"), onClick: () => { @@ -319,6 +324,7 @@ export function ProfileInfoDialog({ disabled: isCrossOs, }, { + id: "sync", icon: , label: t("profiles.actions.syncSettings"), onClick: () => { @@ -337,6 +343,7 @@ export function ProfileInfoDialog({ runningBadge: isRunning, }, { + id: "fingerprint", icon: , label: t("profiles.actions.changeFingerprint"), onClick: () => { @@ -359,6 +366,7 @@ export function ProfileInfoDialog({ hidden: profile.browser !== "wayfern" || !onLaunchWithSync, }, { + id: "cookiesCopy", icon: , label: t("profiles.actions.copyCookiesToProfile"), onClick: () => { @@ -372,6 +380,7 @@ export function ProfileInfoDialog({ !onCopyCookiesToProfile, }, { + id: "cookiesManage", icon: , label: t("profileInfo.actions.manageCookies"), onClick: () => { @@ -395,6 +404,7 @@ export function ProfileInfoDialog({ hidden: profile.ephemeral === true, }, { + id: "extension", icon: , label: t("profileInfo.actions.assignExtensionGroup"), onClick: () => { @@ -419,6 +429,7 @@ export function ProfileInfoDialog({ }, }, { + id: "hook", icon: , label: t("profiles.actions.launchHook"), onClick: () => { @@ -461,6 +472,7 @@ export function ProfileInfoDialog({ destructive: true, }, { + id: "delete", icon: , label: t("profiles.actions.delete"), onClick: () => { @@ -534,6 +546,7 @@ interface ProfileInfoLayoutProps { onCloneProfile?: (profile: BrowserProfile) => void; onKillProfile?: (profile: BrowserProfile) => void; visibleActions: { + id?: string; icon: React.ReactNode; label: string; onClick: () => void; @@ -579,22 +592,23 @@ function ProfileInfoLayout({ }: ProfileInfoLayoutProps) { const [section, setSection] = React.useState("overview"); - // Map sidebar items to existing action labels, so clicking a section - // simply triggers the existing dialog handler. + // Map sidebar items to existing actions by their stable, language-independent + // `id`, so clicking a section triggers the existing dialog handler. Matching + // on `label` would break for every non-English locale (the labels are + // translated) and hide whole sections. const findAction = React.useCallback( - (substr: string) => - visibleActions.find((a) => a.label.toLowerCase().includes(substr)), + (id: string) => visibleActions.find((a) => a.id === id), [visibleActions], ); const deleteAction = findAction("delete"); const fingerprintAction = findAction("fingerprint"); - const cookiesManageAction = findAction("manage cookies"); - const cookiesCopyAction = findAction("copy cookies"); + const cookiesManageAction = findAction("cookiesManage"); + const cookiesCopyAction = findAction("cookiesCopy"); const cookiesAction = cookiesManageAction ?? cookiesCopyAction; const extensionAction = findAction("extension"); const syncAction = findAction("sync"); - const _launchHookAction = findAction("hook") ?? findAction("launch hook"); + const _launchHookAction = findAction("hook"); const _networkAction = findAction("network"); // Password actions are no longer routed via the legacy action handlers — // SecuritySectionInline writes directly to the backend instead. @@ -1149,7 +1163,7 @@ function SyncSectionInline({ syncMode: mode, }); } catch (e) { - setError(String(e)); + setError(translateBackendError(t as never, e)); } finally { setIsSaving(false); } @@ -1192,7 +1206,9 @@ function SyncSectionInline({

{t("profileInfo.fields.syncStatus")}

-

{syncStatus.status}

+

+ {t(`profileInfo.syncStatusValue.${syncStatus.status}`)} +

{syncStatus.error && (

{syncStatus.error}

)} @@ -1246,7 +1262,7 @@ function NetworkSectionInline({ setProxyId(nextId); if (nextId !== null) setVpnId(null); } catch (e) { - setError(String(e)); + setError(translateBackendError(t as never, e)); } finally { setIsSaving(false); } @@ -1264,7 +1280,7 @@ function NetworkSectionInline({ setVpnId(nextId); if (nextId !== null) setProxyId(null); } catch (e) { - setError(String(e)); + setError(translateBackendError(t as never, e)); } finally { setIsSaving(false); } @@ -1370,7 +1386,7 @@ function ExtensionsSectionInline({ ); if (mounted) setGroups(data); } catch (e) { - if (mounted) setError(String(e)); + if (mounted) setError(translateBackendError(t as never, e)); } }; void load(); @@ -1384,7 +1400,7 @@ function ExtensionsSectionInline({ mounted = false; unlisten?.(); }; - }, []); + }, [t]); const onChange = async (value: string) => { const next = value === "__none__" ? null : value; @@ -1397,7 +1413,7 @@ function ExtensionsSectionInline({ }); setGroupId(next); } catch (e) { - setError(String(e)); + setError(translateBackendError(t as never, e)); } finally { setIsSaving(false); } @@ -1684,7 +1700,7 @@ function FingerprintSectionInline({ // Close the dialog once the fingerprint is saved. onSaved(); } catch (e) { - setError(String(e)); + setError(translateBackendError(t as never, e)); } finally { setIsSaving(false); } diff --git a/src/components/profile-password-dialog.tsx b/src/components/profile-password-dialog.tsx index 81743c6..f91e2bc 100644 --- a/src/components/profile-password-dialog.tsx +++ b/src/components/profile-password-dialog.tsx @@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
{(mode === "set" || mode === "change") && (
-

+

{t("profilePassword.warnings.forgetWarningTitle")}

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8f5512b..cd54638 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -239,7 +239,11 @@ "extDefault": "Default", "dnsLevel": "DNS blocklist: {{level}}", "extSearch": "Search groups…", - "extEmpty": "No extension groups" + "extEmpty": "No extension groups", + "cantModifyRunning": "Can't modify running profile", + "cantModifyLaunching": "Can't modify profile while launching", + "cantModifyStopping": "Can't modify profile while stopping", + "cantModifyUpdating": "Can't modify profile while browser is updating" }, "actions": { "launch": "Launch", @@ -639,7 +643,11 @@ "profileSynced": "Profile '{{name}}' synced successfully", "profileSyncFailed": "Failed to sync profile '{{name}}'", "profileSyncFailedWithError": "Failed to sync profile '{{name}}': {{error}}" - } + }, + "enabledToast": "Sync enabled", + "disabledToast": "Sync disabled", + "enabledDescription": "Profile sync has been enabled", + "disabledDescription": "Profile sync has been disabled" }, "integrations": { "title": "Integrations", @@ -918,6 +926,11 @@ "syncingProfile": "Syncing profile '{{name}}'...", "syncingProfileWithProgress": "{{count}} files ({{size}})", "updatingVersions": "Updating browser versions..." + }, + "progress": { + "remaining": "{{time}} remaining", + "filesProgress": "{{completed}}/{{total}} files", + "filesFailed": "{{count}} file(s) failed" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Add Rule", "rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local", "noRules": "No bypass rules configured.", - "ruleTypes": "Supports hostnames, IP addresses, and regex patterns." + "ruleTypes": "Supports hostnames, IP addresses, and regex patterns.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "Launch Hook URL", @@ -1198,6 +1213,12 @@ "notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.", "lockedTitle": "Viewing & editing the fingerprint is a Pro feature", "lockedDescription": "Fingerprint protection is included on every plan. Viewing and editing a profile's fingerprint values is what requires an active paid plan." + }, + "syncStatusValue": { + "waiting": "Waiting", + "syncing": "Syncing", + "synced": "Synced", + "error": "Error" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.", "downloadStarting": "Starting {{browser}} {{version}} download", "downloadProgressBelow": "Download progress will be shown below...", - "autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below." + "autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below.", + "lookingForUpdates": "Looking for updates for {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 weeks free", "commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving." } + }, + "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." } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5f2d3d2..b2ca0e8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -239,7 +239,11 @@ "extDefault": "Predet.", "dnsLevel": "Lista DNS: {{level}}", "extSearch": "Buscar grupos…", - "extEmpty": "Sin grupos de extensiones" + "extEmpty": "Sin grupos de extensiones", + "cantModifyRunning": "No se puede modificar un perfil en ejecución", + "cantModifyLaunching": "No se puede modificar el perfil mientras se inicia", + "cantModifyStopping": "No se puede modificar el perfil mientras se detiene", + "cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador" }, "actions": { "launch": "Iniciar", @@ -639,7 +643,11 @@ "profileSynced": "Perfil '{{name}}' sincronizado correctamente", "profileSyncFailed": "Error al sincronizar el perfil '{{name}}'", "profileSyncFailedWithError": "Error al sincronizar el perfil '{{name}}': {{error}}" - } + }, + "enabledToast": "Sincronización activada", + "disabledToast": "Sincronización desactivada", + "enabledDescription": "Se ha activado la sincronización del perfil", + "disabledDescription": "Se ha desactivado la sincronización del perfil" }, "integrations": { "title": "Integraciones", @@ -918,6 +926,11 @@ "syncingProfile": "Sincronizando perfil '{{name}}'...", "syncingProfileWithProgress": "{{count}} archivos ({{size}})", "updatingVersions": "Actualizando versiones de navegadores..." + }, + "progress": { + "remaining": "{{time}} restante", + "filesProgress": "{{completed}}/{{total}} archivos", + "filesFailed": "{{count}} archivo(s) con error" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Agregar Regla", "rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local", "noRules": "No hay reglas de omisión configuradas.", - "ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex." + "ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "URL del hook de inicio", @@ -1198,6 +1213,12 @@ "notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.", "lockedTitle": "Ver y editar la huella digital es una función Pro", "lockedDescription": "La protección de huella digital está incluida en todos los planes. Ver y editar los valores de la huella digital de un perfil es lo que requiere un plan de pago activo." + }, + "syncStatusValue": { + "waiting": "Esperando", + "syncing": "Sincronizando", + "synced": "Sincronizado", + "error": "Error" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.", "downloadStarting": "Iniciando la descarga de {{browser}} {{version}}", "downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...", - "autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación." + "autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación.", + "lookingForUpdates": "Buscando actualizaciones de {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 semanas gratis", "commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero." } + }, + "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." } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 206d433..87ddb83 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -239,7 +239,11 @@ "extDefault": "Défaut", "dnsLevel": "Liste DNS : {{level}}", "extSearch": "Rechercher des groupes…", - "extEmpty": "Aucun groupe d’extensions" + "extEmpty": "Aucun groupe d’extensions", + "cantModifyRunning": "Impossible de modifier un profil en cours d'exécution", + "cantModifyLaunching": "Impossible de modifier le profil pendant le lancement", + "cantModifyStopping": "Impossible de modifier le profil pendant l'arrêt", + "cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur" }, "actions": { "launch": "Lancer", @@ -639,7 +643,11 @@ "profileSynced": "Profil '{{name}}' synchronisé avec succès", "profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'", "profileSyncFailedWithError": "Échec de la synchronisation du profil '{{name}}' : {{error}}" - } + }, + "enabledToast": "Synchronisation activée", + "disabledToast": "Synchronisation désactivée", + "enabledDescription": "La synchronisation du profil a été activée", + "disabledDescription": "La synchronisation du profil a été désactivée" }, "integrations": { "title": "Intégrations", @@ -918,6 +926,11 @@ "syncingProfile": "Synchronisation du profil '{{name}}'...", "syncingProfileWithProgress": "{{count}} fichiers ({{size}})", "updatingVersions": "Mise à jour des versions de navigateurs..." + }, + "progress": { + "remaining": "{{time}} restant", + "filesProgress": "{{completed}}/{{total}} fichiers", + "filesFailed": "Échec de {{count}} fichier(s)" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Ajouter une Règle", "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", "noRules": "Aucune règle de contournement configurée.", - "ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières." + "ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières.", + "vpnLabel": "VPN : {{name}}", + "proxyLabel": "Proxy : {{name}}" }, "launchHook": { "title": "URL du hook de lancement", @@ -1198,6 +1213,12 @@ "notSupported": "L’édition des empreintes n’est disponible que pour les profils Camoufox et Wayfern.", "lockedTitle": "Afficher et modifier l'empreinte est une fonctionnalité Pro", "lockedDescription": "La protection contre le fingerprinting est incluse dans tous les forfaits. C'est l'affichage et la modification des valeurs de l'empreinte d'un profil qui nécessitent un forfait payant actif." + }, + "syncStatusValue": { + "waiting": "En attente", + "syncing": "Synchronisation", + "synced": "Synchronisé", + "error": "Erreur" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.", "downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}", "downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...", - "autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous." + "autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous.", + "lookingForUpdates": "Recherche de mises à jour pour {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 semaines gratuites", "commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet." } + }, + "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." } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9b09f5d..b91475d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -239,7 +239,11 @@ "extDefault": "既定", "dnsLevel": "DNS ブロックリスト: {{level}}", "extSearch": "グループを検索…", - "extEmpty": "拡張機能グループがありません" + "extEmpty": "拡張機能グループがありません", + "cantModifyRunning": "実行中のプロファイルは変更できません", + "cantModifyLaunching": "起動中はプロファイルを変更できません", + "cantModifyStopping": "停止中はプロファイルを変更できません", + "cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません" }, "actions": { "launch": "起動", @@ -639,7 +643,11 @@ "profileSynced": "プロファイル '{{name}}' を同期しました", "profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました", "profileSyncFailedWithError": "プロファイル '{{name}}' の同期に失敗しました: {{error}}" - } + }, + "enabledToast": "同期を有効化しました", + "disabledToast": "同期を無効化しました", + "enabledDescription": "プロファイルの同期が有効になりました", + "disabledDescription": "プロファイルの同期が無効になりました" }, "integrations": { "title": "統合", @@ -918,6 +926,11 @@ "syncingProfile": "プロファイル '{{name}}' を同期中...", "syncingProfileWithProgress": "{{count}} ファイル ({{size}})", "updatingVersions": "ブラウザバージョンを更新中..." + }, + "progress": { + "remaining": "残り {{time}}", + "filesProgress": "{{completed}}/{{total}} ファイル", + "filesFailed": "{{count}} 件のファイルが失敗しました" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "ルールを追加", "rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local", "noRules": "バイパスルールは設定されていません。", - "ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。" + "ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "起動フックURL", @@ -1198,6 +1213,12 @@ "notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。", "lockedTitle": "フィンガープリントの表示と編集は Pro 機能です", "lockedDescription": "フィンガープリント保護はすべてのプランに含まれています。プロファイルのフィンガープリントの値を表示・編集するには、有効な有料プランが必要です。" + }, + "syncStatusValue": { + "waiting": "待機中", + "syncing": "同期中", + "synced": "同期済み", + "error": "エラー" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。", "downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています", "downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...", - "autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。" + "autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。", + "lookingForUpdates": "{{browser}} の更新を確認しています" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2週間無料", "commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。" } + }, + "wayfernBlocked": { + "title": "ブラウザの自動化が一時停止しました", + "description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 665e27a..3ed689e 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -131,12 +131,12 @@ "title": "기본 브라우저", "setAsDefault": "기본 브라우저로 설정", "alreadyDefault": "이미 기본 브라우저입니다", - "description": "기본 브라우저로 설정하면 도넛 브라우저가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다." + "description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다." }, "permissions": { "title": "시스템 권한", "loading": "권한 불러오는 중...", - "description": "이 권한은 도넛 브라우저에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "description": "이 권한은 Donut Browser에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", "microphone": "마이크", "microphoneDescription": "브라우저 애플리케이션의 마이크 액세스", "camera": "카메라", @@ -180,7 +180,7 @@ "trialExpired": "체험판이 만료되었습니다", "trialExpiredDescription": "개인 사용은 무료로 유지됩니다. 상업적 사용에는 라이선스가 필요합니다.", "subscriptionActive": "구독 중 — {{plan}} 플랜", - "subscriptionActiveDescription": "도넛 브라우저 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다." + "subscriptionActiveDescription": "Donut Browser 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다." }, "advanced": { "title": "고급", @@ -193,7 +193,7 @@ "copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다." }, "disableAutoUpdates": "앱 자동 업데이트 사용 안 함", - "disableAutoUpdatesDescription": "도넛 브라우저 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.", + "disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.", "keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지", "keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다." }, @@ -212,7 +212,7 @@ "extensions": "확장 프로그램" }, "newProfile": "새로 만들기", - "donutLogo": "도넛 브라우저 로고", + "donutLogo": "Donut Browser 로고", "scrollGroupsLeft": "그룹 왼쪽으로 스크롤", "scrollGroupsRight": "그룹 오른쪽으로 스크롤" }, @@ -239,7 +239,11 @@ "extDefault": "기본값", "dnsLevel": "DNS 차단 목록: {{level}}", "extSearch": "그룹 검색…", - "extEmpty": "확장 프로그램 그룹이 없습니다" + "extEmpty": "확장 프로그램 그룹이 없습니다", + "cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다", + "cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다", + "cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다", + "cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다" }, "actions": { "launch": "실행", @@ -639,7 +643,11 @@ "profileSynced": "프로필 '{{name}}'이(가) 동기화되었습니다", "profileSyncFailed": "프로필 '{{name}}' 동기화 실패", "profileSyncFailedWithError": "프로필 '{{name}}' 동기화 실패: {{error}}" - } + }, + "enabledToast": "동기화 사용", + "disabledToast": "동기화 사용 안 함", + "enabledDescription": "프로필 동기화가 활성화되었습니다", + "disabledDescription": "프로필 동기화가 비활성화되었습니다" }, "integrations": { "title": "통합", @@ -918,6 +926,11 @@ "syncingProfile": "프로필 '{{name}}' 동기화 중...", "syncingProfileWithProgress": "{{count}}개 파일 ({{size}})", "updatingVersions": "브라우저 버전 업데이트 중..." + }, + "progress": { + "remaining": "{{time}} 남음", + "filesProgress": "{{completed}}/{{total}} 파일", + "filesFailed": "{{count}}개 파일 실패" } }, "errors": { @@ -1136,12 +1149,14 @@ "addRule": "규칙 추가", "rulePlaceholder": "예: example.com, 192.168.1.*, .*\\.local", "noRules": "구성된 우회 규칙이 없습니다.", - "ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다." + "ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "실행 후크 URL", "label": "실행 후크 URL", - "description": "도넛 브라우저는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.", + "description": "Donut Browser는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.", "placeholder": "https://example.com/hooks/profile-launch", "invalidUrlHint": "유효한 http:// 또는 https:// URL을 입력하세요." }, @@ -1198,6 +1213,12 @@ "notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.", "lockedTitle": "핑거프린트 보기 및 편집은 Pro 기능입니다", "lockedDescription": "핑거프린트 보호는 모든 요금제에 포함되어 있습니다. 프로필의 핑거프린트 값을 보고 편집하려면 활성 유료 요금제가 필요합니다." + }, + "syncStatusValue": { + "waiting": "대기 중", + "syncing": "동기화 중", + "synced": "동기화됨", + "error": "오류" } }, "extensions": { @@ -1535,7 +1556,7 @@ }, "wayfernTerms": { "title": "Wayfern 이용 약관", - "description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.", + "description": "Donut Browser를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.", "reviewLabel": "다음 위치에서 이용 약관을 검토하세요:", "agreeNotice": "\"동의함\"을 클릭하면 이 약관에 동의하는 것입니다.", "acceptButton": "동의함", @@ -1546,7 +1567,7 @@ "commercialTrial": { "title": "상업용 체험판 만료됨", "description": "2주 상업용 체험판 기간이 종료되었습니다.", - "body": "도넛 브라우저를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.", + "body": "Donut Browser를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.", "understandButton": "이해했습니다", "failed": "확인 저장 실패", "tryAgain": "다시 시도하세요" @@ -1554,10 +1575,10 @@ "permissionDialog": { "titleMicrophone": "마이크 액세스가 필요합니다", "titleCamera": "카메라 액세스가 필요합니다", - "descMicrophone": "도넛 브라우저는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", - "descCamera": "도넛 브라우저는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", - "grantedMicrophone": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.", - "grantedCamera": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.", + "descMicrophone": "Donut Browser는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "descCamera": "Donut Browser는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "grantedMicrophone": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.", + "grantedCamera": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.", "notGrantedMicrophone": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 마이크에 대한 액세스를 요청하세요.", "notGrantedCamera": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 카메라에 대한 액세스를 요청하세요.", "doneButton": "완료", @@ -1670,7 +1691,7 @@ }, "appUpdate": { "toast": { - "updateFailed": "도넛 브라우저 업데이트 실패", + "updateFailed": "Donut Browser 업데이트 실패", "restartFailed": "재시작 실패", "updateReady": "업데이트 준비됨, 적용하려면 재시작하세요", "manualDownloadRequired": "수동 다운로드 필요", @@ -1718,7 +1739,8 @@ "updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.", "downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중", "downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...", - "autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다." + "autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다.", + "lookingForUpdates": "{{browser}} 업데이트를 확인하는 중" } }, "profilePassword": { @@ -1762,7 +1784,7 @@ }, "warnings": { "forgetWarningTitle": "중요: 이 비밀번호는 복구할 수 없습니다", - "forgetWarningBody": "도넛 브라우저는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다." + "forgetWarningBody": "Donut Browser는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다." }, "modes": { "set": "설정", @@ -1806,7 +1828,7 @@ "invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.", "cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.", "cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.", - "selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.", + "selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 Donut 계정에서 로그아웃하세요.", "fingerprintRequiresPro": "핑거프린트를 보거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.", "proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.", "proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.", @@ -1885,8 +1907,8 @@ }, "selfHosted": { "title": "자체 호스팅 동기화 서버", - "description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 도넛을 자체 donut-sync 서버로 연결하세요.", - "disabledWhileLoggedIn": "도넛 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.", + "description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 Donut을 자체 donut-sync 서버로 연결하세요.", + "disabledWhileLoggedIn": "Donut 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.", "connectionStatus": "연결:", "statusUnknown": "테스트 안 됨", "testConnection": "연결 테스트", @@ -2017,5 +2039,9 @@ "trialBadge": "2주 무료", "commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다." } + }, + "wayfernBlocked": { + "title": "브라우저 자동화가 일시 중지됨", + "description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다." } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 64a4b50..5a89f7e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -239,7 +239,11 @@ "extDefault": "Padrão", "dnsLevel": "Lista DNS: {{level}}", "extSearch": "Pesquisar grupos…", - "extEmpty": "Sem grupos de extensões" + "extEmpty": "Sem grupos de extensões", + "cantModifyRunning": "Não é possível modificar um perfil em execução", + "cantModifyLaunching": "Não é possível modificar o perfil durante a inicialização", + "cantModifyStopping": "Não é possível modificar o perfil durante a parada", + "cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado" }, "actions": { "launch": "Iniciar", @@ -639,7 +643,11 @@ "profileSynced": "Perfil '{{name}}' sincronizado com sucesso", "profileSyncFailed": "Falha ao sincronizar o perfil '{{name}}'", "profileSyncFailedWithError": "Falha ao sincronizar o perfil '{{name}}': {{error}}" - } + }, + "enabledToast": "Sincronização ativada", + "disabledToast": "Sincronização desativada", + "enabledDescription": "A sincronização do perfil foi ativada", + "disabledDescription": "A sincronização do perfil foi desativada" }, "integrations": { "title": "Integrações", @@ -918,6 +926,11 @@ "syncingProfile": "Sincronizando perfil '{{name}}'...", "syncingProfileWithProgress": "{{count}} arquivos ({{size}})", "updatingVersions": "Atualizando versões de navegadores..." + }, + "progress": { + "remaining": "{{time}} restante", + "filesProgress": "{{completed}}/{{total}} arquivos", + "filesFailed": "{{count}} arquivo(s) com falha" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Adicionar Regra", "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", "noRules": "Nenhuma regra de bypass configurada.", - "ruleTypes": "Suporta nomes de host, endereços IP e padrões regex." + "ruleTypes": "Suporta nomes de host, endereços IP e padrões regex.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "URL do hook de inicialização", @@ -1198,6 +1213,12 @@ "notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.", "lockedTitle": "Visualizar e editar a impressão digital é um recurso Pro", "lockedDescription": "A proteção contra fingerprint está incluída em todos os planos. Visualizar e editar os valores da impressão digital de um perfil é o que requer um plano pago ativo." + }, + "syncStatusValue": { + "waiting": "Aguardando", + "syncing": "Sincronizando", + "synced": "Sincronizado", + "error": "Erro" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.", "downloadStarting": "Iniciando o download do {{browser}} {{version}}", "downloadProgressBelow": "O progresso do download será mostrado abaixo...", - "autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo." + "autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo.", + "lookingForUpdates": "Procurando atualizações para {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 semanas grátis", "commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero." } + }, + "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." } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index cfe4fbc..e548508 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -239,7 +239,11 @@ "extDefault": "По умолч.", "dnsLevel": "DNS-блок-лист: {{level}}", "extSearch": "Поиск групп…", - "extEmpty": "Нет групп расширений" + "extEmpty": "Нет групп расширений", + "cantModifyRunning": "Нельзя изменить запущенный профиль", + "cantModifyLaunching": "Нельзя изменить профиль во время запуска", + "cantModifyStopping": "Нельзя изменить профиль во время остановки", + "cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера" }, "actions": { "launch": "Запустить", @@ -639,7 +643,11 @@ "profileSynced": "Профиль '{{name}}' успешно синхронизирован", "profileSyncFailed": "Не удалось синхронизировать профиль '{{name}}'", "profileSyncFailedWithError": "Не удалось синхронизировать профиль '{{name}}': {{error}}" - } + }, + "enabledToast": "Синхронизация включена", + "disabledToast": "Синхронизация отключена", + "enabledDescription": "Синхронизация профиля включена", + "disabledDescription": "Синхронизация профиля отключена" }, "integrations": { "title": "Интеграции", @@ -918,6 +926,11 @@ "syncingProfile": "Синхронизация профиля '{{name}}'...", "syncingProfileWithProgress": "{{count}} файлов ({{size}})", "updatingVersions": "Обновление версий браузеров..." + }, + "progress": { + "remaining": "осталось {{time}}", + "filesProgress": "{{completed}}/{{total}} файлов", + "filesFailed": "Ошибка в {{count}} файле(ах)" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Добавить правило", "rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local", "noRules": "Правила обхода не настроены.", - "ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений." + "ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "URL хука запуска", @@ -1198,6 +1213,12 @@ "notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.", "lockedTitle": "Просмотр и редактирование отпечатка — функция Pro", "lockedDescription": "Защита от отпечатков включена во все планы. Активный платный план требуется именно для просмотра и редактирования значений отпечатка профиля." + }, + "syncStatusValue": { + "waiting": "Ожидание", + "syncing": "Синхронизация", + "synced": "Синхронизировано", + "error": "Ошибка" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.", "downloadStarting": "Запуск загрузки {{browser}} {{version}}", "downloadProgressBelow": "Прогресс загрузки будет показан ниже...", - "autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже." + "autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже.", + "lookingForUpdates": "Поиск обновлений для {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 недели бесплатно", "commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект." } + }, + "wayfernBlocked": { + "title": "Автоматизация браузера приостановлена", + "description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ." } } diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 245dfe8..056dfdf 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -239,7 +239,11 @@ "extDefault": "Mặc định", "dnsLevel": "Danh sách chặn DNS: {{level}}", "extSearch": "Tìm kiếm nhóm…", - "extEmpty": "Không có nhóm tiện ích" + "extEmpty": "Không có nhóm tiện ích", + "cantModifyRunning": "Không thể chỉnh sửa profile đang chạy", + "cantModifyLaunching": "Không thể chỉnh sửa profile khi đang khởi chạy", + "cantModifyStopping": "Không thể chỉnh sửa profile khi đang dừng", + "cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật" }, "actions": { "launch": "Khởi chạy", @@ -639,7 +643,11 @@ "profileSynced": "Đã đồng bộ profile '{{name}}' thành công", "profileSyncFailed": "Đồng bộ profile '{{name}}' thất bại", "profileSyncFailedWithError": "Đồng bộ profile '{{name}}' thất bại: {{error}}" - } + }, + "enabledToast": "Đã bật đồng bộ", + "disabledToast": "Đã tắt đồng bộ", + "enabledDescription": "Đồng bộ profile đã được bật", + "disabledDescription": "Đồng bộ profile đã được tắt" }, "integrations": { "title": "Tích hợp", @@ -918,6 +926,11 @@ "syncingProfile": "Đang đồng bộ profile '{{name}}'...", "syncingProfileWithProgress": "{{count}} tệp ({{size}})", "updatingVersions": "Đang cập nhật phiên bản trình duyệt..." + }, + "progress": { + "remaining": "còn {{time}}", + "filesProgress": "{{completed}}/{{total}} tệp", + "filesFailed": "{{count}} tệp thất bại" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "Thêm quy tắc", "rulePlaceholder": "ví dụ: example.com, 192.168.1.*, .*\\.local", "noRules": "Chưa cấu hình quy tắc bỏ qua nào.", - "ruleTypes": "Hỗ trợ tên máy chủ, địa chỉ IP và biểu thức chính quy." + "ruleTypes": "Hỗ trợ tên máy chủ, địa chỉ IP và biểu thức chính quy.", + "vpnLabel": "VPN: {{name}}", + "proxyLabel": "Proxy: {{name}}" }, "launchHook": { "title": "URL hook khởi chạy", @@ -1198,6 +1213,12 @@ "notSupported": "Chỉnh sửa vân tay chỉ khả dụng cho profile Camoufox và Wayfern.", "lockedTitle": "Xem và chỉnh sửa vân tay là tính năng Pro", "lockedDescription": "Bảo vệ vân tay được bao gồm trong mọi gói. Việc xem và chỉnh sửa các giá trị vân tay của profile mới là phần yêu cầu gói trả phí đang hoạt động." + }, + "syncStatusValue": { + "waiting": "Đang chờ", + "syncing": "Đang đồng bộ", + "synced": "Đã đồng bộ", + "error": "Lỗi" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "Quá trình tải phiên bản {{version}} sẽ sớm bắt đầu. Việc khởi chạy trình duyệt bị tắt cho đến khi cập nhật hoàn tất.", "downloadStarting": "Đang bắt đầu tải {{browser}} {{version}}", "downloadProgressBelow": "Tiến trình tải sẽ hiển thị bên dưới...", - "autoDownloadStarted": "Đang tự động tải {{browser}} {{version}}. Tiến trình sẽ hiển thị bên dưới." + "autoDownloadStarted": "Đang tự động tải {{browser}} {{version}}. Tiến trình sẽ hiển thị bên dưới.", + "lookingForUpdates": "Đang tìm bản cập nhật cho {{browser}}" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 tuần miễn phí", "commercialDesc": "Miễn phí dùng thử 2 tuần. Sau đó, gói trả phí giúp dự án được duy trì và phát triển." } + }, + "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." } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 52db228..c1338f5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -239,7 +239,11 @@ "extDefault": "默认", "dnsLevel": "DNS 屏蔽列表: {{level}}", "extSearch": "搜索分组…", - "extEmpty": "没有扩展组" + "extEmpty": "没有扩展组", + "cantModifyRunning": "无法修改正在运行的配置文件", + "cantModifyLaunching": "启动期间无法修改配置文件", + "cantModifyStopping": "停止期间无法修改配置文件", + "cantModifyUpdating": "浏览器更新期间无法修改配置文件" }, "actions": { "launch": "启动", @@ -639,7 +643,11 @@ "profileSynced": "配置文件 '{{name}}' 同步成功", "profileSyncFailed": "同步配置文件 '{{name}}' 失败", "profileSyncFailedWithError": "同步配置文件 '{{name}}' 失败: {{error}}" - } + }, + "enabledToast": "已启用同步", + "disabledToast": "已禁用同步", + "enabledDescription": "已启用配置文件同步", + "disabledDescription": "已禁用配置文件同步" }, "integrations": { "title": "集成", @@ -918,6 +926,11 @@ "syncingProfile": "正在同步配置文件 '{{name}}'...", "syncingProfileWithProgress": "{{count}} 个文件 ({{size}})", "updatingVersions": "正在更新浏览器版本..." + }, + "progress": { + "remaining": "剩余 {{time}}", + "filesProgress": "{{completed}}/{{total}} 个文件", + "filesFailed": "{{count}} 个文件失败" } }, "errors": { @@ -1136,7 +1149,9 @@ "addRule": "添加规则", "rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local", "noRules": "未配置绕过规则。", - "ruleTypes": "支持主机名、IP地址和正则表达式模式。" + "ruleTypes": "支持主机名、IP地址和正则表达式模式。", + "vpnLabel": "VPN:{{name}}", + "proxyLabel": "代理:{{name}}" }, "launchHook": { "title": "启动钩子 URL", @@ -1198,6 +1213,12 @@ "notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。", "lockedTitle": "查看和编辑指纹是 Pro 功能", "lockedDescription": "所有方案都包含指纹保护。查看和编辑配置文件的指纹数值才需要有效的付费方案。" + }, + "syncStatusValue": { + "waiting": "等待中", + "syncing": "同步中", + "synced": "已同步", + "error": "错误" } }, "extensions": { @@ -1718,7 +1739,8 @@ "updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。", "downloadStarting": "正在开始下载 {{browser}} {{version}}", "downloadProgressBelow": "下载进度将显示在下方...", - "autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。" + "autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。", + "lookingForUpdates": "正在检查 {{browser}} 的更新" } }, "profilePassword": { @@ -2017,5 +2039,9 @@ "trialBadge": "2 周免费", "commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。" } + }, + "wayfernBlocked": { + "title": "浏览器自动化已暂停", + "description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。" } } diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 7816350..449a33d 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import React from "react"; import { type ExternalToast, toast as sonnerToast } from "sonner"; import { UnifiedToast } from "@/components/custom-toast"; +import i18n from "@/i18n"; interface BaseToastProps { id?: string; @@ -29,12 +30,7 @@ interface ErrorToastProps extends BaseToastProps { interface DownloadToastProps extends BaseToastProps { type: "download"; - stage?: - | "downloading" - | "extracting" - | "verifying" - | "completed" - | "downloading (twilight rolling release)"; + stage?: "downloading" | "extracting" | "verifying" | "completed"; progress?: { percentage: number; speed?: string; @@ -159,25 +155,19 @@ export function showToast(props: ToastProps & { id?: string }) { export function showDownloadToast( browserName: string, version: string, - stage: - | "downloading" - | "extracting" - | "verifying" - | "completed" - | "downloading (twilight rolling release)", + stage: "downloading" | "extracting" | "verifying" | "completed", progress?: { percentage: number; speed?: string; eta?: string }, options?: { suppressCompletionToast?: boolean; onCancel?: () => void }, ) { + const tParams = { browser: browserName, version }; const title = stage === "completed" - ? `${browserName} ${version} downloaded successfully!` + ? i18n.t("toasts.success.downloadComplete", tParams) : stage === "downloading" - ? `Downloading ${browserName} ${version}` + ? i18n.t("toasts.loading.downloading", tParams) : stage === "extracting" - ? `Extracting ${browserName} ${version}` - : stage === "downloading (twilight rolling release)" - ? `Downloading ${browserName} ${version}` - : `Verifying ${browserName} ${version}`; + ? i18n.t("toasts.loading.extracting", tParams) + : i18n.t("toasts.loading.verifying", tParams); // Don't show completion toast if suppressed (for auto-update scenarios) if (stage === "completed" && options?.suppressCompletionToast) { @@ -186,9 +176,7 @@ export function showDownloadToast( } // Only show cancel button during active downloading, not for completed/extracting/verifying - const showCancel = - stage === "downloading" || - stage === "downloading (twilight rolling release)"; + const showCancel = stage === "downloading"; return showToast({ type: "download", @@ -241,10 +229,15 @@ export function showAutoUpdateToast( ) { return showToast({ type: "loading", - title: `${browserName} update started`, + title: i18n.t("versionUpdater.toast.updateStarted", { + browser: browserName, + }), description: options?.description ?? - `Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`, + i18n.t("versionUpdater.toast.autoDownloadStarted", { + browser: browserName, + version, + }), id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`, duration: options?.duration ?? 4000, }); @@ -270,7 +263,7 @@ export function showSyncProgressToast( ) { return showToast({ type: "sync-progress", - title: `Syncing profile '${profileName}'...`, + title: i18n.t("toasts.loading.syncingProfile", { name: profileName }), progress, id: options?.id, duration: Number.POSITIVE_INFINITY,