refactor: ui refresh

This commit is contained in:
zhom
2026-07-18 11:42:07 +04:00
parent 4f7910dd23
commit f1664b2950
64 changed files with 7472 additions and 2827 deletions
+10 -6
View File
@@ -17,7 +17,7 @@ donutbrowser/
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
@@ -38,6 +38,10 @@ donutbrowser/
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
│ │ ├── extension_manager.rs # Browser extension management
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer
@@ -79,12 +83,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
- Adding a new string means adding the key to EVERY locale file in `src/i18n/locales/` — currently en, es, fr, ja, ko, pt, ru, tr, vi, zh — not just `en.json`. The English version alone is incomplete work. Don't trust this list: enumerate `src/i18n/locales/*.json` and update every file you find, because a newly added locale is exactly what a hardcoded list silently skips.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
- When adding or removing keys across the locales, use a one-shot Python script in the scratchpad dir that globs `src/i18n/locales/*.json`, mutates each, and writes it back. Sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. Finish by diffing every locale's flattened key set against `en.json` — zero missing and zero extra, for all of them.
## Backend error codes (mandatory)
@@ -98,7 +102,7 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all nine locale files.
4. Add `backendErrors.fooBar` to every locale file in `src/i18n/locales/`.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
@@ -175,7 +179,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in every locale.
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
- `matchesGroupDigit(event)` returns 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
@@ -185,7 +189,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
4. Add `shortcuts.yourId` (label) to all nine locale files.
4. Add `shortcuts.yourId` (label) to every locale file in `src/i18n/locales/`.
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
-1
View File
@@ -61,7 +61,6 @@
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.42.2",
"i18next": "^26.3.4",
"lucide-react": "^1.23.0",
"motion": "^12.42.2",
-3
View File
@@ -109,9 +109,6 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
framer-motion:
specifier: ^12.42.2
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
i18next:
specifier: ^26.3.4
version: 26.3.4(typescript@6.0.3)
+23 -12
View File
@@ -39,6 +39,7 @@ pub struct ApiProfile {
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
pub clear_on_close: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -94,6 +95,9 @@ pub struct UpdateProfileRequest {
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
/// Wipe browsing data (keeping extensions and bookmarks) when the browser
/// exits. Rejected (400) for ephemeral or password-protected profiles.
pub clear_on_close: Option<bool>,
}
#[derive(Clone)]
@@ -756,6 +760,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
StatusCode::NOT_FOUND
} else if code == "INTERNAL_ERROR" {
StatusCode::INTERNAL_SERVER_ERROR
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
// Mapping them here lets the gate live in the shared manager instead of
// being re-implemented in each handler to get the status right.
StatusCode::PAYMENT_REQUIRED
} else {
// Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS,
// WAYFERN_VERSION_NOT_AVAILABLE, ...).
@@ -838,6 +847,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
clear_on_close: profile.clear_on_close,
})
.collect();
@@ -891,6 +901,7 @@ async fn get_profile(
is_running: profile.process_id.is_some(), // Simple check based on process_id
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
clear_on_close: profile.clear_on_close,
},
}))
} else {
@@ -1055,6 +1066,7 @@ async fn create_profile(
is_running: false,
proxy_bypass_rules: profile.proxy_bypass_rules,
vpn_id: profile.vpn_id,
clear_on_close: profile.clear_on_close,
},
}))
}
@@ -1196,6 +1208,14 @@ async fn update_profile(
}
}
if let Some(clear_on_close) = request.clear_on_close {
if let Err(e) =
profile_manager.update_profile_clear_on_close(&state.app_handle, &id, clear_on_close)
{
return Err(manager_error_response(e));
}
}
// Return updated profile
get_profile(Path(id), State(state))
.await
@@ -2412,17 +2432,8 @@ async fn import_profiles_api(
.as_ref()
.and_then(|config| serde_json::from_value(config.clone()).ok());
let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref());
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err((
StatusCode::PAYMENT_REQUIRED,
"Fingerprint OS spoofing requires an active Pro subscription.".to_string(),
));
}
// The Pro gate for fingerprint OS spoofing lives inside import_profiles, so
// every surface inherits it; manager_error_response maps the code to 402.
let importer = crate::profile_importer::ProfileImporter::instance();
importer
.import_profiles(
@@ -2706,7 +2717,7 @@ mod tests {
}
let import_item = schema_required(&spec, "ImportProfileItem");
for field in ["proxy_id", "browser_type"] {
for field in ["proxy_id", "vpn_id", "browser_type"] {
assert!(
!import_item.iter().any(|f| f == field),
"{field} must be optional on import items, required list: {import_item:?}"
+1
View File
@@ -668,6 +668,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
}
+8
View File
@@ -163,6 +163,12 @@ async fn main() {
.long("blocklist-file")
.help("Path to DNS blocklist file (one domain per line)"),
)
.arg(
Arg::new("dns-allowlist-mode")
.long("dns-allowlist-mode")
.num_args(0)
.help("Treat --blocklist-file as an allowlist (block all domains not listed)"),
)
.arg(
Arg::new("local-protocol")
.long("local-protocol")
@@ -256,6 +262,7 @@ async fn main() {
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
match start_proxy_process_with_profile(
@@ -264,6 +271,7 @@ async fn main() {
profile_id,
bypass_rules,
blocklist_file,
dns_allowlist_mode,
local_protocol,
)
.await
+1
View File
@@ -642,6 +642,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
};
+87 -80
View File
@@ -34,24 +34,29 @@ impl BrowserRunner {
crate::app_dirs::binaries_dir()
}
/// Resolve the DNS blocklist level to a cached file path.
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
/// Resolve the DNS blocklist level to a cached file path plus whether that
/// file should be treated as an allowlist. If a level is set but the cache
/// is missing, fetches/compiles on demand (blocks until done).
async fn resolve_blocklist_file(
profile: &crate::profile::BrowserProfile,
) -> Result<Option<String>, String> {
) -> Result<(Option<String>, bool), String> {
let Some(ref level_str) = profile.dns_blocklist else {
return Ok(None);
return Ok((None, false));
};
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
return Ok(None);
return Ok((None, false));
};
if level == crate::dns_blocklist::BlocklistLevel::None {
return Ok(None);
return Ok((None, false));
}
// Only the user's custom list can be an allowlist; the Hagezi tiers are
// always block lists.
let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom
&& crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode;
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
.await
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
Ok(Some(path.to_string_lossy().to_string()))
Ok((Some(path.to_string_lossy().to_string()), allowlist_mode))
}
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
@@ -247,15 +252,20 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
let profile_id_str = profile.id.to_string();
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
// Unique per-launch key: a shared constant here would let concurrent
// launches overwrite each other's active_proxies entry, ending with one
// browser's worker tracked under another browser's PID.
let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid();
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
launch_placeholder_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
dns_allowlist_mode,
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
// leaking the real IP, rather than being forced direct as they
@@ -269,6 +279,40 @@ impl BrowserRunner {
error_msg
})?;
// If any step below fails before the browser is up, the detached worker
// must be stopped here: its config never gets a browser_pid, so neither
// the GUI sweeps nor the worker's own watchdog would ever reap it — it
// would survive until machine reboot.
struct ProxyLaunchGuard {
app_handle: tauri::AppHandle,
placeholder_pid: u32,
profile_name: String,
armed: bool,
}
impl Drop for ProxyLaunchGuard {
fn drop(&mut self) {
if self.armed {
log::warn!(
"Launch failed after local proxy start for profile {}; stopping proxy worker",
self.profile_name
);
let app_handle = self.app_handle.clone();
let pid = self.placeholder_pid;
tauri::async_runtime::spawn(async move {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
log::warn!("Failed to stop proxy worker after failed launch: {e}");
}
});
}
}
}
let mut proxy_launch_guard = ProxyLaunchGuard {
app_handle: app_handle.clone(),
placeholder_pid: launch_placeholder_pid,
profile_name: profile.name.clone(),
armed: true,
};
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
@@ -317,11 +361,10 @@ impl BrowserRunner {
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
// The fresh fingerprint's location matches the current routing; record
// its signature so launches keep it in sync with the non-randomize
// path. Only when geolocation actually applied otherwise leave it
// unset so the refresh path can repair the location if the user later
// turns randomize off.
// Record which routing this fresh fingerprint's geolocation was built
// for (provenance only — nothing rewrites the fingerprint from it).
// Only when geolocation actually applied; otherwise leave it unset so a
// later on-demand match can tell the location was never resolved.
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
@@ -338,63 +381,15 @@ impl BrowserRunner {
profile.name,
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
);
} else {
// Safety net: the stored fingerprint's timezone and geolocation were
// computed for whatever proxy was set when the fingerprint was
// generated. If the profile's proxy or VPN has changed since (the
// common case being a user who forgot to set a proxy at creation and
// added one afterwards), that location data is stale and the user would
// see the wrong timezone on first launch. When the routing signature no
// longer matches, refresh just the location fields of the stored
// fingerprint through the current proxy. Wayfern only; the randomize
// path above already regenerates the whole fingerprint each launch.
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
profile.vpn_id.as_deref(),
wayfern_config.geoip.as_ref(),
);
let geo_enabled = !matches!(
wayfern_config.geoip.as_ref(),
Some(serde_json::Value::Bool(false))
);
if geo_enabled
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
{
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
log::info!(
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
profile.name,
wayfern_config.geo_proxy_signature,
current_geo_sig
);
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&stored_fp,
wayfern_config.proxy.as_deref(),
wayfern_config.geoip.as_ref(),
)
.await
{
Some(refreshed) => {
// Use the refreshed fingerprint for this launch...
wayfern_config.fingerprint = Some(refreshed.clone());
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
// ...and persist it so the corrected location sticks and we do
// not refresh again on the next launch with the same proxy.
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
cfg.fingerprint = Some(refreshed);
cfg.geo_proxy_signature = Some(current_geo_sig);
updated_profile.wayfern_config = Some(cfg);
}
None => {
log::warn!(
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
profile.name
);
}
}
}
}
}
// A non-randomize profile keeps its configured fingerprint verbatim, even
// when its proxy/VPN routing has changed since the fingerprint was built.
// We deliberately do NOT silently rewrite its timezone/language to match
// the new exit: that hid every real fingerprint-vs-exit mismatch (a US
// fingerprint behind a German exit would be quietly relabelled German
// before the launch-time consistency check could see it). The check now
// surfaces the mismatch, and the user re-matches on demand via
// `match_profile_fingerprint_to_exit`.
// Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected {
@@ -457,6 +452,10 @@ impl BrowserRunner {
format!("Failed to launch Wayfern: {e}").into()
})?;
// Browser is up and using the worker — failures past this point must
// not stop it.
proxy_launch_guard.armed = false;
// Get the process ID from launch result
let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}");
@@ -483,11 +482,18 @@ impl BrowserRunner {
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Update the proxy manager with the correct PID
if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, process_id) {
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
} else {
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
// Update the proxy manager with the correct PID. When the browser
// reported no PID, keep the entry keyed by its unique placeholder (which
// the cleanup sweep skips) rather than remapping to a shared 0 key that
// concurrent launches could collide on.
if process_id != 0 {
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
} else {
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
}
}
// Persist the real browser PID so the detached proxy worker self-reaps
@@ -1096,6 +1102,10 @@ impl BrowserRunner {
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} else if profile.clear_on_close {
// Awaited for the same reason as re-encryption above: a queued sync
// must see the cleared dir, not the pre-clear snapshot.
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
}
log::info!(
@@ -1296,11 +1306,8 @@ pub async fn launch_browser_profile_impl(
updated_profile.id
);
// Now update the proxy with the correct PID if we have one
if let Some(actual_pid) = updated_profile.process_id {
// Update the proxy manager with the correct PID (we always started with temp pid 1)
let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid);
}
// The proxy PID mapping was already reconciled inside launch_browser_internal
// (placeholder → real browser PID); nothing is ever keyed by a constant here.
Ok(updated_profile)
}
+459 -1
View File
@@ -16,6 +16,9 @@ pub enum BlocklistLevel {
Pro,
ProPlus,
Ultimate,
/// User-defined list: compiled from custom source URLs + custom block
/// domains, with custom allow domains removed (allowlist overrides).
Custom,
}
impl BlocklistLevel {
@@ -26,6 +29,7 @@ impl BlocklistLevel {
"pro" => Some(Self::Pro),
"pro_plus" => Some(Self::ProPlus),
"ultimate" => Some(Self::Ultimate),
"custom" => Some(Self::Custom),
"none" => Some(Self::None),
_ => None,
}
@@ -39,6 +43,7 @@ impl BlocklistLevel {
Self::Pro => "pro",
Self::ProPlus => "pro_plus",
Self::Ultimate => "ultimate",
Self::Custom => "custom",
}
}
@@ -50,12 +55,13 @@ impl BlocklistLevel {
Self::Pro => "Pro",
Self::ProPlus => "Pro++",
Self::Ultimate => "Ultimate",
Self::Custom => "Custom",
}
}
pub fn url(&self) -> Option<&'static str> {
match self {
Self::None => None,
Self::None | Self::Custom => None,
Self::Light => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
}
@@ -80,6 +86,7 @@ impl BlocklistLevel {
Self::Pro => Some("pro.txt"),
Self::ProPlus => Some("pro.plus.txt"),
Self::Ultimate => Some("ultimate.txt"),
Self::Custom => Some("custom.txt"),
}
}
@@ -94,6 +101,150 @@ impl BlocklistLevel {
}
}
/// User-defined DNS filtering: extra blocklist source URLs plus manual block /
/// allow domain rules. Allow rules override blocks (the standard exceptions
/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CustomDnsConfig {
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub block_domains: Vec<String>,
#[serde(default)]
pub allow_domains: Vec<String>,
/// When true the custom list is a strict allowlist: the browser may only
/// reach `allow_domains` (and their subdomains); everything else is blocked.
/// Sources/block_domains are ignored in this mode.
#[serde(default)]
pub allowlist_mode: bool,
#[serde(default)]
pub updated_at: Option<u64>,
}
fn normalize_domain(raw: &str) -> Option<String> {
let mut line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
return None;
}
// Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is
// common in public blocklists — take the domain after the sink IP rather
// than rejecting the whole line on the embedded space.
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") {
line = rest.trim();
}
}
// Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public
// hosts lists annotate entries this way; without this the whitespace guard
// below rejects every annotated line, silently dropping most of the source.
for marker in ['#', '!'] {
if let Some((before, _)) = line.split_once(marker) {
line = before.trim();
}
}
let d = line
.trim_start_matches("*.")
.trim_start_matches("||")
.trim_end_matches('^')
.trim_end_matches('.')
.to_lowercase();
if d.is_empty() {
return None;
}
// Reject anything still containing whitespace or a scheme — not a bare domain.
if d.contains(char::is_whitespace) || d.contains("://") {
return None;
}
Some(d)
}
impl CustomDnsConfig {
fn path() -> PathBuf {
app_dirs::data_subdir().join("custom_dns.json")
}
pub fn load() -> Self {
match std::fs::read_to_string(Self::path()) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let path = Self::path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())
}
/// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for
/// source URLs, `@@domain` for allow rules, bare domains for blocks.
pub fn to_txt(&self) -> String {
let mut out = String::new();
for s in &self.sources {
out.push_str("! source: ");
out.push_str(s);
out.push('\n');
}
for d in &self.allow_domains {
out.push_str("@@");
out.push_str(d);
out.push('\n');
}
for d in &self.block_domains {
out.push_str(d);
out.push('\n');
}
out
}
/// Parse a plain-text rule list back into a config (sources from
/// `! source:` lines, `@@`-prefixed as allow, bare domains as block).
pub fn from_txt(content: &str) -> Self {
let mut cfg = CustomDnsConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(src) = line.strip_prefix("! source:") {
let src = src.trim();
if !src.is_empty() {
cfg.sources.push(src.to_string());
}
continue;
}
if line.starts_with('#') || line.starts_with('!') {
continue;
}
if let Some(allow) = line.strip_prefix("@@") {
if let Some(d) = normalize_domain(allow) {
cfg.allow_domains.push(d);
}
} else if let Some(d) = normalize_domain(line) {
cfg.block_domains.push(d);
}
}
cfg.dedup();
cfg
}
/// Drop duplicates, preserving first-seen order so an exported rule list
/// still reads the way the user wrote it.
fn dedup(&mut self) {
for v in [
&mut self.sources,
&mut self.block_domains,
&mut self.allow_domains,
] {
let mut seen = std::collections::HashSet::new();
v.retain(|item| seen.insert(item.clone()));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlocklistCacheStatus {
pub level: String,
@@ -193,6 +344,19 @@ impl BlocklistManager {
}
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
if level == BlocklistLevel::Custom {
// Recompile only when the compiled file is missing or stale. Edits call
// compile_custom_blocklist directly and refresh_all_stale keeps sources
// current, so recompiling unconditionally would re-download every source
// on every profile launch — and this is awaited on the blocking launch
// path, so the browser cannot start until it finishes.
if let Some(path) = Self::cached_file_path(level) {
if Self::is_cache_fresh(level) {
return Ok(path);
}
}
return Self::compile_custom_blocklist().await;
}
if let Some(path) = Self::cached_file_path(level) {
if path.exists() {
return Ok(path);
@@ -201,6 +365,120 @@ impl BlocklistManager {
Self::fetch_blocklist(level).await
}
/// Compile the user's custom DNS file. In blocklist mode: fetch every source,
/// union with the manual block domains, then remove the allow domains
/// (allowlist overrides). In allowlist mode: the file is just the allow
/// domains — the worker then blocks everything NOT listed. Always rewrites
/// `custom.txt`.
pub async fn compile_custom_blocklist() -> Result<PathBuf, String> {
use std::collections::HashSet;
let config = CustomDnsConfig::load();
let mut domains: HashSet<String> = HashSet::new();
let path =
Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?;
if config.allowlist_mode {
// Strict allowlist: the compiled file is the set of permitted domains.
for d in &config.allow_domains {
if let Some(n) = normalize_domain(d) {
domains.insert(n);
}
}
} else {
// Fetch every source concurrently. Sequential awaits make this the sum of
// all source latencies, and it runs on the blocking profile-launch path.
let fetches = config.sources.iter().map(|source| async move {
match HTTP_CLIENT.get(source).send().await {
Ok(resp) if resp.status().is_success() => resp
.text()
.await
.map_err(|e| format!("custom source {source} body read failed: {e}")),
Ok(resp) => Err(format!(
"custom source {source} returned HTTP {}",
resp.status()
)),
Err(e) => Err(format!("custom source {source} failed: {e}")),
}
});
let mut source_failures = 0usize;
for result in futures_util::future::join_all(fetches).await {
match result {
Ok(body) => {
for line in body.lines() {
if let Some(d) = normalize_domain(line) {
domains.insert(d);
}
}
}
Err(e) => {
log::warn!("[dns-blocklist] {e}");
source_failures += 1;
}
}
}
// A failed source must never shrink the compiled list. Overwriting with a
// short (or empty) result would silently stop blocking whatever that
// source contributed — and `is_blocked` fails open on an empty set, so a
// single offline launch would disable custom filtering entirely and
// destroy the good cached list. Re-seed from the cached file so a network
// failure degrades to "stale" instead of "off"; manual rule edits below
// still apply, and the next successful compile rewrites cleanly.
if source_failures > 0 {
match std::fs::read_to_string(&path) {
Ok(cached) => {
let before = domains.len();
for line in cached.lines() {
if let Some(d) = normalize_domain(line) {
domains.insert(d);
}
}
log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
domains.len().saturating_sub(before)
);
}
Err(_) => log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
),
}
}
for d in &config.block_domains {
if let Some(n) = normalize_domain(d) {
domains.insert(n);
}
}
// Allow rules override blocks (exceptions).
for d in &config.allow_domains {
if let Some(n) = normalize_domain(d) {
domains.remove(&n);
}
}
}
let cache_dir = Self::cache_dir();
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
sorted.sort_unstable();
let body = sorted.join("\n");
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &body)
.map_err(|e| format!("Failed to write custom blocklist: {e}"))?;
std::fs::rename(&tmp_path, &path)
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
log::info!(
"[dns-blocklist] Compiled custom blocklist ({} domains)",
domains.len()
);
Ok(path)
}
pub async fn refresh_all_stale(&self) {
for &level in BlocklistLevel::all_downloadable() {
if !Self::is_cache_fresh(level) {
@@ -219,6 +497,13 @@ impl BlocklistManager {
}
}
}
// Recompile the custom list too so its sources track upstream changes.
let config = CustomDnsConfig::load();
if !config.sources.is_empty() || !config.block_domains.is_empty() {
if let Err(e) = Self::compile_custom_blocklist().await {
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
}
}
}
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
@@ -289,6 +574,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
Ok(CustomDnsConfig::load())
}
/// Normalize, persist, recompile and announce a custom DNS config. Shared by
/// the set and import commands so both apply the same normalization and the
/// same side effects — a step added here reaches both.
async fn persist_custom_config(mut config: CustomDnsConfig) -> Result<CustomDnsConfig, String> {
config.sources = std::mem::take(&mut config.sources)
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
config.block_domains = config
.block_domains
.iter()
.filter_map(|d| normalize_domain(d))
.collect();
config.allow_domains = config
.allow_domains
.iter()
.filter_map(|d| normalize_domain(d))
.collect();
config.updated_at = Some(crate::proxy_manager::now_secs());
config.dedup();
config
.save()
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?;
// Recompile so a profile relaunch picks up the new rules immediately.
let _ = BlocklistManager::compile_custom_blocklist().await;
let _ = crate::events::emit_empty("custom-dns-changed");
Ok(config)
}
/// Persist the custom DNS config and recompile the `custom.txt` list. Domains
/// are normalized; blank/comment entries are dropped.
#[tauri::command]
pub async fn set_custom_dns_config(
sources: Vec<String>,
block_domains: Vec<String>,
allow_domains: Vec<String>,
allowlist_mode: bool,
) -> Result<CustomDnsConfig, String> {
persist_custom_config(CustomDnsConfig {
sources,
block_domains,
allow_domains,
allowlist_mode,
updated_at: None,
})
.await
}
/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or
/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains).
#[tauri::command]
pub async fn import_custom_dns_rules(
content: String,
format: String,
) -> Result<CustomDnsConfig, String> {
let config = match format.as_str() {
"json" => serde_json::from_str::<CustomDnsConfig>(&content)
.map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?,
"txt" => CustomDnsConfig::from_txt(&content),
other => {
return Err(
serde_json::json!({
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
"params": { "format": other },
})
.to_string(),
)
}
};
persist_custom_config(config).await
}
/// Export the custom DNS rules as "json" or "txt".
#[tauri::command]
pub async fn export_custom_dns_rules(format: String) -> Result<String, String> {
let config = CustomDnsConfig::load();
match format.as_str() {
"json" => serde_json::to_string_pretty(&config)
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()),
"txt" => Ok(config.to_txt()),
other => Err(
serde_json::json!({
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
"params": { "format": other },
})
.to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -306,6 +687,83 @@ mod tests {
);
}
#[test]
fn test_custom_config_txt_roundtrip() {
let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n";
let cfg = CustomDnsConfig::from_txt(txt);
assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]);
assert_eq!(cfg.allow_domains, vec!["allowed.com"]);
assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]);
// Re-exporting produces parseable text.
let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt());
assert_eq!(reparsed.block_domains, cfg.block_domains);
assert_eq!(reparsed.allow_domains, cfg.allow_domains);
assert_eq!(reparsed.sources, cfg.sources);
}
#[test]
fn test_normalize_domain() {
assert_eq!(
normalize_domain("*.Example.COM"),
Some("example.com".into())
);
assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into()));
assert_eq!(normalize_domain(" "), None);
assert_eq!(normalize_domain("# comment"), None);
assert_eq!(normalize_domain("http://x.com"), None);
// Hosts-file format lines yield the domain, not None.
assert_eq!(
normalize_domain("0.0.0.0 ads.example.com"),
Some("ads.example.com".into())
);
assert_eq!(
normalize_domain("127.0.0.1\ttracker.net"),
Some("tracker.net".into())
);
// A bare two-token line that isn't hosts-format is still rejected.
assert_eq!(normalize_domain("foo bar"), None);
}
#[test]
fn test_normalize_domain_strips_trailing_comments() {
// Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without
// comment stripping the whitespace guard drops every annotated line, so a
// source compiles down to a fraction of its real domain count.
assert_eq!(
normalize_domain("0.0.0.0 ads.example.com # AdGuard"),
Some("ads.example.com".into())
);
assert_eq!(
normalize_domain("127.0.0.1 tracker.net #comment"),
Some("tracker.net".into())
);
assert_eq!(
normalize_domain("plain.example.com # trailing note"),
Some("plain.example.com".into())
);
assert_eq!(
normalize_domain("ads.example.com ! adblock note"),
Some("ads.example.com".into())
);
// A sink IP followed only by a comment has no domain left.
assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None);
// Full-line comments stay rejected.
assert_eq!(normalize_domain("! adblock header"), None);
}
#[test]
fn test_custom_level_roundtrip() {
assert_eq!(
BlocklistLevel::parse_level("custom"),
Some(BlocklistLevel::Custom)
);
assert_eq!(BlocklistLevel::Custom.as_str(), "custom");
assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt"));
assert!(BlocklistLevel::Custom.url().is_none());
// Custom must NOT be in the auto-refresh set (no upstream URL).
assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom));
}
#[test]
fn test_level_urls_all_present() {
for &level in BlocklistLevel::all_downloadable() {
+1
View File
@@ -280,6 +280,7 @@ mod tests {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
}
+381
View File
@@ -0,0 +1,381 @@
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
//! with the bundled MaxMind database (the same source the fingerprint generator
//! uses), then compare its timezone and country against the profile
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
//! behind a German exit IP) is a strong anti-bot tell even though the real
//! device never leaks — so we warn the user after launch and offer to match the
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
//! a real mismatch always surfaces here.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use crate::profile::types::BrowserProfile;
use crate::proxy_manager::PROXY_MANAGER;
/// Exit-node lookups are cached per proxy for this long. A stored proxy's exit
/// geolocation is stable enough that re-resolving the exit IP through the proxy
/// on every launch is wasteful.
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
#[derive(Clone)]
struct CachedExit {
fetched_at: u64,
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
/// its id, so without this an entry outlives the endpoint it describes: the
/// check would compare a re-generated fingerprint against the *old* exit and
/// either warn about a correct profile or — worse — call a genuinely
/// mismatched one consistent, which is exactly the tell it exists to catch.
proxy_url: String,
timezone: Option<String>,
country_code: Option<String>,
ip: Option<String>,
}
lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConsistencyResult {
/// True when everything we could check lines up (or there was nothing to
/// check — no proxy assigned).
pub consistent: bool,
/// True when we actually reached an exit node and compared something.
pub checked: bool,
pub exit_ip: Option<String>,
pub exit_country_code: Option<String>,
pub exit_timezone: Option<String>,
pub fingerprint_timezone: Option<String>,
pub fingerprint_language: Option<String>,
/// One of "timezone", "language" — the dimensions that disagree.
pub mismatches: Vec<String>,
}
impl ConsistencyResult {
fn skip() -> Self {
Self {
consistent: true,
checked: false,
exit_ip: None,
exit_country_code: None,
exit_timezone: None,
fingerprint_timezone: None,
fingerprint_language: None,
mismatches: Vec::new(),
}
}
}
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
/// drive (Shadowsocks).
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
),
_ => None,
}
}
/// Whether the fingerprint's language is plausible for the exit country.
///
/// Validated against the same CLDR data the fingerprint generator samples from
/// (`geolocation::LocaleSelector`), not a hand-written country->language table.
/// The generator picks a language at random weighted by CLDR speaker share, so
/// any table naming one "expected" language per country flags fingerprints
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
/// data and the check is skipped.
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
crate::geolocation::locale_selector()?.region_speaks(cc, language)
}
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
let Some(config) = &profile.wayfern_config else {
return (None, None);
};
let Some(fp_str) = &config.fingerprint else {
return (None, None);
};
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
return (None, None);
};
let timezone = fp
.get("timezone")
.and_then(|v| v.as_str())
.map(str::to_string);
let language = fp
.get("language")
.and_then(|v| v.as_str())
.map(str::to_string);
(timezone, language)
}
/// Run the check for a profile. No-ops (consistent, unchecked) when the
/// profile has no proxy or the exit node can't be reached.
pub async fn check_profile_consistency(
profile: &BrowserProfile,
) -> Result<ConsistencyResult, String> {
let Some(proxy_id) = &profile.proxy_id else {
return Ok(ConsistencyResult::skip());
};
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = proxy_url(&settings) else {
return Ok(ConsistencyResult::skip());
};
let now = crate::proxy_manager::now_secs();
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
// it was measured through the proxy's current endpoint and credentials.
let cached = {
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
.cloned()
};
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
(c.timezone, c.country_code, c.ip)
} else {
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
.await
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
let ip = Some(exit_ip);
EXIT_CACHE.lock().unwrap().insert(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: url.clone(),
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
},
);
(tz, cc, ip)
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
return Ok(ConsistencyResult::skip());
}
}
};
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut mismatches = Vec::new();
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
}
}
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
if language_matches_country(cc, lang) == Some(false) {
mismatches.push("language".to_string());
}
}
Ok(ConsistencyResult {
consistent: mismatches.is_empty(),
checked: true,
exit_ip,
exit_country_code: exit_cc,
exit_timezone: exit_tz,
fingerprint_timezone: fp_tz,
fingerprint_language: fp_lang,
mismatches,
})
}
#[tauri::command]
pub async fn check_profile_fingerprint_consistency(
profile_id: String,
) -> Result<ConsistencyResult, String> {
let profiles = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
check_profile_consistency(&profile).await
}
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
/// language, coordinates) matches `exit_ip`, and persist it. This is the
/// on-demand resolution for the consistency warning: launches no longer rewrite
/// the fingerprint silently, so the user opts into matching it here.
///
/// `exit_ip` is the exit the consistency check already resolved, applied via
/// MaxMind directly — no second proxy round-trip — and it forces geolocation
/// even when the profile has geo spoofing disabled, since the user explicitly
/// asked to match. Takes effect on the next launch of the profile.
#[tauri::command]
pub async fn match_profile_fingerprint_to_exit(
profile_id: String,
exit_ip: String,
) -> Result<(), String> {
let manager = crate::profile::ProfileManager::instance();
let mut profile = manager
.list_profiles()
.map_err(|e| e.to_string())?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
let mut config = profile
.wayfern_config
.clone()
.filter(|c| c.fingerprint.is_some())
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
let fingerprint = config.fingerprint.clone().unwrap();
let geoip_override = serde_json::Value::String(exit_ip);
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&fingerprint,
None,
Some(&geoip_override),
)
.await
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
config.fingerprint = Some(refreshed);
profile.wayfern_config = Some(config);
manager.save_profile(&profile).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_check_accepts_the_main_language_of_the_country() {
assert_eq!(language_matches_country("US", "en-US"), Some(true));
assert_eq!(language_matches_country("de", "de-DE"), Some(true));
assert_eq!(language_matches_country("BR", "pt-BR"), Some(true));
}
#[test]
fn language_check_accepts_what_the_fingerprint_generator_emits() {
// These are not the "expected" language for the country, but the generator
// samples the CLDR distribution and produces them routinely — CLDR puts es
// at 9.6% in the US and fr at 30% in Canada. Flagging them warns the user
// about a fingerprint Donut itself created.
assert_eq!(language_matches_country("US", "es-US"), Some(true));
assert_eq!(language_matches_country("CA", "fr-CA"), Some(true));
assert_eq!(language_matches_country("CA", "en-CA"), Some(true));
}
#[test]
fn language_check_flags_us_fingerprint_behind_armenian_exit() {
// The reported scenario: a US (en) fingerprint routed through an Armenian
// exit. Armenia's CLDR lists hy/ku/az, never en, so this must flag.
assert_eq!(language_matches_country("AM", "en-US"), Some(false));
// And a fingerprint actually matched to Armenia must not flag.
assert_eq!(language_matches_country("AM", "hy-AM"), Some(true));
}
#[test]
fn language_check_still_flags_implausible_combinations() {
// Nothing in CLDR associates these with the country, so they remain the
// anti-bot tell the check exists to surface. (Japan lists ja/ryu/ko only.)
assert_eq!(language_matches_country("JP", "pt-BR"), Some(false));
assert_eq!(language_matches_country("JP", "de-DE"), Some(false));
assert_eq!(language_matches_country("BR", "ru-RU"), Some(false));
}
#[test]
fn language_check_tolerates_minority_languages_the_generator_can_emit() {
// CLDR lists ja for Brazil (0.21%, the Japanese-Brazilian community) and de
// for the US (0.47%), and the generator samples both. They are weak signals
// but flagging them would contradict our own fingerprints, so the check
// accepts anything CLDR lists at all. That is the deliberate ceiling on
// this dimension — timezone, compared exactly, carries the real signal.
assert_eq!(language_matches_country("BR", "ja-JP"), Some(true));
assert_eq!(language_matches_country("US", "de-DE"), Some(true));
}
#[test]
fn language_check_skips_countries_without_cldr_data() {
// The old hand-written table returned None for ~180 countries and silently
// skipped the check; only genuinely unknown territories should do that now.
assert_eq!(language_matches_country("ZZ", "en-US"), None);
// Countries the old table never covered are now checked.
assert!(language_matches_country("ID", "id-ID").is_some());
assert!(language_matches_country("CH", "de-CH").is_some());
}
#[test]
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
let http = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "h".into(),
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
};
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
let reserved = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
};
assert_eq!(
proxy_url(&reserved).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
// Username-only proxies keep their auth.
let user_only = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "h".into(),
port: 1080,
username: Some("justuser".into()),
password: None,
};
assert_eq!(
proxy_url(&user_only).as_deref(),
Some("socks5://justuser@h:1080")
);
let ss = crate::browser::ProxySettings {
proxy_type: "ss".into(),
host: "h".into(),
port: 8080,
username: None,
password: None,
};
assert_eq!(proxy_url(&ss), None);
}
}
+44
View File
@@ -9,6 +9,7 @@ use rand::RngExt;
use std::collections::HashMap;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::OnceLock;
const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml");
@@ -41,6 +42,27 @@ pub enum GeolocationError {
Ip(#[from] IpError),
}
/// The language part of a locale or language tag: `"en"` from `"en-US"`,
/// `"zh"` from `"zh_Hant"`.
pub fn primary_subtag(tag: &str) -> &str {
tag.split(['-', '_']).next().unwrap_or(tag)
}
/// Shared selector over the bundled CLDR territory data. Parsing the XML costs
/// real time, and the data is immutable, so build it once.
pub fn locale_selector() -> Option<&'static LocaleSelector> {
static SELECTOR: OnceLock<Option<LocaleSelector>> = OnceLock::new();
SELECTOR
.get_or_init(|| match LocaleSelector::new() {
Ok(s) => Some(s),
Err(e) => {
log::warn!("Failed to build the CLDR locale selector: {e}");
None
}
})
.as_ref()
}
#[derive(Debug, Clone)]
pub struct Locale {
pub language: String,
@@ -152,6 +174,28 @@ impl LocaleSelector {
Ok(Self { territories })
}
/// Whether CLDR associates `language` with `region` at all.
///
/// Returns `None` for a territory with no language data, so callers can skip
/// rather than guess. The test is deliberately "is this language listed for
/// this country", not "is this the country's main language": `from_region`
/// samples the whole listed distribution weighted by speaker share, so every
/// listed language is one this selector itself can emit. Anything stricter
/// would flag fingerprints Donut generated on purpose — CLDR puts `es` at
/// 9.6% in the US and `fr` at 30% in Canada, and those get picked.
pub fn region_speaks(&self, region: &str, language: &str) -> Option<bool> {
let languages = self.territories.get(&region.to_uppercase())?;
if languages.is_empty() {
return None;
}
let primary = primary_subtag(language);
Some(
languages
.iter()
.any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)),
)
}
#[allow(clippy::wrong_self_convention)]
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
let region_upper = region.to_uppercase();
+31 -3
View File
@@ -29,6 +29,7 @@ mod downloader;
mod ephemeral_dirs;
mod extension_manager;
mod extraction;
mod fingerprint_consistency;
mod geoip_downloader;
mod geolocation;
mod group_manager;
@@ -68,9 +69,10 @@ use browser_runner::{
use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_profile_dns_blocklist, update_profile_launch_hook,
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config,
list_browser_profiles, rename_profile, update_profile_clear_on_close,
update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note,
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
update_profile_window_color, update_wayfern_config,
};
use profile::password::{
@@ -784,6 +786,13 @@ async fn clear_all_traffic_stats() -> Result<(), String> {
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
}
#[tauri::command]
async fn clear_profile_traffic_stats(profile_id: String) -> Result<(), String> {
crate::traffic_stats::delete_traffic_stats(&profile_id);
let _ = events::emit_empty("traffic-stats-changed");
Ok(())
}
#[tauri::command]
async fn get_traffic_stats_for_period(
profile_id: String,
@@ -1217,6 +1226,7 @@ async fn generate_sample_fingerprint(
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
};
@@ -2044,6 +2054,16 @@ pub fn run() {
.await;
}
// Clear-on-close for natural exits (user closed the window).
// The explicit kill path in browser_runner.rs handles
// app-driven stops. Must also run before
// `mark_profile_stopped` so a queued sync sees the cleared
// dir rather than re-uploading the wiped browsing data.
if !is_running {
crate::profile::clear_on_close::clear_profile_browsing_data(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -2238,6 +2258,7 @@ pub fn run() {
update_profile_vpn,
update_profile_tags,
update_profile_note,
update_profile_clear_on_close,
update_profile_launch_hook,
update_profile_window_color,
update_profile_proxy_bypass_rules,
@@ -2319,7 +2340,10 @@ pub fn run() {
get_all_traffic_snapshots,
get_profile_traffic_snapshot,
clear_all_traffic_stats,
clear_profile_traffic_stats,
get_traffic_stats_for_period,
fingerprint_consistency::check_profile_fingerprint_consistency,
fingerprint_consistency::match_profile_fingerprint_to_exit,
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
@@ -2395,6 +2419,10 @@ pub fn run() {
// DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists,
dns_blocklist::get_custom_dns_config,
dns_blocklist::set_custom_dns_config,
dns_blocklist::import_custom_dns_rules,
dns_blocklist::export_custom_dns_rules,
// Profile password commands
set_profile_password,
change_profile_password,
+16
View File
@@ -673,6 +673,10 @@ impl McpServer {
"proxy_id": {
"type": "string",
"description": "Optional proxy UUID to assign to this profile"
},
"vpn_id": {
"type": "string",
"description": "Optional VPN UUID to assign to this profile"
}
},
"required": ["source_path", "new_profile_name"]
@@ -731,6 +735,10 @@ impl McpServer {
"type": "array",
"items": { "type": "string" },
"description": "Proxy bypass rules (replaces existing rules)"
},
"clear_on_close": {
"type": "boolean",
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
}
},
"required": ["profile_id"]
@@ -2547,6 +2555,14 @@ impl McpServer {
})?;
}
if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) {
pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update clear-on-close: {e}"),
})?;
}
Ok(serde_json::json!({
"content": [{
"type": "text",
+292
View File
@@ -0,0 +1,292 @@
//! Clear-on-close: wipe a profile's browsing data when the browser exits,
//! keeping extensions and bookmarks (and the settings files Chromium needs to
//! keep those working). The middle ground between a fully persistent profile
//! and a RAM-backed ephemeral one.
use std::fs;
use std::path::Path;
use crate::profile::types::BrowserProfile;
/// Entries kept inside a Chromium profile directory (one with `Preferences`).
/// Everything else — cookies, History, caches, storage, sessions, autofill,
/// saved logins — is browsing data and gets removed.
const PROFILE_KEEP: &[&str] = &[
"Bookmarks",
"Bookmarks.bak",
"Extensions",
"Extension State",
"Extension Rules",
"Extension Scripts",
"Extension Cookies",
"Local Extension Settings",
"Managed Extension Settings",
// Preferences hold the extension registry + user settings; deleting them
// disables every installed extension, so they stay.
"Preferences",
"Secure Preferences",
];
/// Entries kept at the user-data-dir top level (outside profile subdirs).
const TOP_KEEP: &[&str] = &["Local State", "First Run"];
fn is_kept(name: &str) -> bool {
PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name)
}
/// Whether a top-level entry name is one of Chromium's profile directories.
///
/// Identity must not rest on `Preferences` existing. Chromium writes it lazily,
/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting
/// step since it regenerates — leaves a populated `Default/` without it. Such a
/// directory would then be taken for junk and removed wholesale, destroying the
/// Extensions and Bookmarks this feature exists to preserve.
fn is_profile_dir_name(name: &str) -> bool {
matches!(name, "Default" | "Guest Profile" | "System Profile")
|| name
.strip_prefix("Profile ")
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
}
fn remove_entry(path: &Path) {
let result = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
if let Err(e) = result {
log::warn!("clear-on-close: failed to remove {}: {e}", path.display());
}
}
/// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP`
/// (and `TOP_KEEP`, harmless at this level).
fn clear_chromium_profile_dir(profile_dir: &Path) -> usize {
let mut cleared = 0usize;
let Ok(entries) = fs::read_dir(profile_dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if is_kept(name) {
continue;
}
remove_entry(&entry.path());
cleared += 1;
}
cleared
}
/// Clear browsing data in a Wayfern user-data directory. Handles both
/// layouts: profile content at the root (imported profiles copy a Chromium
/// profile dir directly) and the standard `Default` / `Profile N` subdirs
/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache,
/// component_crx_cache, …) are removed; `Local State` stays because it holds
/// the os_crypt key extensions may rely on.
pub fn clear_user_data_dir(user_data_dir: &Path) -> usize {
if !user_data_dir.exists() {
return 0;
}
// Root itself is a profile dir (legacy/imported layout).
if user_data_dir.join("Preferences").exists() {
return clear_chromium_profile_dir(user_data_dir);
}
let mut cleared = 0usize;
let Ok(entries) = fs::read_dir(user_data_dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if is_kept(name) {
continue;
}
let path = entry.path();
if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) {
// A profile subdir (Default / Profile N) — clear inside, keep the dir.
cleared += clear_chromium_profile_dir(&path);
} else {
remove_entry(&path);
cleared += 1;
}
}
cleared
}
/// Clear a profile's browsing data if its `clear_on_close` flag is set.
/// No-ops for ephemeral (already wiped) and password-protected (dir is
/// re-encrypted; plaintext never persists) profiles. Runs the filesystem work
/// on a blocking thread.
pub async fn clear_profile_browsing_data(profile: &BrowserProfile) {
if !profile.clear_on_close || profile.ephemeral || profile.password_protected {
return;
}
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
let name = profile.name.clone();
let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir))
.await
.unwrap_or(0);
log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'");
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(dir: &Path, name: &str) {
fs::write(dir.join(name), "x").unwrap();
}
fn mkdir(dir: &Path, name: &str) {
fs::create_dir_all(dir.join(name)).unwrap();
}
#[test]
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Preferences");
touch(dir, "Secure Preferences");
touch(dir, "Bookmarks");
touch(dir, "History");
touch(dir, "Web Data");
touch(dir, "Login Data");
mkdir(dir, "Extensions");
mkdir(dir, "Local Extension Settings");
mkdir(dir, "Cache");
mkdir(dir, "Network");
touch(&dir.join("Network"), "Cookies");
mkdir(dir, "Local Storage");
let cleared = clear_user_data_dir(dir);
assert!(
cleared >= 5,
"should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}"
);
assert!(dir.join("Preferences").exists());
assert!(dir.join("Bookmarks").exists());
assert!(dir.join("Extensions").exists());
assert!(dir.join("Local Extension Settings").exists());
assert!(!dir.join("History").exists());
assert!(!dir.join("Web Data").exists());
assert!(!dir.join("Login Data").exists());
assert!(!dir.join("Cache").exists());
assert!(!dir.join("Network").exists(), "Network/Cookies must go");
assert!(!dir.join("Local Storage").exists());
}
#[test]
fn clears_standard_user_data_layout() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Local State");
mkdir(dir, "ShaderCache");
mkdir(dir, "Default");
let default = dir.join("Default");
touch(&default, "Preferences");
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
assert!(dir.join("Local State").exists());
assert!(!dir.join("ShaderCache").exists());
assert!(default.join("Preferences").exists());
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
}
#[test]
fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() {
// Chromium writes Preferences lazily, so a crash (or a user removing a
// corrupt copy) can leave a populated Default/ without it. Identifying
// profile dirs by Preferences alone would delete the whole directory.
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Local State");
mkdir(dir, "Default");
let default = dir.join("Default");
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
mkdir(&default, "Local Extension Settings");
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
assert!(default.exists(), "the profile dir must survive");
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert!(default.join("Local Extension Settings").exists());
// Browsing data inside it is still cleared.
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
}
#[test]
fn numbered_profile_dirs_are_recognised() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
mkdir(dir, "Profile 2");
let p2 = dir.join("Profile 2");
touch(&p2, "Bookmarks");
touch(&p2, "History");
clear_user_data_dir(dir);
assert!(p2.join("Bookmarks").exists());
assert!(!p2.join("History").exists());
}
#[test]
fn non_profile_dirs_are_still_removed_wholesale() {
// The permissive branch must not turn into "never delete a directory":
// top-level caches are browsing data and have to go.
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
mkdir(dir, "ShaderCache");
mkdir(dir, "component_crx_cache");
mkdir(dir, "Profileless");
clear_user_data_dir(dir);
assert!(!dir.join("ShaderCache").exists());
assert!(!dir.join("component_crx_cache").exists());
assert!(
!dir.join("Profileless").exists(),
"a name that merely starts with 'Profile' is not a profile dir"
);
}
#[test]
fn profile_dir_name_matching() {
assert!(is_profile_dir_name("Default"));
assert!(is_profile_dir_name("Profile 1"));
assert!(is_profile_dir_name("Profile 42"));
assert!(is_profile_dir_name("Guest Profile"));
assert!(is_profile_dir_name("System Profile"));
assert!(!is_profile_dir_name("Profile"));
assert!(!is_profile_dir_name("Profile "));
assert!(!is_profile_dir_name("Profile abc"));
assert!(!is_profile_dir_name("ShaderCache"));
}
#[test]
fn missing_dir_is_a_noop() {
let tmp = TempDir::new().unwrap();
assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0);
}
}
+52
View File
@@ -213,6 +213,7 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
};
@@ -299,6 +300,7 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist,
password_protected: false,
clear_on_close: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -746,6 +748,44 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_clear_on_close(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
clear_on_close: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Ephemeral profiles are already wiped on close; password-protected ones
// re-encrypt and never persist plaintext — the flag is meaningless there.
if clear_on_close && (profile.ephemeral || profile.password_protected) {
return Err(
serde_json::json!({ "code": "CLEAR_ON_CLOSE_UNAVAILABLE" })
.to_string()
.into(),
);
}
profile.clear_on_close = clear_on_close;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn update_profile_window_color(
&self,
_app_handle: &tauri::AppHandle,
@@ -1010,6 +1050,7 @@ impl ProfileManager {
created_by_email: None,
dns_blocklist: source.dns_blocklist,
password_protected: false,
clear_on_close: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1737,6 +1778,17 @@ pub fn update_profile_window_color(
.map_err(|e| format!("Failed to update profile window color: {e}"))
}
#[tauri::command]
pub fn update_profile_clear_on_close(
app_handle: tauri::AppHandle,
profile_id: String,
clear_on_close: bool,
) -> Result<BrowserProfile, String> {
ProfileManager::instance()
.update_profile_clear_on_close(&app_handle, &profile_id, clear_on_close)
.map_err(crate::profile_importer::error_to_code_string)
}
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
+1
View File
@@ -1,3 +1,4 @@
pub mod clear_on_close;
pub mod encryption;
pub mod manager;
pub mod password;
+4
View File
@@ -72,6 +72,10 @@ pub struct BrowserProfile {
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)]
pub password_protected: bool,
/// Wipe browsing data (except extensions and bookmarks) when the browser
/// exits. Ignored for ephemeral and password-protected profiles.
#[serde(default)]
pub clear_on_close: bool,
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
/// profiles that pre-date this field — those are treated as ancient by
/// any staleness check.
+280 -102
View File
@@ -33,6 +33,8 @@ pub struct ImportProfileItem {
pub new_profile_name: String,
#[serde(default)]
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
}
fn default_import_browser_type() -> String {
@@ -132,6 +134,15 @@ fn emit_import_progress(total: usize, completed: usize, index: usize, name: &str
);
}
/// A known Chromium-family browser install location.
struct BrowserSource {
key: &'static str,
dir: PathBuf,
/// Opera-style quirk: the profile (Preferences, Cookies, …) lives at the
/// root of the config dir instead of under `Default/`.
root_profile: bool,
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -161,9 +172,50 @@ impl ProfileImporter {
// Only Chromium-based sources (mapping to Wayfern) are detected. Gecko-family
// sources mapped to Camoufox, which was removed, so they can no longer be
// imported.
detected_profiles.extend(self.detect_chrome_profiles()?);
detected_profiles.extend(self.detect_brave_profiles()?);
detected_profiles.extend(self.detect_chromium_profiles()?);
for source in self.browser_sources() {
if source.root_profile {
// Opera-style layout: the user-data root itself is the profile.
if source.dir.join("Preferences").exists() {
detected_profiles.push(DetectedProfile {
browser: source.key.to_string(),
mapped_browser: map_browser_type(source.key).to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(source.key)
),
path: source.dir.to_string_lossy().to_string(),
description: "Default profile".to_string(),
});
}
// Newer Opera builds keep extra profiles under _side_profiles/.
let side = source.dir.join("_side_profiles");
if let Ok(entries) = fs::read_dir(&side) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("Preferences").exists() {
let dir_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("profile")
.to_string();
detected_profiles.push(DetectedProfile {
browser: source.key.to_string(),
mapped_browser: map_browser_type(source.key).to_string(),
name: format!(
"{} - {}",
self.get_browser_display_name(source.key),
dir_name
),
path: path.to_string_lossy().to_string(),
description: format!("Side profile {dir_name}"),
});
}
}
}
} else {
detected_profiles.extend(self.scan_chrome_profiles_dir(&source.dir, source.key)?);
}
}
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -174,6 +226,162 @@ impl ProfileImporter {
Ok(unique_profiles)
}
/// Every Chromium-family browser we know how to find, with its per-OS
/// user-data directory. `root_profile` marks the Opera-style quirk where the
/// profile lives at the root of the config dir instead of `Default/`.
fn browser_sources(&self) -> Vec<BrowserSource> {
let mut sources: Vec<BrowserSource> = Vec::new();
#[cfg(target_os = "macos")]
{
let support = self
.base_dirs
.home_dir()
.join("Library/Application Support");
let standard: &[(&str, &str)] = &[
("chromium", "Google/Chrome"),
("chrome-beta", "Google/Chrome Beta"),
("chrome-dev", "Google/Chrome Dev"),
("chrome-canary", "Google/Chrome Canary"),
("chromium", "Chromium"),
("brave", "BraveSoftware/Brave-Browser"),
("brave-beta", "BraveSoftware/Brave-Browser-Beta"),
("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"),
("edge", "Microsoft Edge"),
("edge-beta", "Microsoft Edge Beta"),
("edge-dev", "Microsoft Edge Dev"),
("vivaldi", "Vivaldi"),
// Arc nests its user-data dir one level down, unlike Chrome.
("arc", "Arc/User Data"),
("yandex", "Yandex/YandexBrowser"),
];
for (key, rel) in standard {
sources.push(BrowserSource {
key,
dir: support.join(rel),
root_profile: false,
});
}
for (key, rel) in &[
("opera", "com.operasoftware.Opera"),
("opera-gx", "com.operasoftware.OperaGX"),
] {
sources.push(BrowserSource {
key,
dir: support.join(rel),
root_profile: true,
});
}
}
#[cfg(target_os = "windows")]
{
let local = self.base_dirs.data_local_dir().to_path_buf();
let standard: &[(&str, &str)] = &[
("chromium", "Google/Chrome/User Data"),
("chrome-beta", "Google/Chrome Beta/User Data"),
("chrome-dev", "Google/Chrome Dev/User Data"),
// Canary installs as "Chrome SxS" (side-by-side), not "Chrome Canary".
("chrome-canary", "Google/Chrome SxS/User Data"),
("chromium", "Chromium/User Data"),
("brave", "BraveSoftware/Brave-Browser/User Data"),
("brave-beta", "BraveSoftware/Brave-Browser-Beta/User Data"),
(
"brave-nightly",
"BraveSoftware/Brave-Browser-Nightly/User Data",
),
("edge", "Microsoft/Edge/User Data"),
("edge-beta", "Microsoft/Edge Beta/User Data"),
("edge-dev", "Microsoft/Edge Dev/User Data"),
("vivaldi", "Vivaldi/User Data"),
("yandex", "Yandex/YandexBrowser/User Data"),
];
for (key, rel) in standard {
sources.push(BrowserSource {
key,
dir: local.join(rel),
root_profile: false,
});
}
// Opera keeps the profile under %APPDATA% (Roaming), not %LOCALAPPDATA%.
let roaming = self.base_dirs.data_dir().to_path_buf();
for (key, rel) in &[
("opera", "Opera Software/Opera Stable"),
("opera-gx", "Opera Software/Opera GX Stable"),
] {
sources.push(BrowserSource {
key,
dir: roaming.join(rel),
root_profile: true,
});
}
// Arc on Windows is MSIX-packaged; the package-family suffix can vary,
// so glob Packages/TheBrowserCompany.Arc_*.
let packages = local.join("Packages");
if let Ok(entries) = fs::read_dir(&packages) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with("TheBrowserCompany.Arc_") {
sources.push(BrowserSource {
key: "arc",
dir: entry.path().join("LocalCache/Local/Arc/User Data"),
root_profile: false,
});
}
}
}
}
#[cfg(target_os = "linux")]
{
let home = self.base_dirs.home_dir().to_path_buf();
let config = home.join(".config");
let standard: &[(&str, &str)] = &[
("chromium", "google-chrome"),
("chrome-beta", "google-chrome-beta"),
// The Linux Dev channel dir is google-chrome-unstable.
("chrome-dev", "google-chrome-unstable"),
("chromium", "chromium"),
("brave", "BraveSoftware/Brave-Browser"),
("brave-beta", "BraveSoftware/Brave-Browser-Beta"),
("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"),
("edge", "microsoft-edge"),
("edge-beta", "microsoft-edge-beta"),
("edge-dev", "microsoft-edge-dev"),
("vivaldi", "vivaldi"),
("yandex", "yandex-browser"),
// The long-standing Linux package ships as the beta channel.
("yandex", "yandex-browser-beta"),
];
for (key, rel) in standard {
sources.push(BrowserSource {
key,
dir: config.join(rel),
root_profile: false,
});
}
sources.push(BrowserSource {
key: "opera",
dir: config.join("opera"),
root_profile: true,
});
// Distro-packaged Chromium fallbacks.
sources.push(BrowserSource {
key: "chromium",
dir: home.join("snap/chromium/common/chromium"),
root_profile: false,
});
sources.push(BrowserSource {
key: "chromium",
dir: home.join(".var/app/org.chromium.Chromium/config/chromium"),
root_profile: false,
});
}
sources
}
/// Scan an arbitrary folder for importable Chromium-family profiles.
/// Handles three shapes: the folder itself is a profile (has `Preferences`),
/// the folder is a user-data dir (`Default` / `Profile N` children), or the
@@ -349,93 +557,6 @@ impl ProfileImporter {
Ok(())
}
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chrome_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Google/Chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[cfg(target_os = "windows")]
{
let local_app_data = self.base_dirs.data_local_dir();
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
{
let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome");
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
}
Ok(profiles)
}
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let chromium_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[cfg(target_os = "windows")]
{
let local_app_data = self.base_dirs.data_local_dir();
let chromium_dir = local_app_data.join("Chromium/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
#[cfg(target_os = "linux")]
{
let chromium_dir = self.base_dirs.home_dir().join(".config/chromium");
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
}
Ok(profiles)
}
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
#[cfg(target_os = "macos")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[cfg(target_os = "windows")]
{
let local_app_data = self.base_dirs.data_local_dir();
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
#[cfg(target_os = "linux")]
{
let brave_dir = self
.base_dirs
.home_dir()
.join(".config/BraveSoftware/Brave-Browser");
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
}
Ok(profiles)
}
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -491,7 +612,20 @@ impl ProfileImporter {
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"chromium" => "Chrome/Chromium",
"chrome-beta" => "Chrome Beta",
"chrome-dev" => "Chrome Dev",
"chrome-canary" => "Chrome Canary",
"brave" => "Brave",
"brave-beta" => "Brave Beta",
"brave-nightly" => "Brave Nightly",
"edge" => "Microsoft Edge",
"edge-beta" => "Edge Beta",
"edge-dev" => "Edge Dev",
"opera" => "Opera",
"opera-gx" => "Opera GX",
"vivaldi" => "Vivaldi",
"arc" => "Arc",
"yandex" => "Yandex Browser",
"zen" => "Zen Browser",
"wayfern" => "Wayfern",
@@ -528,6 +662,37 @@ impl ProfileImporter {
}
}
// Gate the paid fingerprint-OS override here rather than at each call site.
// `wayfern_config` is only ever consumed by this function, so a caller that
// forgets the check (the REST and MCP surfaces each had their own copy)
// would bypass the restriction with no compile error.
let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref());
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err(
serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" })
.to_string()
.into(),
);
}
// A profile routes through a proxy or a VPN, never both: create_profile_with_group
// rejects it, and at launch browser_runner resolves the proxy first and
// silently ignores the VPN. The importer saves profiles directly, so
// without this it is the one way to persist the invalid combination.
if items
.iter()
.any(|i| i.proxy_id.is_some() && i.vpn_id.is_some())
{
return Err(
serde_json::json!({ "code": "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE" })
.to_string()
.into(),
);
}
let mut taken_names: HashSet<String> = self
.profile_manager
.list_profiles()?
@@ -589,6 +754,7 @@ impl ProfileImporter {
&item.browser_type,
&final_name,
item.proxy_id.clone(),
item.vpn_id.clone(),
group_id.clone(),
wayfern_config.clone(),
)
@@ -640,6 +806,7 @@ impl ProfileImporter {
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
vpn_id: Option<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -683,11 +850,28 @@ impl ProfileImporter {
// Profile dirs can be multiple GB — keep the copy off the async runtime.
let copy_source = source_path.to_path_buf();
let copy_dest = new_profile_data_dir.clone();
let copy_result = tokio::task::spawn_blocking(move || {
let copy_result = match tokio::task::spawn_blocking(move || {
Self::copy_directory_recursive(&copy_source, &copy_dest).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("Profile copy task failed: {e}"))?;
{
Ok(r) => r,
Err(e) => {
// The copy task died (panic, or runtime shutdown mid-import). Clean up
// like every other error path here, or the half-copied — possibly
// multi-GB — directory is orphaned with no metadata pointing at it, so
// nothing ever reclaims it.
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
"params": { "detail": format!("Profile copy task failed: {e}") },
})
.to_string()
.into(),
);
}
};
if let Err(e) = copy_result {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
@@ -761,6 +945,7 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: None,
updated_at: None,
};
@@ -799,7 +984,7 @@ impl ProfileImporter {
browser: mapped.to_string(),
version,
proxy_id,
vpn_id: None,
vpn_id,
launch_hook: None,
process_id: None,
last_launch: None,
@@ -820,6 +1005,7 @@ impl ProfileImporter {
created_by_email: None,
dns_blocklist: None,
password_protected: false,
clear_on_close: false,
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -924,15 +1110,7 @@ pub async fn import_browser_profiles(
duplicate_strategy: Option<DuplicateStrategy>,
wayfern_config: Option<WayfernConfig>,
) -> Result<ProfileImportBatchResult, String> {
let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref());
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
// The Pro gate for fingerprint OS spoofing lives inside import_profiles.
let importer = ProfileImporter::instance();
importer
.import_profiles(
+79 -9
View File
@@ -137,6 +137,25 @@ pub fn now_secs() -> u64 {
.as_secs()
}
/// Keys in `active_proxies` at or above this value are in-flight launch
/// placeholders, not real browser PIDs. Real OS PIDs never reach this range
/// (Linux pid_max caps at 2^22, macOS at ~100k, Windows PIDs are small DWORD
/// multiples of 4), so a placeholder can never shadow a live process ID.
pub const LAUNCH_PLACEHOLDER_PID_MIN: u32 = u32::MAX - 0x00FF_FFFF;
static NEXT_LAUNCH_PLACEHOLDER_PID: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(u32::MAX);
/// Allocate a unique `active_proxies` key for a launch in flight, so concurrent
/// launches can never overwrite each other's placeholder entry.
pub fn next_launch_placeholder_pid() -> u32 {
NEXT_LAUNCH_PLACEHOLDER_PID.fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn is_launch_placeholder_pid(pid: u32) -> bool {
pid >= LAUNCH_PLACEHOLDER_PID_MIN
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -1040,8 +1059,10 @@ impl ProxyManager {
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
}
// Build proxy URL string from ProxySettings
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
// Build proxy URL string from ProxySettings. Credentials are percent-encoded:
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
// authority and silently retargets the request at the wrong host.
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
let mut url = format!("{}://", proxy_settings.proxy_type);
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
@@ -1081,9 +1102,17 @@ impl ProxyManager {
.map_err(|e| e.to_string());
let ip_result = match proxy_start_result {
Ok(proxy_config) => {
Ok(mut proxy_config) => {
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let config_id = proxy_config.id.clone();
// Tie the check worker's lifetime to this GUI process: the worker's
// PID watchdog self-exits when browser_pid dies, so if the app is
// killed mid-check the worker follows instead of idling until the
// next app launch.
proxy_config.browser_pid = Some(std::process::id());
if !crate::proxy_storage::update_proxy_config(&proxy_config) {
log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry");
}
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
// if the upstream is slow or unreachable.
let result = tokio::time::timeout(
@@ -1493,6 +1522,7 @@ impl ProxyManager {
profile_id: Option<&str>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
local_protocol: &str,
@@ -1626,6 +1656,9 @@ impl ProxyManager {
// Add blocklist file path if provided
if let Some(ref path) = blocklist_file {
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
if dns_allowlist_mode {
proxy_cmd = proxy_cmd.arg("--dns-allowlist-mode");
}
}
// Tell the worker which protocol to serve the browser (http or socks5)
@@ -1696,6 +1729,10 @@ impl ProxyManager {
}
}
if !ready {
// The detached worker is already running with its config on disk, but
// it was never registered in active_proxies — no cleanup pass could
// ever find it, so it must be killed before this error propagates.
let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await;
return Err(format!(
"Local proxy on 127.0.0.1:{} did not become ready in time",
proxy_info.local_port
@@ -1868,9 +1905,9 @@ impl ProxyManager {
/// Persist the real browser PID onto the worker's on-disk config so the
/// detached worker can self-terminate when that browser dies, independent of
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
/// launch that collides across concurrent launches, which could tag a live
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
/// PID-keyed `active_proxies` map: the latter is keyed by a per-launch
/// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
/// find the worker for a profile mid-launch. Safe on the reuse
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
/// of 0 (launch failed to report a PID) is ignored so the worker never
/// self-exits against a bogus PID.
@@ -2090,9 +2127,9 @@ impl ProxyManager {
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (browser_pid, proxy_id, profile_id) in snapshot {
snapshot_pids.insert(browser_pid);
// The sentinel PID=0 is used as a placeholder during launch,
// before update_proxy_pid has recorded the real browser PID.
if browser_pid == 0 {
// Launch placeholders (and the legacy 0 sentinel) are not real
// browser PIDs — update_proxy_pid hasn't recorded the real one yet.
if browser_pid == 0 || is_launch_placeholder_pid(browser_pid) {
continue;
}
if system
@@ -2758,6 +2795,35 @@ mod tests {
assert!(result.unwrap_err().contains("No proxy found for PID 777"));
}
#[test]
fn test_launch_placeholder_pids_unique_and_reconcile_independently() {
let a = next_launch_placeholder_pid();
let b = next_launch_placeholder_pid();
assert_ne!(a, b);
assert!(is_launch_placeholder_pid(a));
assert!(is_launch_placeholder_pid(b));
// Real PIDs (and the legacy 0 sentinel) are never in the placeholder range.
assert!(!is_launch_placeholder_pid(0));
assert!(!is_launch_placeholder_pid(1));
assert!(!is_launch_placeholder_pid(4_194_304)); // Linux pid_max
assert!(!is_launch_placeholder_pid(std::process::id()));
// Two concurrent launches with distinct placeholder keys: finishing
// launch A must not remap or evict launch B's in-flight entry.
let pm = ProxyManager::new();
pm.insert_active_proxy(a, make_proxy_info("px_launch_a", 9201, Some("prof_a")));
pm.insert_active_proxy(b, make_proxy_info("px_launch_b", 9202, Some("prof_b")));
pm.update_proxy_pid(a, 3001).unwrap();
assert_eq!(pm.get_active_proxy(3001).unwrap().id, "px_launch_a");
assert_eq!(pm.get_active_proxy(b).unwrap().id, "px_launch_b");
pm.update_proxy_pid(b, 3002).unwrap();
assert_eq!(pm.get_active_proxy(3002).unwrap().id, "px_launch_b");
assert!(pm.get_active_proxy(a).is_none());
assert!(pm.get_active_proxy(b).is_none());
}
#[test]
fn test_profile_proxy_id_mapping_tracks_active_proxy() {
let pm = ProxyManager::new();
@@ -2930,6 +2996,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None,
browser_pid: None,
};
@@ -2943,6 +3010,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None,
browser_pid: None,
};
@@ -2984,6 +3052,7 @@ mod tests {
profile_id: Some("prof_abc".to_string()),
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None,
browser_pid: None,
};
@@ -3304,6 +3373,7 @@ mod tests {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None,
browser_pid: None,
};
+15 -15
View File
@@ -160,15 +160,17 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await
}
#[allow(clippy::too_many_arguments)]
pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>,
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
blocklist_file: Option<String>,
dns_allowlist_mode: bool,
local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
@@ -185,6 +187,7 @@ pub async fn start_proxy_process_with_profile(
.with_profile_id(profile_id.clone())
.with_bypass_rules(bypass_rules)
.with_blocklist_file(blocklist_file)
.with_dns_allowlist_mode(dns_allowlist_mode)
.with_local_protocol(local_protocol);
save_proxy_config(&config)?;
@@ -370,24 +373,21 @@ pub async fn start_proxy_process_with_profile(
attempts += 1;
if attempts >= max_attempts {
// Try to get the config one more time for better error message
if let Some(config) = get_proxy_config(&id) {
let detail = if let Some(config) = get_proxy_config(&id) {
// Check if process is still running
let process_running = config.pid.map(is_process_running).unwrap_or(false);
return Err(
format!(
"Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
config.id, config.local_url, config.local_port, config.pid, process_running
)
.into(),
);
}
return Err(
format!(
"Proxy worker failed to start in time. Config not found for id: {}",
id
"Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
config.id, config.local_url, config.local_port, config.pid, process_running
)
.into(),
);
} else {
format!("Config not found for id: {}", id)
};
// The detached worker (if it did spawn) would otherwise outlive this
// failed start with nothing tracking it — callers only get an error
// string, so this is the last place that can still kill it.
let _ = stop_proxy_process(&id).await;
return Err(format!("Proxy worker failed to start in time. {detail}").into());
}
}
}
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -16,6 +16,10 @@ pub struct ProxyConfig {
pub bypass_rules: Vec<String>,
#[serde(default)]
pub blocklist_file: Option<String>,
/// When true, `blocklist_file` is treated as an ALLOW list: the browser may
/// only reach domains in the file; everything else is blocked.
#[serde(default)]
pub dns_allowlist_mode: bool,
/// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
@@ -42,6 +46,7 @@ impl ProxyConfig {
profile_id: None,
bypass_rules: Vec::new(),
blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None,
browser_pid: None,
}
@@ -62,6 +67,11 @@ impl ProxyConfig {
self
}
pub fn with_dns_allowlist_mode(mut self, allowlist_mode: bool) -> Self {
self.dns_allowlist_mode = allowlist_mode;
self
}
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
self.local_protocol = local_protocol;
self
@@ -167,11 +177,18 @@ pub fn generate_proxy_id() -> String {
}
pub fn is_process_running(pid: u32) -> bool {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
let pid = sysinfo::Pid::from_u32(pid);
// Refresh only the queried PID with the minimal refresh kind: this is a
// pure existence check, and callers (worker supervisors every 15s, GUI
// cleanup loops) must not pay for a full system process-table scan.
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
);
system.process(sysinfo::Pid::from_u32(pid)).is_some()
system.process(pid).is_some()
}
#[cfg(test)]
+282 -13
View File
@@ -13,13 +13,21 @@
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
//! the association) the request is refused, so Chromium falls back to proxied
//! TCP rather than sending UDP from the real IP.
//!
//! Both commands enforce the same DNS blocklist and feed the same traffic
//! tracker as the HTTP front-end: CONNECT via `handle_connect`, and every UDP
//! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an
//! unfiltered, unmetered side channel around the TCP filter.
use crate::proxy_server::{
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
};
use crate::traffic_stats::get_traffic_tracker;
use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
use async_socks5::{AddrKind, Auth, SocksDatagram};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use url::Url;
@@ -37,6 +45,127 @@ const CMD_UDP_ASSOCIATE: u8 = 0x03;
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
const UDP_BUF: usize = 65_536;
// How often per-domain UDP byte totals are pushed into the traffic tracker.
// Datagram relay is a per-packet path, so totals accumulate locally and flush
// on this interval rather than taking the tracker's domain write lock per
// packet. Global byte counters are plain atomics and update inline, so live
// bandwidth stays real-time exactly as it does for TCP tunnels.
const UDP_STATS_FLUSH_INTERVAL: Duration = Duration::from_secs(2);
// Cap on remembered destination->host flows per association. A long-lived
// association fanning out to many peers must not grow this map without bound.
const UDP_FLOW_MAP_MAX: usize = 1024;
/// Per-association filtering and traffic accounting for the UDP relay loops.
///
/// UDP has no per-datagram error channel (RFC 1928 §7 has no reply code), so a
/// blocked destination is dropped silently — which is what the browser sees for
/// any unreachable UDP peer, and makes it fall back to proxied TCP.
struct UdpRelayContext {
blocklist_matcher: BlocklistMatcher,
tracker: Option<Arc<LiveTrafficTracker>>,
/// Pending per-domain (sent, received) deltas awaiting flush.
pending: HashMap<String, (u64, u64)>,
/// Destinations already counted as a request, so each is recorded once.
seen: std::collections::HashSet<String>,
/// Maps a peer key back to the destination host we sent to, so reply bytes
/// are attributed to the domain rather than to a bare IP.
flow: HashMap<String, String>,
last_flush: Instant,
}
impl UdpRelayContext {
fn new(blocklist_matcher: BlocklistMatcher) -> Self {
Self {
blocklist_matcher,
tracker: get_traffic_tracker(),
pending: HashMap::new(),
seen: std::collections::HashSet::new(),
flow: HashMap::new(),
last_flush: Instant::now(),
}
}
/// Apply the DNS blocklist to a datagram destination. Mirrors the TCP path
/// exactly: the host string is matched whether it is a domain or an IP
/// literal, so allowlist mode rejects unlisted IP destinations here too.
fn is_blocked(&self, host: &str) -> bool {
self.blocklist_matcher.is_blocked(host)
}
/// Account a datagram relayed browser -> destination.
fn record_sent(&mut self, host: &str, peer_key: String, bytes: u64) {
let Some(tracker) = &self.tracker else {
return;
};
tracker.add_bytes_sent(bytes);
if self.seen.insert(host.to_string()) {
// First datagram to this destination: count it once so UDP peers show up
// in the domain list the way CONNECT targets do.
tracker.record_request(host, 0, 0);
}
if self.flow.len() < UDP_FLOW_MAP_MAX {
self.flow.insert(peer_key, host.to_string());
}
self.pending.entry(host.to_string()).or_insert((0, 0)).0 += bytes;
self.maybe_flush();
}
/// Account a datagram relayed destination -> browser. `peer_key` is looked up
/// against the flows recorded by `record_sent`; an unmatched reply (an
/// upstream that reports a different address than we addressed) is attributed
/// to `fallback_host`.
fn record_received(&mut self, peer_key: &str, fallback_host: &str, bytes: u64) {
let Some(tracker) = &self.tracker else {
return;
};
tracker.add_bytes_received(bytes);
let host = self
.flow
.get(peer_key)
.cloned()
.unwrap_or_else(|| fallback_host.to_string());
self.pending.entry(host).or_insert((0, 0)).1 += bytes;
self.maybe_flush();
}
fn maybe_flush(&mut self) {
if self.last_flush.elapsed() >= UDP_STATS_FLUSH_INTERVAL {
self.flush();
}
}
/// Push accumulated per-domain totals into the tracker.
fn flush(&mut self) {
let Some(tracker) = &self.tracker else {
self.pending.clear();
return;
};
for (domain, (sent, recv)) in self.pending.drain() {
tracker.update_domain_bytes(&domain, sent, recv);
}
self.last_flush = Instant::now();
}
}
/// Host portion of a UDP destination, for blocklist matching. An IP literal is
/// rendered without its port so it matches the same way a CONNECT host does.
fn addrkind_host(addr: &AddrKind) -> String {
match addr {
AddrKind::Ip(s) => s.ip().to_string(),
AddrKind::Domain(domain, _) => domain.clone(),
}
}
/// Stable key identifying a UDP peer, used to tie replies back to the
/// destination host they belong to.
fn addrkind_key(addr: &AddrKind) -> String {
match addr {
AddrKind::Ip(s) => s.to_string(),
AddrKind::Domain(domain, port) => format!("{domain}:{port}"),
}
}
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
/// IP never leaks.
#[derive(Debug, PartialEq, Eq)]
@@ -108,7 +237,7 @@ pub async fn handle_socks5_connection(
.await;
}
CMD_UDP_ASSOCIATE => {
handle_udp_associate(stream, upstream_url).await;
handle_udp_associate(stream, upstream_url, blocklist_matcher).await;
}
other => {
log::debug!("SOCKS5 unsupported command {other:#04x}");
@@ -294,8 +423,13 @@ async fn handle_connect(
///
/// `control` is the TCP control connection; the UDP association lives exactly
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
/// the browser closes it.
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
/// the browser closes it. Every relayed datagram is filtered and metered via
/// [`UdpRelayContext`], matching the TCP CONNECT path.
async fn handle_udp_associate(
mut control: TcpStream,
upstream_url: Option<String>,
blocklist_matcher: BlocklistMatcher,
) {
let mode = udp_mode(upstream_url.as_deref());
if mode == UdpMode::Refuse {
@@ -344,7 +478,7 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
run_udp_relay_direct(control, relay, out).await;
run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
}
UdpMode::Socks5Upstream => {
// Establish the upstream association FIRST; if the upstream refuses UDP,
@@ -367,7 +501,13 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
return;
}
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
run_udp_relay_socks5(control, relay, datagram).await;
run_udp_relay_socks5(
control,
relay,
datagram,
UdpRelayContext::new(blocklist_matcher),
)
.await;
}
UdpMode::Refuse => unreachable!("handled above"),
}
@@ -389,10 +529,20 @@ async fn associate_upstream(
None
};
let proxy_stream = TcpStream::connect((host, port)).await?;
let proxy_stream = tokio::time::timeout(
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
TcpStream::connect((host, port)),
)
.await
.map_err(|_| format!("upstream SOCKS5 connect to {host}:{port} timed out"))??;
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
let datagram = tokio::time::timeout(
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>),
)
.await
.map_err(|_| "upstream SOCKS5 UDP ASSOCIATE handshake timed out")??;
Ok(datagram)
}
@@ -467,7 +617,12 @@ fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
/// there is no upstream proxy, so the host IP is the profile's own IP.
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
async fn run_udp_relay_direct(
mut control: TcpStream,
relay: UdpSocket,
out: UdpSocket,
mut ctx: UdpRelayContext,
) {
let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF];
let mut from_target = vec![0u8; UDP_BUF];
@@ -490,23 +645,37 @@ async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: Udp
if header.frag != 0 {
continue; // fragmentation unsupported
}
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
continue;
}
let payload = &from_client[header.data_offset..n];
// Resolve after the blocklist check so a blocked host is never even
// looked up, and count bytes against the resolved peer so replies from
// it are attributed back to this host.
let dst = match resolve_addr(&header.dst).await {
Some(d) => d,
None => continue,
};
let _ = out.send_to(payload, dst).await;
if out.send_to(payload, dst).await.is_ok() {
ctx.record_sent(&host, dst.to_string(), payload.len() as u64);
}
}
// Target -> browser.
r = out.recv_from(&mut from_target) => {
let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr {
let resp = build_udp_response(peer, &from_target[..n]);
let _ = relay.send_to(&resp, client).await;
if relay.send_to(&resp, client).await.is_ok() {
ctx.record_received(&peer.to_string(), &peer.ip().to_string(), n as u64);
}
}
}
}
}
ctx.flush();
}
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
@@ -514,6 +683,7 @@ async fn run_udp_relay_socks5(
mut control: TcpStream,
relay: UdpSocket,
datagram: SocksDatagram<TcpStream>,
mut ctx: UdpRelayContext,
) {
let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF];
@@ -536,19 +706,33 @@ async fn run_udp_relay_socks5(
if header.frag != 0 {
continue;
}
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
continue;
}
let peer_key = addrkind_key(&header.dst);
let payload = from_client[header.data_offset..n].to_vec();
let _ = datagram.send_to(&payload, header.dst).await;
if datagram.send_to(&payload, header.dst).await.is_ok() {
ctx.record_sent(&host, peer_key, payload.len() as u64);
}
}
// Upstream -> browser.
r = datagram.recv_from(&mut from_upstream) => {
let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr {
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
let _ = relay.send_to(&resp, client).await;
if relay.send_to(&resp, client).await.is_ok() {
// The upstream usually reports the peer by IP even when we
// addressed a domain, so an unmatched flow falls back to that IP.
ctx.record_received(&addrkind_key(&peer), &addrkind_host(&peer), n as u64);
}
}
}
}
}
ctx.flush();
}
/// Resolve a UDP destination to a concrete socket address for direct relay.
@@ -637,6 +821,91 @@ mod tests {
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
}
#[test]
fn addrkind_host_strips_port_for_blocklist_matching() {
// The blocklist matches CONNECT hosts without a port, so UDP destinations
// must be rendered the same way or IP rules would never match.
assert_eq!(
addrkind_host(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
443
))),
"1.2.3.4"
);
assert_eq!(
addrkind_host(&AddrKind::Domain("ads.example.com".into(), 443)),
"ads.example.com"
);
}
#[test]
fn addrkind_key_distinguishes_ports() {
assert_eq!(
addrkind_key(&AddrKind::Domain("example.com".into(), 443)),
"example.com:443"
);
assert_eq!(
addrkind_key(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
53
))),
"1.2.3.4:53"
);
}
#[test]
fn udp_relay_context_blocks_like_the_tcp_path() {
let dir = std::env::temp_dir().join(format!("donut-udp-blocklist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("block.txt");
std::fs::write(&path, "ads.example.com\n").unwrap();
let matcher = BlocklistMatcher::from_file(path.to_str().unwrap()).unwrap();
let ctx = UdpRelayContext::new(matcher);
assert!(ctx.is_blocked("ads.example.com"));
// Subdomains are blocked by the parent rule, as on the CONNECT path.
assert!(ctx.is_blocked("cdn.ads.example.com"));
assert!(!ctx.is_blocked("example.com"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn udp_relay_context_allowlist_mode_blocks_unlisted_ip_destinations() {
// Parity check: allowlist mode blocks a bare IP destination over UDP the
// same way is_blocked does for a CONNECT to an IP literal — otherwise QUIC
// to a hardcoded IP would walk straight through a strict allowlist.
let dir = std::env::temp_dir().join(format!("donut-udp-allowlist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("allow.txt");
std::fs::write(&path, "trusted.example.com\n").unwrap();
let matcher = BlocklistMatcher::from_file_with_mode(path.to_str().unwrap(), true).unwrap();
let ctx = UdpRelayContext::new(matcher);
assert!(!ctx.is_blocked("trusted.example.com"));
assert!(ctx.is_blocked("evil.example.com"));
assert!(ctx.is_blocked(&addrkind_host(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
443
)))));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn udp_relay_context_attributes_replies_to_the_destination_host() {
// No tracker is initialised in tests, so this exercises the flow map that
// decides which domain reply bytes belong to.
let mut ctx = UdpRelayContext::new(BlocklistMatcher::new());
ctx.flow.insert("1.2.3.4:443".into(), "example.com".into());
assert_eq!(
ctx.flow.get("1.2.3.4:443").map(String::as_str),
Some("example.com")
);
// An unknown peer has no flow and falls back to the peer's own host.
assert!(!ctx.flow.contains_key("9.9.9.9:53"));
}
#[test]
fn build_udp_response_prefixes_header() {
let resp = build_udp_response(
+103 -33
View File
@@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error
let key = get_stats_storage_key(stats);
let file_path = storage_dir.join(format!("{key}.json"));
let content = serde_json::to_string(stats)?;
fs::write(&file_path, content)?;
// Write atomically via temp file + rename so readers never observe a
// partially-written file (same pattern as write_session_snapshot)
let temp_path = storage_dir.join(format!("{key}.json.tmp"));
fs::write(&temp_path, content)?;
fs::rename(&temp_path, &file_path)?;
Ok(())
}
@@ -370,15 +375,18 @@ pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
load_traffic_stats(profile_id)
}
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
pub fn list_traffic_stats() -> Vec<TrafficStats> {
/// Load all traffic stats files keyed by storage key, migrating old
/// proxy-id based files to profile-id based ones. Only writes to disk when a
/// migration or merge actually changed data; the common case is read-only.
fn collect_traffic_stats() -> HashMap<String, TrafficStats> {
let storage_dir = get_traffic_stats_dir();
if !storage_dir.exists() {
return Vec::new();
return HashMap::new();
}
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
let mut dirty_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) {
@@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
if let Some(existing) = stats_map.get_mut(&key) {
// Merge stats from this file into existing
merge_traffic_stats(existing, &s);
dirty_keys.insert(key);
if is_old_proxy_file {
files_to_delete.push(path.clone());
}
} else {
stats_map.insert(key.clone(), s);
if is_old_proxy_file {
dirty_keys.insert(key);
files_to_delete.push(path.clone());
}
}
@@ -414,10 +424,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
}
}
// Save merged stats and delete old files
for stats in stats_map.values() {
if let Err(e) = save_traffic_stats(stats) {
log::warn!("Failed to save merged traffic stats: {}", e);
// Persist only entries actually changed by a merge or migration
for key in &dirty_keys {
if let Some(stats) = stats_map.get(key) {
if let Err(e) = save_traffic_stats(stats) {
log::warn!("Failed to save merged traffic stats: {}", e);
}
}
}
@@ -427,7 +439,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
}
}
stats_map.into_values().collect()
stats_map
}
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
pub fn list_traffic_stats() -> Vec<TrafficStats> {
collect_traffic_stats().into_values().collect()
}
/// Merge traffic stats from source into destination
@@ -487,27 +504,82 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
}
}
/// Delete traffic stats by id (profile_id or proxy_id)
/// Delete traffic stats by id (profile_id or proxy_id).
///
/// Removes the session snapshot and any temp orphans alongside the main file.
/// The snapshot is what a running worker writes every second, and
/// `get_all_traffic_snapshots_realtime` rebuilds an entry straight from it when
/// no main file exists — so clearing only `{id}.json` resurrects the very
/// totals the user asked to erase, and leaves the snapshot's copy on disk.
pub fn delete_traffic_stats(id: &str) -> bool {
let storage_dir = get_traffic_stats_dir();
let file_path = storage_dir.join(format!("{id}.json"));
let mut removed = false;
if file_path.exists() {
fs::remove_file(&file_path).is_ok()
} else {
false
for name in [
format!("{id}.json"),
format!("{id}.json.tmp"),
format!("{id}.session.json"),
format!("{id}.session.json.tmp"),
] {
let path = storage_dir.join(name);
if path.exists() && secure_remove_file(&path).is_ok() {
removed = true;
}
}
removed
}
/// Clear all traffic stats (used when clearing cache)
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
/// before unlinking, so the traffic history isn't trivially recoverable from
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
/// old blocks — this is a best-effort mitigation, not a guarantee.
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
use std::io::Write;
if let Ok(meta) = fs::metadata(path) {
let len = meta.len();
if len > 0 {
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
let zeros = vec![0u8; 8192];
let mut remaining = len;
// The overwrite is best-effort and must never gate the unlink: a write
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
// otherwise leave the file both un-wiped and un-deleted, which is
// strictly worse than the plain remove this replaced — and the caller
// reports success either way, so the history would silently survive a
// clear.
while remaining > 0 {
let chunk = remaining.min(zeros.len() as u64) as usize;
if f.write_all(&zeros[..chunk]).is_err() {
break;
}
remaining -= chunk as u64;
}
let _ = f.flush();
let _ = f.sync_all();
}
}
}
fs::remove_file(path)
}
/// Clear all traffic stats (used when clearing cache), securely erasing each
/// file first.
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_traffic_stats_dir();
if storage_dir.exists() {
for entry in fs::read_dir(&storage_dir)?.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let _ = fs::remove_file(&path);
// Wipe the live stats files and any temp orphan left by an interrupted
// atomic save, which still holds a copy of the history. This directory
// holds nothing but traffic stats, so matching every `.json`/`.tmp` also
// catches orphans from older temp-naming schemes.
let is_stats = path
.extension()
.is_some_and(|ext| ext == "json" || ext == "tmp");
if is_stats {
let _ = secure_remove_file(&path);
}
}
}
@@ -584,8 +656,10 @@ impl LiveTrafficTracker {
fs::create_dir_all(&storage_dir)?;
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
// Write atomically using a temp file
let temp_file = session_file.with_extension("tmp");
// Write atomically using a temp file. Named by suffix rather than
// `with_extension`, which would replace `.json` and yield
// `{key}.session.tmp` — a name the cleanup sweeps did not recognise.
let temp_file = storage_dir.join(format!("{}.session.json.tmp", storage_key));
let content = serde_json::to_string(&snapshot)?;
fs::write(&temp_file, content)?;
fs::rename(&temp_file, &session_file)?;
@@ -1035,14 +1109,14 @@ fn load_session_snapshot(profile_id: &str) -> Option<SessionSnapshot> {
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
use std::collections::HashMap;
// Start with disk-stored stats
let mut snapshots: HashMap<String, TrafficSnapshot> = list_traffic_stats()
.into_iter()
.map(|s| {
let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone());
(key, s.to_snapshot())
})
.collect();
// Start with disk-stored stats, keeping last_flush_timestamp per key so the
// session-snapshot merge below doesn't need to re-read the files
let mut snapshots: HashMap<String, TrafficSnapshot> = HashMap::new();
let mut last_flush_by_key: HashMap<String, u64> = HashMap::new();
for (key, s) in collect_traffic_stats() {
last_flush_by_key.insert(key.clone(), s.last_flush_timestamp);
snapshots.insert(key, s.to_snapshot());
}
// Try to merge in real-time data from active tracker (if in same process)
if let Some(tracker) = get_traffic_tracker() {
@@ -1068,11 +1142,7 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
// Only merge session data if it's newer than the last flush
// Session snapshots written before the last flush contain bytes already
// included in disk totals, so merging them would cause double-counting
let disk_stats = load_traffic_stats(profile_id);
let last_flush = disk_stats
.as_ref()
.map(|s| s.last_flush_timestamp)
.unwrap_or(0);
let last_flush = last_flush_by_key.get(profile_id).copied().unwrap_or(0);
if session.timestamp > last_flush {
// Session data contains in-memory counters not yet flushed to disk
+84 -5
View File
@@ -3,14 +3,21 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { motion } from "motion/react";
import { useOnborda } from "onborda";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AboutDialog } from "@/components/about-dialog";
import { AccountPage } from "@/components/account-page";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import {
type ConsistencyResult,
ConsistencyWarningDialog,
isConsistencyWarningSuppressed,
} from "@/components/consistency-warning-dialog";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
@@ -60,6 +67,7 @@ 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 { MOTION_EASE_OUT } from "@/lib/motion";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
@@ -324,6 +332,11 @@ export default function Home() {
const [currentProfileForSync, setCurrentProfileForSync] =
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const [consistencyWarning, setConsistencyWarning] = useState<{
profile: BrowserProfile;
result: ConsistencyResult;
} | null>(null);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
@@ -917,6 +930,24 @@ export default function Home() {
profile,
});
console.log("Successfully launched profile:", result.name);
// Non-blocking: after a successful launch, check that the proxy exit
// node's timezone/country agrees with the fingerprint. A mismatch is a
// strong anti-bot tell even though the real device never leaks.
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
void invoke<ConsistencyResult>(
"check_profile_fingerprint_consistency",
{ profileId: profile.id },
)
.then((res) => {
if (res.checked && !res.consistent) {
setConsistencyWarning({ profile, result: res });
}
})
.catch((e) => {
console.warn("Consistency check failed:", e);
});
}
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -1580,12 +1611,24 @@ export default function Home() {
pageTitle={subPageTitle}
/>
<div className="flex min-h-0 flex-1">
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
<RailNav
currentPage={currentPage}
onNavigate={handleRailNavigate}
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{currentPage === "profiles" && (
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5">
{isLoading && groupsData.length === 0 ? null : null}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
className="flex min-h-0 flex-1 flex-col px-3 pt-2.5"
>
<ProfilesDataTable
isLoading={isLoading && profiles.length === 0}
showOnboardingEmptyState={profiles.length === 0}
profiles={filteredProfiles}
infoDialogProfile={profileInfoDialog}
onInfoDialogProfileChange={setProfileInfoDialog}
@@ -1626,12 +1669,25 @@ export default function Home() {
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
onCreateProfile={() => {
setCreateProfileDialogOpen(true);
}}
onImportProfiles={() => {
handleRailNavigate("import");
}}
/>
</div>
</motion.div>
)}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
className="flex min-h-0 flex-1 flex-col"
>
<ShortcutsPage groupTargets={orderedGroupTargets} />
</motion.div>
)}
{settingsDialogOpen && (
@@ -1760,6 +1816,29 @@ export default function Home() {
handleRailNavigate("profiles");
setProfileInfoDialog(profile);
}}
onCreateProfile={() => {
setCreateProfileDialogOpen(true);
}}
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
/>
<AboutDialog
isOpen={aboutDialogOpen}
onClose={() => {
setAboutDialogOpen(false);
}}
/>
<ConsistencyWarningDialog
isOpen={consistencyWarning !== null}
onClose={() => {
setConsistencyWarning(null);
}}
profileName={consistencyWarning?.profile.name ?? ""}
profileId={consistencyWarning?.profile.id ?? ""}
result={consistencyWarning?.result ?? null}
/>
{pendingUrls.map((pendingUrl) => (
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { launchDonutClone } from "@/lib/donut-physics";
import { Logo } from "./icons/logo";
import { RippleButton } from "./ui/ripple";
interface AboutDialogProps {
isOpen: boolean;
onClose: () => void;
}
interface SystemInfo {
app_version: string;
os: string;
arch: string;
portable: boolean;
}
// Flywheel: each click adds spin; past this speed the donut escapes the
// dialog and bounces around the window (shared physics with the rail egg).
const SPIN_PER_CLICK = 540; // deg/s
const ESCAPE_VELOCITY = 2200; // deg/s
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const { t } = useTranslation();
const reducedMotion = useReducedMotion();
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [logoFlown, setLogoFlown] = useState(false);
const logoRef = useRef<HTMLButtonElement>(null);
const rotationRef = useRef(0);
const velocityRef = useRef(0);
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const cancelLaunchRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!isOpen) return;
invoke<SystemInfo>("get_system_info")
.then(setSystemInfo)
.catch(() => {
setSystemInfo(null);
});
}, [isOpen]);
const stopSpin = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
velocityRef.current = 0;
}, []);
const spinFrame = useCallback(
(time: number) => {
const el = logoRef.current;
if (!el) {
rafRef.current = 0;
return;
}
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
lastTimeRef.current = time;
rotationRef.current += velocityRef.current * dt;
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
el.style.transform = `rotate(${rotationRef.current}deg)`;
if (velocityRef.current >= ESCAPE_VELOCITY) {
// The flywheel wins: the donut tears loose and joins the bounce sim,
// keeping its spin.
stopSpin();
setLogoFlown(true);
cancelLaunchRef.current = launchDonutClone(el, {
initialVX: Math.random() > 0.5 ? 420 : -420,
initialVY: -750,
spinSpeed: ESCAPE_VELOCITY,
onExit: () => {
cancelLaunchRef.current = null;
},
});
return;
}
if (velocityRef.current > 5) {
rafRef.current = requestAnimationFrame(spinFrame);
} else {
rafRef.current = 0;
velocityRef.current = 0;
}
},
[stopSpin],
);
const handleLogoClick = useCallback(() => {
if (reducedMotion || logoFlown) return;
velocityRef.current += SPIN_PER_CLICK;
if (!rafRef.current) {
lastTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(spinFrame);
}
}, [reducedMotion, logoFlown, spinFrame]);
const handleClose = useCallback(() => {
stopSpin();
cancelLaunchRef.current?.();
cancelLaunchRef.current = null;
rotationRef.current = 0;
if (logoRef.current) {
logoRef.current.style.transform = "";
logoRef.current.style.visibility = "";
}
setLogoFlown(false);
onClose();
}, [stopSpin, onClose]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
cancelLaunchRef.current?.();
};
}, []);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-4">
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
>
<Logo className="size-14" />
</button>
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", { version: systemInfo.app_version })}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
{t("about.portableBadge")}
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
)}
</div>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl("https://github.com/zhom/donutbrowser")
}
>
GitHub
</Button>
</div>
</div>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
</DialogContent>
</Dialog>
);
}
+272 -270
View File
@@ -199,314 +199,316 @@ export function AccountPage({
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto",
subPage && "mx-auto w-full max-w-3xl",
)}
>
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
>
{t("account.tabs.selfHosted")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
>
{t("account.tabs.selfHosted")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
<LuUser className="size-6" />
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="truncate text-base font-semibold">
{user.email}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="truncate text-base font-semibold">
{user.email}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">
{user.plan}
</p>
</div>
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.teamRole")}
{t("account.fields.plan")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.period")}
<p className="mt-0.5 font-medium uppercase">
{user.plan}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.device")}
{t("account.fields.status")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
{user.subscriptionStatus ?? "—"}
</p>
</div>
{user.teamRole && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("account.fields.device")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
</p>
</div>
)}
</div>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
</div>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{isLoggedIn ? (
<>
<div className="mt-2 flex flex-wrap gap-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 gap-1.5 text-xs"
>
<LuRefreshCw className="size-3" />
{t("account.refresh")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 gap-1.5 text-xs"
>
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
onClick={onOpenSignIn}
className="h-8 gap-1.5 text-xs"
>
<LuRefreshCw className="size-3" />
{t("account.refresh")}
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 gap-1.5 text-xs"
>
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 gap-1.5 text-xs"
>
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
)}
)}
</div>
</div>
</div>
</AnimatedTabsContent>
</AnimatedTabsContent>
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.selfHosted.description")}
</p>
</div>
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("account.selfHosted.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-server-url" className="text-xs">
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<div className="space-y-1.5">
<Label
htmlFor="self-hosted-server-url"
className="text-xs"
>
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setToken(e.target.value);
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="bg-success text-success-foreground"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="bg-success text-success-foreground"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
</div>
</div>
</div>
)}
</AnimatedTabsContent>
</AnimatedTabs>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</div>
</DialogContent>
</Dialog>
+12 -5
View File
@@ -1,5 +1,6 @@
"use client";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
@@ -17,11 +18,17 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
return (
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
{/* reducedMotion="user" makes every motion/react animation honor the
OS prefers-reduced-motion setting: transforms are skipped, opacity
cross-fades are kept. The CSS-side media query in globals.css only
covers CSS transitions this covers the JS-driven ones. */}
<MotionConfig reducedMotion="user">
<WindowDragArea />
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
</MotionConfig>
</CustomThemeProvider>
</I18nProvider>
);
+22
View File
@@ -5,12 +5,14 @@ import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear } from "react-icons/go";
import {
LuBadgeInfo,
LuCircleStop,
LuCloud,
LuInfo,
LuKeyboard,
LuPlay,
LuPlug,
LuPlus,
LuPuzzle,
LuUser,
LuUsers,
@@ -53,6 +55,8 @@ interface CommandPaletteProps {
onLaunchProfile: (profile: BrowserProfile) => void;
onKillProfile: (profile: BrowserProfile) => void;
onShowProfileInfo: (profile: BrowserProfile) => void;
onCreateProfile: () => void;
onOpenAbout: () => void;
}
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
@@ -122,6 +126,8 @@ export function CommandPalette({
onLaunchProfile,
onKillProfile,
onShowProfileInfo,
onCreateProfile,
onOpenAbout,
}: CommandPaletteProps) {
const { t } = useTranslation();
@@ -251,6 +257,14 @@ export function CommandPalette({
<CommandSeparator />
<CommandGroup heading={t("commandPalette.groups.actions")}>
<CommandItem
onSelect={() => {
dispatch(onCreateProfile);
}}
>
<LuPlus />
<span>{t("commandPalette.actions.createProfile")}</span>
</CommandItem>
{byGroup("actions").map((s) => {
const Icon = ICONS[s.id];
return (
@@ -268,6 +282,14 @@ export function CommandPalette({
</CommandItem>
);
})}
<CommandItem
onSelect={() => {
dispatch(onOpenAbout);
}}
>
<LuBadgeInfo />
<span>{t("commandPalette.actions.about")}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
@@ -0,0 +1,184 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
export interface ConsistencyResult {
consistent: boolean;
checked: boolean;
exit_ip: string | null;
exit_country_code: string | null;
exit_timezone: string | null;
fingerprint_timezone: string | null;
fingerprint_language: string | null;
mismatches: string[];
}
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
export function isConsistencyWarningEnabled(): boolean {
try {
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
} catch {
return true;
}
}
export function isConsistencyWarningSuppressed(profileId: string): boolean {
try {
return (
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
localStorage.getItem(perProfileKey(profileId)) === "1"
);
} catch {
return false;
}
}
interface ConsistencyWarningDialogProps {
isOpen: boolean;
onClose: () => void;
profileName: string;
profileId: string;
result: ConsistencyResult | null;
}
export function ConsistencyWarningDialog({
isOpen,
onClose,
profileName,
profileId,
result,
}: ConsistencyWarningDialogProps) {
const { t } = useTranslation();
const [dontWarnAgain, setDontWarnAgain] = useState(false);
const [isMatching, setIsMatching] = useState(false);
const handleClose = () => {
if (dontWarnAgain) {
try {
localStorage.setItem(perProfileKey(profileId), "1");
} catch {
// localStorage unavailable — nothing to persist
}
}
setDontWarnAgain(false);
onClose();
};
const mismatches = result?.mismatches ?? [];
const exitIp = result?.exit_ip ?? null;
const handleMatch = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
handleClose();
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("consistencyWarning.intro", { name: profileName })}
</p>
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
{mismatches.includes("timezone") && (
<div>
<p className="font-medium">
{t("consistencyWarning.timezoneTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: result?.exit_timezone ?? "?",
fingerprint: result?.fingerprint_timezone ?? "?",
})}
</p>
</div>
)}
{mismatches.includes("language") && (
<div>
<p className="font-medium">
{t("consistencyWarning.languageTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: result?.exit_country_code ?? "?",
fingerprint: result?.fingerprint_language ?? "?",
})}
</p>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
<label
htmlFor="consistency-dont-warn"
className="flex cursor-pointer items-center gap-2 text-xs"
>
<Checkbox
id="consistency-dont-warn"
checked={dontWarnAgain}
onCheckedChange={(v) => setDontWarnAgain(v === true)}
/>
{t("consistencyWarning.dontWarnAgain")}
</label>
</div>
<div className="flex justify-end gap-2">
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isMatching}
>
{t("common.buttons.close")}
</RippleButton>
{exitIp && (
<RippleButton onClick={handleMatch} disabled={isMatching}>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
</div>
</DialogContent>
</Dialog>
);
}
+9 -15
View File
@@ -53,6 +53,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
@@ -1183,21 +1184,14 @@ export function CreateProfileDialog({
<SelectItem value="none">
{t("dnsBlocklist.none")}
</SelectItem>
<SelectItem value="light">
{t("dnsBlocklist.light")}
</SelectItem>
<SelectItem value="normal">
{t("dnsBlocklist.normal")}
</SelectItem>
<SelectItem value="pro">
{t("dnsBlocklist.pro")}
</SelectItem>
<SelectItem value="pro_plus">
{t("dnsBlocklist.proPlus")}
</SelectItem>
<SelectItem value="ultimate">
{t("dnsBlocklist.ultimate")}
</SelectItem>
{DNS_BLOCKLIST_LEVELS.map((level) => (
<SelectItem
key={level.value}
value={level.value}
>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
+282 -54
View File
@@ -1,9 +1,22 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import {
open as openDialog,
save as saveDialog,
} from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuRefreshCw } from "react-icons/lu";
import { toast } from "sonner";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -12,6 +25,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import { dnsBlocklistLabelKey } from "@/lib/dns-blocklist-levels";
import { LoadingButton } from "./loading-button";
interface BlocklistCacheStatus {
level: string;
@@ -23,11 +41,25 @@ interface BlocklistCacheStatus {
is_cached: boolean;
}
interface CustomDnsConfig {
sources: string[];
block_domains: string[];
allow_domains: string[];
allowlist_mode: boolean;
updated_at: number | null;
}
interface DnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
}
const linesToArray = (v: string) =>
v
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
export function DnsBlocklistDialog({
isOpen,
onClose,
@@ -36,6 +68,12 @@ export function DnsBlocklistDialog({
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [sources, setSources] = useState("");
const [blockDomains, setBlockDomains] = useState("");
const [allowDomains, setAllowDomains] = useState("");
const [allowlistMode, setAllowlistMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const loadStatuses = useCallback(async () => {
try {
const result = await invoke<BlocklistCacheStatus[]>(
@@ -47,11 +85,24 @@ export function DnsBlocklistDialog({
}
}, []);
const loadCustomConfig = useCallback(async () => {
try {
const config = await invoke<CustomDnsConfig>("get_custom_dns_config");
setSources(config.sources.join("\n"));
setBlockDomains(config.block_domains.join("\n"));
setAllowDomains(config.allow_domains.join("\n"));
setAllowlistMode(config.allowlist_mode);
} catch (e) {
console.error("Failed to load custom DNS config:", e);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadStatuses();
void loadCustomConfig();
}
}, [isOpen, loadStatuses]);
}, [isOpen, loadStatuses, loadCustomConfig]);
const handleRefreshAll = async () => {
setIsRefreshing(true);
@@ -65,6 +116,67 @@ export function DnsBlocklistDialog({
}
};
const handleSaveCustom = async () => {
setIsSaving(true);
try {
const config = await invoke<CustomDnsConfig>("set_custom_dns_config", {
sources: linesToArray(sources),
blockDomains: linesToArray(blockDomains),
allowDomains: linesToArray(allowDomains),
allowlistMode,
});
setSources(config.sources.join("\n"));
setBlockDomains(config.block_domains.join("\n"));
setAllowDomains(config.allow_domains.join("\n"));
setAllowlistMode(config.allowlist_mode);
toast.success(t("dnsBlocklist.custom.saved"));
} catch (e) {
toast.error(translateBackendError(t, e));
} finally {
setIsSaving(false);
}
};
const handleImport = async () => {
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: "Rules", extensions: ["json", "txt"] }],
});
if (!selected || typeof selected !== "string") return;
const content = await readTextFile(selected);
const format = selected.toLowerCase().endsWith(".json") ? "json" : "txt";
const config = await invoke<CustomDnsConfig>("import_custom_dns_rules", {
content,
format,
});
setSources(config.sources.join("\n"));
setBlockDomains(config.block_domains.join("\n"));
setAllowDomains(config.allow_domains.join("\n"));
setAllowlistMode(config.allowlist_mode);
toast.success(t("dnsBlocklist.custom.imported"));
} catch (e) {
toast.error(translateBackendError(t, e));
}
};
const handleExport = async (format: "json" | "txt") => {
try {
const content = await invoke<string>("export_custom_dns_rules", {
format,
});
const path = await saveDialog({
defaultPath: `donut-dns-rules.${format}`,
filters: [{ name: format.toUpperCase(), extensions: [format] }],
});
if (!path) return;
await writeTextFile(path, content);
toast.success(t("dnsBlocklist.custom.exported"));
} catch (e) {
toast.error(translateBackendError(t, e));
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -78,69 +190,185 @@ export function DnsBlocklistDialog({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogContent className="flex max-h-[80vh] max-w-lg flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("dnsBlocklist.settingsDescription")}
</p>
<AnimatedTabs
defaultValue="blocklists"
className="flex min-h-0 flex-1 flex-col gap-4"
>
<AnimatedTabsList className="shrink-0">
<AnimatedTabsTrigger value="blocklists">
{t("dnsBlocklist.tabBlocklists")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="custom">
{t("dnsBlocklist.tabCustom")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
{statuses.map((status) => (
<div
key={status.level}
className="flex items-center justify-between rounded-md border border-border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{status.display_name}
</span>
{status.is_cached ? (
status.is_fresh ? (
<Badge variant="default" className="px-1.5 text-[10px]">
{t("dnsBlocklist.fresh")}
</Badge>
<AnimatedTabsContent
value="blocklists"
className="min-h-0 flex-1 space-y-3 overflow-y-auto"
>
<p className="text-sm text-muted-foreground">
{t("dnsBlocklist.settingsDescription")}
</p>
{statuses.map((status) => (
<div
key={status.level}
className="flex items-center justify-between rounded-md border border-border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{t(dnsBlocklistLabelKey(status.level))}
</span>
{status.is_cached ? (
status.is_fresh ? (
<Badge variant="default" className="px-1.5 text-[10px]">
{t("dnsBlocklist.fresh")}
</Badge>
) : (
<Badge
variant="secondary"
className="px-1.5 text-[10px]"
>
{t("dnsBlocklist.stale")}
</Badge>
)
) : (
<Badge variant="secondary" className="px-1.5 text-[10px]">
{t("dnsBlocklist.stale")}
<Badge
variant="outline"
className="px-1.5 text-[10px] text-muted-foreground"
>
{t("dnsBlocklist.notCached")}
</Badge>
)
) : (
<Badge
variant="outline"
className="px-1.5 text-[10px] text-muted-foreground"
>
{t("dnsBlocklist.notCached")}
</Badge>
)}
</div>
{status.is_cached && (
<div className="text-xs text-muted-foreground">
{status.entry_count.toLocaleString()}{" "}
{t("dnsBlocklist.domains")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{formatDate(status.last_updated)}
</div>
)}
</div>
{status.is_cached && (
<div className="text-xs text-muted-foreground">
{status.entry_count.toLocaleString()}{" "}
{t("dnsBlocklist.domains")} &middot;{" "}
{formatSize(status.file_size_bytes)} &middot;{" "}
{formatDate(status.last_updated)}
</div>
)}
</div>
</div>
))}
</div>
))}
<Button
onClick={handleRefreshAll}
disabled={isRefreshing}
variant="outline"
className="w-full"
>
<LuRefreshCw
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
</AnimatedTabsContent>
<Button
onClick={handleRefreshAll}
disabled={isRefreshing}
variant="outline"
className="w-full"
>
<LuRefreshCw
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
<AnimatedTabsContent
value="custom"
className="min-h-0 flex-1 space-y-4 overflow-y-auto"
>
<p className="text-sm text-muted-foreground">
{t("dnsBlocklist.custom.description")}
</p>
<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t("dnsBlocklist.custom.allowlistModeLabel")}
</p>
<p className="text-[11px] text-muted-foreground">
{allowlistMode
? t("dnsBlocklist.custom.allowlistModeOn")
: t("dnsBlocklist.custom.allowlistModeOff")}
</p>
</div>
<AnimatedSwitch
checked={allowlistMode}
onCheckedChange={(v) => setAllowlistMode(v === true)}
aria-label={t("dnsBlocklist.custom.allowlistModeLabel")}
/>
</div>
{!allowlistMode && (
<div>
<Label className="mb-1.5">
{t("dnsBlocklist.custom.sourcesLabel")}
</Label>
<Textarea
value={sources}
onChange={(e) => setSources(e.target.value)}
placeholder={t("dnsBlocklist.custom.sourcesPlaceholder")}
rows={3}
className="font-mono text-xs"
/>
</div>
)}
{!allowlistMode && (
<div>
<Label className="mb-1.5">
{t("dnsBlocklist.custom.blockLabel")}
</Label>
<Textarea
value={blockDomains}
onChange={(e) => setBlockDomains(e.target.value)}
placeholder={t("dnsBlocklist.custom.blockPlaceholder")}
rows={4}
className="font-mono text-xs"
/>
</div>
)}
<div>
<Label className="mb-1.5">
{allowlistMode
? t("dnsBlocklist.custom.allowedOnlyLabel")
: t("dnsBlocklist.custom.allowLabel")}
</Label>
<Textarea
value={allowDomains}
onChange={(e) => setAllowDomains(e.target.value)}
placeholder={t("dnsBlocklist.custom.allowPlaceholder")}
rows={allowlistMode ? 6 : 3}
className="font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
{allowlistMode
? t("dnsBlocklist.custom.allowedOnlyHint")
: t("dnsBlocklist.custom.allowHint")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<LoadingButton isLoading={isSaving} onClick={handleSaveCustom}>
{t("common.buttons.save")}
</LoadingButton>
<Button variant="outline" onClick={handleImport}>
{t("common.buttons.import")}
</Button>
<Button
variant="outline"
onClick={() => void handleExport("txt")}
>
{t("dnsBlocklist.custom.exportTxt")}
</Button>
<Button
variant="outline"
onClick={() => void handleExport("json")}
>
{t("dnsBlocklist.custom.exportJson")}
</Button>
</div>
</AnimatedTabsContent>
</AnimatedTabs>
</DialogContent>
</Dialog>
);
+434 -340
View File
@@ -3,12 +3,18 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFileArchive, FaFolder } from "react-icons/fa";
import { LuChevronRight } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedDisclosureChevron,
AnimatedDisclosureContent,
} from "@/components/ui/animated-disclosure";
import {
AnimatedTabs,
AnimatedTabsContent,
@@ -36,8 +42,10 @@ import {
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useGroupEvents } from "@/hooks/use-group-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { fireSprinkleConfetti } from "@/lib/confetti";
import { cn } from "@/lib/utils";
import type {
ArchiveScanResult,
@@ -89,7 +97,13 @@ export function ImportProfileDialog({
useState<DuplicateStrategy>("rename");
// "none" | "round-robin" | a stored proxy id
const [proxyAssignment, setProxyAssignment] = useState<string>("none");
// "none" | a VPN config id (applied to every imported profile)
const [vpnAssignment, setVpnAssignment] = useState<string>("none");
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
// Fingerprint + advanced options collapse behind disclosures — the default
// path is just names + proxy/VPN.
const [showFingerprint, setShowFingerprint] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
@@ -97,6 +111,8 @@ export function ImportProfileDialog({
const { storedProxies } = useProxyEvents();
const { groups } = useGroupEvents();
const { vpnConfigs } = useVpnEvents();
const reducedMotion = useReducedMotion();
const activeProfiles =
importMode === "auto-detect" ? detectedProfiles : scannedProfiles;
@@ -284,6 +300,7 @@ export function ImportProfileDialog({
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
}));
setCurrentStep("importing");
@@ -308,6 +325,9 @@ export function ImportProfileDialog({
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
@@ -319,9 +339,11 @@ export function ImportProfileDialog({
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
t,
]);
@@ -339,7 +361,10 @@ export function ImportProfileDialog({
setNewGroupName("");
setDuplicateStrategy("rename");
setProxyAssignment("none");
setVpnAssignment("none");
setWayfernConfig({});
setShowFingerprint(false);
setShowAdvanced(false);
setProgress(null);
setResult(null);
onClose();
@@ -430,365 +455,434 @@ export function ImportProfileDialog({
</DialogHeader>
)}
<div
className={cn(
"min-h-0 flex-1 space-y-6 overflow-y-auto",
subPage && "mx-auto w-full max-w-2xl",
)}
>
{currentStep === "select" && (
<AnimatedTabs
value={importMode}
onValueChange={(v) => {
setImportMode(v as ImportMode);
setSelectedPaths(new Set());
}}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.scanning")}
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.noneFound")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("importProfile.noneFoundHint")}
</p>
</div>
) : (
renderProfileList(detectedProfiles)
)}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
</h3>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
{t("importProfile.profileFolderPath")}
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualPath}
onChange={(e) => {
setManualPath(e.target.value);
}}
placeholder={t(
"importProfile.profileFolderPlaceholder",
)}
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseArchive()}
title={t("importProfile.selectArchiveTitle")}
>
<FaFileArchive className="size-4" />
</Button>
<LoadingButton
variant="outline"
isLoading={isScanning}
disabled={!manualPath.trim()}
onClick={() => void scanPath(manualPath.trim())}
>
{t("importProfile.scanButton")}
</LoadingButton>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{t("importProfile.manualHint")}
</p>
<p className="mt-2 text-xs break-all text-muted-foreground">
{t("importProfile.examplePaths")}
<br />
macOS: ~/Library/Application Support/Google/Chrome/Default
<br />
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
<br />
Linux: ~/.config/google-chrome/Default
</p>
</div>
{scannedProfiles.length > 0 &&
renderProfileList(scannedProfiles)}
</div>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && (
<div className="space-y-4">
<Alert>
<AlertDescription>
{t("importProfile.importedAs", {
browser: getBrowserDisplayName("wayfern"),
})}
</AlertDescription>
</Alert>
<div>
<Label className="mb-2">
{t("importProfile.profilesToImport")}
</Label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
{selectedProfiles.map((profile) => (
<div key={profile.path} className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
title={profile.path}
>
{profile.name}
</span>
<Input
className="flex-1"
aria-label={t("importProfile.newProfileName")}
value={profileNames[profile.path] ?? profile.name}
onChange={(e) => {
setProfileNames((prev) => ({
...prev,
[profile.path]: e.target.value,
}));
}}
placeholder={t(
"importProfile.newProfileNamePlaceholder",
)}
/>
</div>
))}
</div>
</div>
<div>
<Label className="mb-2">
{t("importProfile.groupOptional")}
</Label>
{isCreatingGroup ? (
<div className="flex gap-2">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t("importProfile.newGroupNamePlaceholder")}
/>
<Button
variant="outline"
disabled={!newGroupName.trim()}
onClick={() => void handleCreateGroup()}
>
{t("common.buttons.create")}
</Button>
<Button
variant="ghost"
onClick={() => {
setIsCreatingGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<Select
value={selectedGroupId}
onValueChange={(value) => {
if (value === "create-new") {
setIsCreatingGroup(true);
} else {
setSelectedGroupId(value);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noGroup")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
<SelectItem value="create-new">
{t("importProfile.createNewGroup")}
</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div>
<Label className="mb-2">
{t("importProfile.duplicateStrategyLabel")}
</Label>
<Select
value={duplicateStrategy}
onValueChange={(value) => {
setDuplicateStrategy(value as DuplicateStrategy);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rename">
{t("importProfile.duplicateRename")}
</SelectItem>
<SelectItem value="skip">
{t("importProfile.duplicateSkip")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-2">
{t("importProfile.proxyOptional")}
</Label>
<Select
value={proxyAssignment}
onValueChange={setProxyAssignment}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noProxy")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noProxy")}
</SelectItem>
{storedProxies.length > 0 && (
<SelectItem value="round-robin">
{t("importProfile.proxyRoundRobin")}
</SelectItem>
)}
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={cn("space-y-6", subPage && "mx-auto w-full max-w-3xl")}
>
{currentStep === "select" && (
<AnimatedTabs
value={importMode}
onValueChange={(v) => {
setImportMode(v as ImportMode);
setSelectedPaths(new Set());
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
)}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
{currentStep === "importing" && (
<div className="space-y-4">
{isImporting && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.importingTitle")}
</h3>
<Progress value={progressPercent} />
{progress && (
<p className="text-sm text-muted-foreground">
{t("importProfile.importProgress", {
completed: progress.completed,
total: progress.total,
})}
{progress.status === "importing" && (
<> {progress.name}</>
)}
</p>
)}
</div>
)}
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
</h3>
{result && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.resultsSummary", {
imported: result.imported_count,
skipped: result.skipped_count,
failed: result.failed_count,
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.scanning")}
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
{t("importProfile.noneFound")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("importProfile.noneFoundHint")}
</p>
</div>
) : (
renderProfileList(detectedProfiles)
)}
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
</h3>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
{t("importProfile.profileFolderPath")}
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualPath}
onChange={(e) => {
setManualPath(e.target.value);
}}
placeholder={t(
"importProfile.profileFolderPlaceholder",
)}
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseArchive()}
title={t("importProfile.selectArchiveTitle")}
>
<FaFileArchive className="size-4" />
</Button>
<LoadingButton
variant="outline"
isLoading={isScanning}
disabled={!manualPath.trim()}
onClick={() => void scanPath(manualPath.trim())}
>
{t("importProfile.scanButton")}
</LoadingButton>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{t("importProfile.manualHint")}
</p>
<p className="mt-2 text-xs break-all text-muted-foreground">
{t("importProfile.examplePaths")}
<br />
macOS: ~/Library/Application
Support/Google/Chrome/Default
<br />
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
<br />
Linux: ~/.config/google-chrome/Default
</p>
</div>
{scannedProfiles.length > 0 &&
renderProfileList(scannedProfiles)}
</div>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && (
<div className="space-y-4">
<Alert>
<AlertDescription>
{t("importProfile.importedAs", {
browser: getBrowserDisplayName("wayfern"),
})}
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
</AlertDescription>
</Alert>
<div>
<Label className="mb-2">
{t("importProfile.profilesToImport")}
</Label>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
{selectedProfiles.map((profile) => (
<div
key={item.source_path}
className="flex items-center gap-2 p-1 text-sm"
key={profile.path}
className="flex items-center gap-2"
>
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" && "text-destructive",
)}
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
title={profile.path}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
{profile.name}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
{translateBackendError(t, new Error(item.error))}
</span>
)}
<Input
className="flex-1"
aria-label={t("importProfile.newProfileName")}
value={profileNames[profile.path] ?? profile.name}
onChange={(e) => {
setProfileNames((prev) => ({
...prev,
[profile.path]: e.target.value,
}));
}}
placeholder={t(
"importProfile.newProfileNamePlaceholder",
)}
/>
</div>
))}
</div>
</div>
)}
</div>
)}
<div>
<Label className="mb-2">
{t("importProfile.proxyOptional")}
</Label>
<Select
value={proxyAssignment}
onValueChange={setProxyAssignment}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noProxy")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noProxy")}
</SelectItem>
{storedProxies.length > 0 && (
<SelectItem value="round-robin">
{t("importProfile.proxyRoundRobin")}
</SelectItem>
)}
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{vpnConfigs.length > 0 && (
<div>
<Label className="mb-2">
{t("importProfile.vpnOptional")}
</Label>
<Select
value={vpnAssignment}
onValueChange={setVpnAssignment}
>
<SelectTrigger>
<SelectValue placeholder={t("importProfile.noVpn")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noVpn")}
</SelectItem>
{vpnConfigs.map((vpn) => (
<SelectItem key={vpn.id} value={vpn.id}>
{vpn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<button
type="button"
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
>
<AnimatedDisclosureChevron open={showAdvanced}>
<LuChevronRight className="size-3.5" />
</AnimatedDisclosureChevron>
{t("importProfile.advancedOptions")}
</button>
<AnimatedDisclosureContent
open={showAdvanced}
className="mt-3 space-y-4"
>
<div>
<Label className="mb-2">
{t("importProfile.groupOptional")}
</Label>
{isCreatingGroup ? (
<div className="flex gap-2">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"importProfile.newGroupNamePlaceholder",
)}
/>
<Button
variant="outline"
disabled={!newGroupName.trim()}
onClick={() => void handleCreateGroup()}
>
{t("common.buttons.create")}
</Button>
<Button
variant="ghost"
onClick={() => {
setIsCreatingGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<Select
value={selectedGroupId}
onValueChange={(value) => {
if (value === "create-new") {
setIsCreatingGroup(true);
} else {
setSelectedGroupId(value);
}
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("importProfile.noGroup")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("importProfile.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
<SelectItem value="create-new">
{t("importProfile.createNewGroup")}
</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div>
<Label className="mb-2">
{t("importProfile.duplicateStrategyLabel")}
</Label>
<Select
value={duplicateStrategy}
onValueChange={(value) => {
setDuplicateStrategy(value as DuplicateStrategy);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rename">
{t("importProfile.duplicateRename")}
</SelectItem>
<SelectItem value="skip">
{t("importProfile.duplicateSkip")}
</SelectItem>
</SelectContent>
</Select>
</div>
</AnimatedDisclosureContent>
</div>
<div>
<button
type="button"
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => setShowFingerprint((v) => !v)}
aria-expanded={showFingerprint}
>
<AnimatedDisclosureChevron open={showFingerprint}>
<LuChevronRight className="size-3.5" />
</AnimatedDisclosureChevron>
{t("importProfile.configureFingerprint")}
</button>
<AnimatedDisclosureContent
open={showFingerprint}
className="mt-3"
>
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</AnimatedDisclosureContent>
</div>
</div>
)}
{currentStep === "importing" && (
<div className="space-y-4">
{isImporting && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.importingTitle")}
</h3>
<Progress value={progressPercent} />
{progress && (
<p className="text-sm text-muted-foreground">
{t("importProfile.importProgress", {
completed: progress.completed,
total: progress.total,
})}
{progress.status === "importing" && (
<> {progress.name}</>
)}
</p>
)}
</div>
)}
{result && (
<div className="space-y-2">
<h3 className="text-lg font-medium">
{t("importProfile.resultsSummary", {
imported: result.imported_count,
skipped: result.skipped_count,
failed: result.failed_count,
})}
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
<div
key={item.source_path}
className="flex items-center gap-2 p-1 text-sm"
>
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" && "text-destructive",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
{translateBackendError(t, new Error(item.error))}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
<div
className={cn(
"flex shrink-0 items-center justify-end gap-2",
subPage
? "mx-auto w-full max-w-2xl border-t border-border pt-2"
? "mx-auto w-full max-w-3xl border-t border-border pt-2"
: undefined,
)}
>
+299 -289
View File
@@ -315,167 +315,261 @@ export function IntegrationsDialog({
</DialogHeader>
)}
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto",
subPage && "mx-auto w-full max-w-3xl",
)}
>
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent
value="api"
className="@container mt-4 flex flex-col gap-4"
>
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
<AnimatedTabsContent
value="api"
className="@container mt-4 flex flex-col gap-4"
>
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
</div>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) =>
void handleApiToggle(checked)
}
/>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div>
{settings.api_enabled && (
<>
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={apiPortDraft}
onChange={(e) => {
setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10);
if (
!Number.isNaN(val) &&
val >= 1 &&
val <= 65535
) {
setSettings({ ...settings, api_port: val });
{settings.api_enabled && (
<>
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={apiPortDraft}
onChange={(e) => {
setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10);
if (
!Number.isNaN(val) &&
val >= 1 &&
val <= 65535
) {
setSettings({ ...settings, api_port: val });
}
}}
onBlur={() => {
const val = Number.parseInt(apiPortDraft, 10);
if (Number.isNaN(val) || val < 1 || val > 65535) {
setApiPortDraft(String(settings.api_port));
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<Button
size="sm"
variant="outline"
disabled={
isApiStarting ||
apiServerPort === settings.api_port
}
}}
onBlur={() => {
const val = Number.parseInt(apiPortDraft, 10);
if (Number.isNaN(val) || val < 1 || val > 65535) {
setApiPortDraft(String(settings.api_port));
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<Button
size="sm"
variant="outline"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
t("integrations.apiInvalidPort"),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
"integrations.apiInvalidPortDescription",
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
return;
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(
t("integrations.apiStartFailed"),
{
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
},
);
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.apiTokenLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="pr-10 font-mono"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.apiTokenLabel")}
{t("integrations.apiExampleRequest")}
</Label>
<CopyToClipboard
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<div className="flex items-center gap-2">
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</>
)}
</AnimatedTabsContent>
<AnimatedTabsContent
value="mcp"
className="mt-4 flex flex-col gap-5"
>
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
</div>
</div>
<AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) =>
void handleMcpToggle(checked)
}
/>
</div>
</div>
{mcpConfig && (
<>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
type={showMcpUrl ? "text" : "password"}
value={mcpUrl}
readOnly
className="pr-10 font-mono"
className="pr-10 font-mono text-xs"
/>
<Button
type="button"
@@ -483,10 +577,10 @@ export function IntegrationsDialog({
size="sm"
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
setShowMcpUrl(!showMcpUrl);
}}
>
{showApiToken ? (
{showMcpUrl ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
@@ -494,164 +588,80 @@ export function IntegrationsDialog({
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<div className="@container flex flex-col gap-3">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.apiExampleRequest")}
{t("integrations.mcp.clientsLabel")}
</Label>
<CopyToClipboard
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</>
)}
</AnimatedTabsContent>
<AnimatedTabsContent
value="mcp"
className="mt-4 flex flex-col gap-5"
>
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
</div>
</div>
<AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(checked)}
/>
</div>
</div>
{mcpConfig && (
<>
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input
type={showMcpUrl ? "text" : "password"}
value={mcpUrl}
readOnly
className="pr-10 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowMcpUrl(!showMcpUrl);
}}
>
{showMcpUrl ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")}
/>
</div>
</div>
<div className="@container flex flex-col gap-3">
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
<div
key={agent.id}
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
>
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
<AgentIcon category={agent.category} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{agent.display_name}
</p>
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{categoryLabel(t, agent.category)}
</p>
</div>
{agent.connected ? (
<div className="flex items-center gap-1">
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
<LuCheck className="size-3" />
{t("integrations.mcp.connected")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void handleRemoveAgent(agent)}
aria-label={t(
"integrations.mcp.removeAriaLabel",
{
name: agent.display_name,
},
)}
>
<LuTrash2 className="size-4" />
</Button>
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
<div
key={agent.id}
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
>
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
<AgentIcon category={agent.category} />
</div>
) : (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void handleAddAgent(agent)}
>
{t("integrations.mcp.add")}
</Button>
)}
</div>
);
})}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{agent.display_name}
</p>
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{categoryLabel(t, agent.category)}
</p>
</div>
{agent.connected ? (
<div className="flex items-center gap-1">
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
<LuCheck className="size-3" />
{t("integrations.mcp.connected")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() =>
void handleRemoveAgent(agent)
}
aria-label={t(
"integrations.mcp.removeAriaLabel",
{
name: agent.display_name,
},
)}
>
<LuTrash2 className="size-4" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void handleAddAgent(agent)}
>
{t("integrations.mcp.add")}
</Button>
)}
</div>
);
})}
</div>
</div>
</div>
</>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</div>
</DialogContent>
</Dialog>
+143 -24
View File
@@ -14,6 +14,7 @@ import {
import { useVirtualizer } from "@tanstack/react-virtual";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { AnimatePresence, motion } from "motion/react";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -31,6 +32,7 @@ import {
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUserSearch,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -89,6 +91,7 @@ import {
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { formatRelativeTime } from "@/lib/flag-utils";
import { cn } from "@/lib/utils";
import type {
@@ -107,11 +110,13 @@ import {
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
import { Logo } from "./icons/logo";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
import { TrafficDetailsDialog } from "./traffic-details-dialog";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
import { Skeleton } from "./ui/skeleton";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
@@ -424,15 +429,7 @@ function DnsCell({
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const level = profile.dns_blocklist ?? null;
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
// Keep the list ordered from least to most restrictive.
const LEVELS: { value: string; labelKey: string }[] = [
{ value: "light", labelKey: "dnsBlocklist.light" },
{ value: "normal", labelKey: "dnsBlocklist.normal" },
{ value: "pro", labelKey: "dnsBlocklist.pro" },
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
];
const LEVELS = DNS_BLOCKLIST_LEVELS;
const currentLabel =
level === null
? null
@@ -1168,6 +1165,12 @@ interface ProfilesDataTableProps {
*/
infoDialogProfile?: BrowserProfile | null;
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
/** Initial data load in flight — renders skeleton rows instead of "empty". */
isLoading?: boolean;
/** True when the app has zero profiles overall (not just a filtered view). */
showOnboardingEmptyState?: boolean;
onCreateProfile?: () => void;
onImportProfiles?: () => void;
}
export function ProfilesDataTable({
@@ -1205,6 +1208,10 @@ export function ProfilesDataTable({
onRemovePassword,
infoDialogProfile,
onInfoDialogProfileChange,
isLoading = false,
showOnboardingEmptyState = false,
onCreateProfile,
onImportProfiles,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -2391,13 +2398,42 @@ export function ProfilesDataTable({
: void handleProfileLaunch(profile)
}
>
{isLaunching || isStopping ? (
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : isRunning ? (
<LuSquare className="size-3.5 fill-current" />
) : (
<LuPlay className="size-3.5 fill-current" />
)}
<AnimatePresence mode="wait" initial={false}>
{isLaunching || isStopping ? (
<motion.span
key="spinner"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="grid place-items-center"
>
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
</motion.span>
) : isRunning ? (
<motion.span
key="stop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="grid place-items-center"
>
<LuSquare className="size-3.5 fill-current" />
</motion.span>
) : (
<motion.span
key="play"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="grid place-items-center"
>
<LuPlay className="size-3.5 fill-current" />
</motion.span>
)}
</AnimatePresence>
</RippleButton>
</span>
</TooltipTrigger>
@@ -3172,14 +3208,97 @@ export function ProfilesDataTable({
</TableHeader>
<TableBody className="overflow-visible">
{sortedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
isLoading ? (
Array.from({ length: 8 }, (_, i) => (
<TableRow
key={`skeleton-${i}`}
className="border-0!"
style={{ height: `${ROW_HEIGHT}px` }}
>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-0"
>
<div className="flex items-center gap-3">
<Skeleton className="size-7 shrink-0 rounded-md" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
) : showOnboardingEmptyState ? (
<TableRow className="border-0! hover:bg-transparent">
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-16"
>
<div className="flex flex-col items-center gap-3 text-center">
<Logo className="size-12 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">
{t("profiles.table.emptyTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profiles.table.emptyHint")}
</p>
</div>
<div className="mt-1 flex gap-2">
{onCreateProfile && (
<RippleButton size="sm" onClick={onCreateProfile}>
{t("profiles.table.emptyCreate")}
</RippleButton>
)}
{onImportProfiles && (
<RippleButton
size="sm"
variant="outline"
onClick={onImportProfiles}
>
{t("profiles.table.emptyImport")}
</RippleButton>
)}
</div>
</div>
</TableCell>
</TableRow>
) : (
<TableRow className="border-0! hover:bg-transparent">
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="py-16"
>
<div className="flex flex-col items-center gap-3 text-center">
<div className="grid size-12 place-items-center rounded-full bg-muted/60">
<LuUserSearch className="size-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium text-foreground">
{t("profiles.table.emptyFilteredTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("profiles.table.emptyFilteredHint")}
</p>
</div>
{onCreateProfile && (
<RippleButton
size="sm"
variant="outline"
className="mt-1"
onClick={onCreateProfile}
>
{t("profiles.table.emptyCreate")}
</RippleButton>
)}
</div>
</TableCell>
</TableRow>
)
) : (
<>
{paddingTop > 0 && (
+61 -5
View File
@@ -15,6 +15,7 @@ import {
LuCookie,
LuCopy,
LuDownload,
LuEraser,
LuFingerprint,
LuGlobe,
LuGroup,
@@ -34,6 +35,7 @@ import {
LuX,
} from "react-icons/lu";
import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Button } from "@/components/ui/button";
import {
ColorPicker,
@@ -73,6 +75,7 @@ import {
} from "@/components/ui/select";
import { translateBackendError } from "@/lib/backend-errors";
import { getProfileIcon } from "@/lib/browser-utils";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
@@ -126,6 +129,56 @@ function _OSIcon({ os }: { os: string }) {
}
}
function ClearOnCloseToggle({
profile,
isDisabled,
}: {
profile: BrowserProfile;
isDisabled: boolean;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = React.useState(profile.clear_on_close === true);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
setEnabled(profile.clear_on_close === true);
}, [profile.clear_on_close]);
const toggle = async (next: boolean) => {
setEnabled(next);
setSaving(true);
try {
await invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: next,
});
} catch (error) {
setEnabled(!next);
showErrorToast(translateBackendError(t, error));
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2">
<LuEraser className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("clearOnClose.label")}</p>
<p className="text-[11px] text-muted-foreground">
{t("clearOnClose.description")}
</p>
</div>
<AnimatedSwitch
checked={enabled}
disabled={saving || isDisabled}
onCheckedChange={(v) => void toggle(v === true)}
aria-label={t("clearOnClose.label")}
/>
</div>
);
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
@@ -976,6 +1029,10 @@ function ProfileInfoLayout({
</div>
</div>
{!profile.ephemeral && !profile.password_protected && (
<ClearOnCloseToggle profile={profile} isDisabled={isDisabled} />
)}
{profile.created_by_email && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
@@ -2320,11 +2377,10 @@ export function ProfileDnsBlocklistDialog({
const options = [
{ value: "", label: t("dnsBlocklist.none") },
{ value: "light", label: t("dnsBlocklist.light") },
{ value: "normal", label: t("dnsBlocklist.normal") },
{ value: "pro", label: t("dnsBlocklist.pro") },
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
...DNS_BLOCKLIST_LEVELS.map((l) => ({
value: l.value as string,
label: t(l.labelKey),
})),
];
return (
+51 -87
View File
@@ -1,5 +1,6 @@
"use client";
import { motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
@@ -7,12 +8,15 @@ import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuInfo,
LuKeyboard,
LuPlug,
LuPuzzle,
LuUser,
LuUsers,
} from "react-icons/lu";
import { launchDonutClone } from "@/lib/donut-physics";
import { MOTION_SPRING_POSITION } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -31,11 +35,6 @@ export type AppPage =
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
function useLogoEasterEgg({
@@ -64,74 +63,15 @@ function useLogoEasterEgg({
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const cancelFallRef = useRef<(() => void) | null>(null);
const triggerFall = useCallback(() => {
const el = logoRef.current;
if (!el || isFalling) return;
setIsFalling(true);
const rect = el.getBoundingClientRect();
const startX = rect.left;
const startY = rect.top;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.pointerEvents = "none";
clone.style.margin = "0";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = -500;
// Roll right first, bounce off the right wall, then escape the left.
let vx = INITIAL_HORIZONTAL_SPEED;
let rotation = 0;
let lastTime = performance.now();
const animate = (time: number) => {
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
// Read live so a mid-animation window resize moves the floor/wall.
const floorY = window.innerHeight;
const rightWall = window.innerWidth;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
vy =
Math.abs(vy) > MIN_BOUNCE_VELOCITY
? -Math.abs(vy) * BOUNCE_DAMPING
: -MIN_BOUNCE_VELOCITY * 3;
}
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
// damping), and keep rolling. Left wall has no bounce — the donut
// exits the window off the left edge.
const currentRight = startX + x + rect.width;
if (currentRight >= rightWall && vx > 0) {
x = rightWall - startX - rect.width;
vx = -Math.abs(vx) * 0.9;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
const offScreenLeft = startX + x + rect.width < -200;
const offScreenBottom = startY + y > floorY + 100;
const offScreenTop = startY + y + rect.height < -200;
if (offScreenLeft || offScreenBottom || offScreenTop) {
clone.remove();
cancelFallRef.current = launchDonutClone(el, {
onExit: () => {
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
@@ -139,16 +79,13 @@ function useLogoEasterEgg({
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
},
});
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
cancelFallRef.current?.();
};
}, []);
@@ -236,6 +173,19 @@ function useLogoEasterEgg({
interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
onOpenAbout: () => void;
}
/** Shared-element indicator that slides between the active rail items. */
function ActiveIndicator() {
return (
<motion.span
aria-hidden="true"
layoutId="rail-indicator"
transition={MOTION_SPRING_POSITION}
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
/>
);
}
interface RailItem {
@@ -275,7 +225,11 @@ const MORE_ITEMS: MoreMenuItem[] = [
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
export function RailNav({
currentPage,
onNavigate,
onOpenAbout,
}: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const {
@@ -358,12 +312,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
/>
)}
{active && <ActiveIndicator />}
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
@@ -413,12 +362,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
)}
>
{currentPage === "settings" && (
<span
aria-hidden="true"
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
/>
)}
{currentPage === "settings" && <ActiveIndicator />}
<GoGear className="size-3.5" />
</button>
</TooltipTrigger>
@@ -435,7 +379,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
setMoreOpen(false);
}}
/>
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
<button
key={page}
@@ -459,6 +403,26 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
</span>
</button>
))}
<button
type="button"
onClick={() => {
setMoreOpen(false);
onOpenAbout();
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
>
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
<LuInfo className="size-3" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{t("rail.more.about")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{t("rail.more.aboutHint")}
</span>
</span>
</button>
</div>
</>
)}
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -8,7 +8,11 @@ import {
useMemo,
useState,
} from "react";
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
import {
applyThemeColors,
clearThemeColors,
withThemeTransition,
} from "@/lib/themes";
interface AppSettings {
set_as_default_browser: boolean;
@@ -54,11 +58,13 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
if (newTheme === "custom") {
applyClassToHtml("dark");
} else {
applyClassToHtml(newTheme);
}
withThemeTransition(() => {
if (newTheme === "custom") {
applyClassToHtml("dark");
} else {
applyClassToHtml(newTheme);
}
});
}, []);
// Load initial theme from Tauri settings
+494 -359
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { LuSearch, LuTrash2 } from "react-icons/lu";
import {
Area,
AreaChart,
@@ -17,6 +18,13 @@ import type {
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { toast } from "sonner";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import {
Dialog,
DialogContent,
@@ -24,6 +32,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -37,7 +46,10 @@ import {
TooltipTrigger,
Tooltip as UITooltip,
} from "@/components/ui/tooltip";
import { translateBackendError } from "@/lib/backend-errors";
import type { FilteredTrafficStats } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
import { RippleButton } from "./ui/ripple";
type TimePeriod =
| "1m"
@@ -157,24 +169,28 @@ export function TrafficDetailsDialog({
const { t } = useTranslation();
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
const [isClearing, setIsClearing] = React.useState(false);
const [domainSearch, setDomainSearch] = React.useState("");
const fetchStats = React.useCallback(async () => {
if (!profileId) return;
try {
const seconds = getSecondsForPeriod(timePeriod);
const filteredStats = await invoke<FilteredTrafficStats | null>(
"get_traffic_stats_for_period",
{ profileId, seconds },
);
setStats(filteredStats);
} catch (error) {
console.error("Failed to fetch traffic stats:", error);
}
}, [profileId, timePeriod]);
// Fetch stats periodically - now uses filtered API
React.useEffect(() => {
if (!isOpen || !profileId) return;
const fetchStats = async () => {
try {
const seconds = getSecondsForPeriod(timePeriod);
const filteredStats = await invoke<FilteredTrafficStats | null>(
"get_traffic_stats_for_period",
{ profileId, seconds },
);
setStats(filteredStats);
} catch (error) {
console.error("Failed to fetch traffic stats:", error);
}
};
void fetchStats();
const interval = setInterval(() => {
void fetchStats();
@@ -185,7 +201,22 @@ export function TrafficDetailsDialog({
// Clear stats from memory when dialog closes to free up memory
setStats(null);
};
}, [isOpen, profileId, timePeriod]);
}, [isOpen, profileId, fetchStats]);
const handleClearHistory = React.useCallback(async () => {
if (!profileId) return;
setIsClearing(true);
try {
await invoke("clear_profile_traffic_stats", { profileId });
setStats(null);
setShowClearConfirm(false);
await fetchStats();
} catch (error) {
toast.error(translateBackendError(t, error));
} finally {
setIsClearing(false);
}
}, [profileId, fetchStats, t]);
// Transform data for chart (already filtered by backend)
const chartData = React.useMemo(() => {
@@ -231,24 +262,31 @@ export function TrafficDetailsDialog({
[t],
);
// Top domains sorted by total traffic
const topDomainsByTraffic = React.useMemo(() => {
// Domains matching the search query (empty query = all).
const filteredDomains = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort(
(a, b) =>
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
)
.slice(0, 10);
}, [stats]);
const q = domainSearch.trim().toLowerCase();
const all = Object.values(stats.domains);
return q ? all.filter((d) => d.domain.toLowerCase().includes(q)) : all;
}, [stats, domainSearch]);
// Top domains sorted by total traffic. When searching, show every match
// (not just the top 10) so the queried domain is never hidden below the cut.
const topDomainsByTraffic = React.useMemo(() => {
const sorted = [...filteredDomains].sort(
(a, b) =>
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
);
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
}, [filteredDomains, domainSearch]);
// Top domains sorted by request count
const topDomainsByRequests = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort((a, b) => b.request_count - a.request_count)
.slice(0, 10);
}, [stats]);
const sorted = [...filteredDomains].sort(
(a, b) => b.request_count - a.request_count,
);
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
}, [filteredDomains, domainSearch]);
return (
<Dialog
@@ -257,364 +295,461 @@ export function TrafficDetailsDialog({
if (!open) onClose();
}}
>
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
<DialogHeader>
<DialogTitle>
{t("traffic.title")}
{profileName && (
<span className="ml-2 font-normal text-muted-foreground">
{profileName}
</span>
)}
</DialogTitle>
<DialogContent className="flex max-h-[80vh] max-w-[min(56rem,calc(100%-4rem))] flex-col">
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<DialogTitle>
{t("traffic.title")}
{profileName && (
<span className="ml-2 font-normal text-muted-foreground">
{profileName}
</span>
)}
</DialogTitle>
<RippleButton
variant="outline"
size="sm"
disabled={!stats}
onClick={() => setShowClearConfirm(true)}
>
<LuTrash2 className="mr-1.5 size-3.5" />
{t("traffic.clearHistory")}
</RippleButton>
</div>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<div className="space-y-6 pr-4">
{/* Chart with Period Selector */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">
{t("traffic.bandwidthOverTime")}
</h3>
<Select
value={timePeriod}
onValueChange={(v) => {
setTimePeriod(v as TimePeriod);
}}
>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue
placeholder={t("traffic.timePeriodPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
</SelectContent>
</Select>
</div>
<AnimatedTabs
defaultValue="overview"
className="flex min-h-0 flex-1 flex-col gap-4"
>
<AnimatedTabsList className="shrink-0">
<AnimatedTabsTrigger value="overview">
{t("traffic.tabOverview")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="domains">
{t("traffic.tabTopDomains")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<div className="h-[clamp(200px,28vh,360px)] w-full">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="sentGradient"
x1="0"
y1="0"
x2="0"
y2="1"
<AnimatedTabsContent
value="overview"
className="min-h-0 flex-1 overflow-hidden"
>
<ScrollArea className="h-[56vh]">
<div className="space-y-6 pr-4">
{/* Chart with Period Selector */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">
{t("traffic.bandwidthOverTime")}
</h3>
<Select
value={timePeriod}
onValueChange={(v) => {
setTimePeriod(v as TimePeriod);
}}
>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue
placeholder={t("traffic.timePeriodPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">
{t("traffic.last1m")}
</SelectItem>
<SelectItem value="5m">
{t("traffic.last5m")}
</SelectItem>
<SelectItem value="30m">
{t("traffic.last30m")}
</SelectItem>
<SelectItem value="1h">
{t("traffic.last1h")}
</SelectItem>
<SelectItem value="2h">
{t("traffic.last2h")}
</SelectItem>
<SelectItem value="4h">
{t("traffic.last4h")}
</SelectItem>
<SelectItem value="1d">
{t("traffic.last1d")}
</SelectItem>
<SelectItem value="7d">
{t("traffic.last7d")}
</SelectItem>
<SelectItem value="30d">
{t("traffic.last30d")}
</SelectItem>
<SelectItem value="all">
{t("traffic.allTime")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-[clamp(200px,28vh,360px)] w-full">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
<defs>
<linearGradient
id="sentGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="receivedGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
<XAxis
dataKey="time"
tickFormatter={(t) =>
new Date(t * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
/>
</linearGradient>
<linearGradient
id="receivedGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
<YAxis
tickFormatter={(v) => formatBytesPerSecond(v)}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={60}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
<Tooltip content={renderTooltip} />
<Area
type="monotone"
dataKey="sent"
stackId="1"
stroke="var(--chart-1)"
fill="url(#sentGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="time"
tickFormatter={(t) =>
new Date(t * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
/>
<YAxis
tickFormatter={(v) => formatBytesPerSecond(v)}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={60}
/>
<Tooltip content={renderTooltip} />
<Area
type="monotone"
dataKey="sent"
stackId="1"
stroke="var(--chart-1)"
fill="url(#sentGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="received"
stackId="1"
stroke="var(--chart-2)"
fill="url(#receivedGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<Area
type="monotone"
dataKey="received"
stackId="1"
stroke="var(--chart-2)"
fill="url(#receivedGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
{t("traffic.sentLegend")}
</span>
<div className="mt-2 flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
{t("traffic.sentLegend")}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
{t("traffic.receivedLegend")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
{t("traffic.receivedLegend")}
</span>
{/* Period Stats - now uses backend-computed values */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.sentLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold text-chart-1">
{formatBytes(stats?.period_bytes_sent ?? 0)}
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.receivedLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold text-chart-2">
{formatBytes(stats?.period_bytes_received ?? 0)}
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.requestsLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold">
{(stats?.period_requests ?? 0).toLocaleString()}
</p>
</div>
</div>
</div>
</div>
{/* Period Stats - now uses backend-computed values */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.sentLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold text-chart-1">
{formatBytes(stats?.period_bytes_sent ?? 0)}
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.receivedLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold text-chart-2">
{formatBytes(stats?.period_bytes_received ?? 0)}
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
{t("traffic.requestsLabel", {
period:
timePeriod === "all"
? t("traffic.totalSuffix")
: timePeriod,
})}
</p>
<p className="text-lg font-semibold">
{(stats?.period_requests ?? 0).toLocaleString()}
</p>
</div>
</div>
{/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
<div>
<span className="font-medium">
{t("traffic.allTimeTraffic")}
</span>{" "}
{formatBytes(
(stats?.total_bytes_sent ?? 0) +
(stats?.total_bytes_received ?? 0),
)}
</div>
<div>
<span className="font-medium">
{t("traffic.allTimeRequests")}
</span>{" "}
{stats?.total_requests?.toLocaleString() ?? 0}
</div>
</div>
{/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
<div>
<span className="font-medium">
{t("traffic.allTimeTraffic")}
</span>{" "}
{formatBytes(
(stats?.total_bytes_sent ?? 0) +
(stats?.total_bytes_received ?? 0),
{/* Disclaimer about proxy/VPN traffic calculation */}
<p className="text-xs text-muted-foreground italic">
{t("traffic.proxyDisclaimer")}
</p>
{/* No data state (overview) */}
{!stats && (
<div className="py-8 text-center text-muted-foreground">
<p>{t("traffic.noData")}</p>
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
</div>
)}
</div>
<div>
<span className="font-medium">
{t("traffic.allTimeRequests")}
</span>{" "}
{stats?.total_requests?.toLocaleString() ?? 0}
</div>
</div>
</ScrollArea>
</AnimatedTabsContent>
{/* Disclaimer about proxy/VPN traffic calculation */}
<p className="text-xs text-muted-foreground italic">
{t("traffic.proxyDisclaimer")}
</p>
{/* Top Domains by Traffic */}
{topDomainsByTraffic.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.topByTraffic", {
period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3>
<div className="rounded-md border">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{t("traffic.columnDomain")}</span>
<span className="text-right">
{t("traffic.columnRequests")}
</span>
<span className="text-right">
{t("traffic.columnSent")}
</span>
<span className="text-right">
{t("traffic.columnReceived")}
</span>
</div>
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
>
<div className="flex min-w-0 items-center gap-2">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right text-chart-1">
{formatBytes(domain.bytes_sent)}
</span>
<span className="text-right text-chart-2">
{formatBytes(domain.bytes_received)}
</span>
</div>
))}
</div>
<AnimatedTabsContent
value="domains"
className="min-h-0 flex-1 overflow-hidden"
>
<ScrollArea className="h-[56vh]">
<div className="space-y-6 pr-4">
<div className="relative">
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={domainSearch}
onChange={(e) => setDomainSearch(e.target.value)}
placeholder={t("traffic.searchDomains")}
className="h-8 pl-8 text-sm"
/>
</div>
</div>
)}
{/* Top Domains by Requests */}
{topDomainsByRequests.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.topByRequests", {
period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3>
<div className="rounded-md border">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{t("traffic.columnDomain")}</span>
<span className="text-right">
{t("traffic.columnRequests")}
</span>
<span className="text-right">
{t("traffic.columnTotal")}
</span>
</div>
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
>
<div className="flex min-w-0 items-center gap-2">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
{domainSearch.trim() && topDomainsByTraffic.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("traffic.noDomainMatch")}
</p>
)}
{/* Top Domains by Traffic */}
{topDomainsByTraffic.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.topByTraffic", {
period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3>
<div className="rounded-md border">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{t("traffic.columnDomain")}</span>
<span className="text-right">
{t("traffic.columnRequests")}
</span>
<span className="text-right">
{formatBytes(
domain.bytes_sent + domain.bytes_received,
)}
{t("traffic.columnSent")}
</span>
<span className="text-right">
{t("traffic.columnReceived")}
</span>
</div>
))}
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
>
<div className="flex min-w-0 items-center gap-2">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right text-chart-1">
{formatBytes(domain.bytes_sent)}
</span>
<span className="text-right text-chart-2">
{formatBytes(domain.bytes_received)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
)}
{/* Unique IPs */}
{stats?.unique_ips && stats.unique_ips.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3>
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
key={ip}
className="rounded bg-muted px-2 py-1 font-mono text-xs"
>
{ip}
</span>
))}
{/* Top Domains by Requests */}
{topDomainsByRequests.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.topByRequests", {
period:
timePeriod === "all"
? t("traffic.allTimeShort")
: timePeriod,
})}
</h3>
<div className="rounded-md border">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>{t("traffic.columnDomain")}</span>
<span className="text-right">
{t("traffic.columnRequests")}
</span>
<span className="text-right">
{t("traffic.columnTotal")}
</span>
</div>
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
>
<div className="flex min-w-0 items-center gap-2">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right">
{formatBytes(
domain.bytes_sent + domain.bytes_received,
)}
</span>
</div>
))}
</div>
</div>
</div>
</FadingScrollArea>
</div>
)}
)}
{/* No data state */}
{!stats && (
<div className="py-8 text-center text-muted-foreground">
<p>{t("traffic.noData")}</p>
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
{/* Unique IPs */}
{stats?.unique_ips && stats.unique_ips.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium">
{t("traffic.uniqueIps", {
count: stats.unique_ips.length,
})}
</h3>
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
key={ip}
className="rounded bg-muted px-2 py-1 font-mono text-xs"
>
{ip}
</span>
))}
</div>
</FadingScrollArea>
</div>
)}
{/* No data state (domains) */}
{!stats && (
<div className="py-8 text-center text-muted-foreground">
<p>{t("traffic.noData")}</p>
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</ScrollArea>
</AnimatedTabsContent>
</AnimatedTabs>
<DeleteConfirmationDialog
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
onConfirm={handleClearHistory}
title={t("traffic.clearHistoryTitle")}
description={t("traffic.clearHistoryDescription", {
name: profileName ?? "",
})}
confirmButtonText={t("traffic.clearHistory")}
isLoading={isClearing}
/>
</DialogContent>
</Dialog>
);
+12 -4
View File
@@ -162,8 +162,12 @@ function SubPageContent({
<motion.div
data-slot="sub-page"
data-sub-page="true"
initial={false}
animate={{ opacity: 1 }}
// Sub-pages enter with a short rise+fade so rail navigation reads as a
// transition instead of a hard cut. Same axis for every page (spatial
// consistency); the outgoing page unmounts under the incoming fade.
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
style={{
position: "relative",
display: "flex",
@@ -177,7 +181,11 @@ function SubPageContent({
margin: 0,
padding: 12,
gap: 12,
overflow: "auto",
// The sub-page wrapper never scrolls itself — exactly one inner
// element per page owns scrolling (full-width, so the wheel works
// over side gutters too). "auto" here created a competing,
// never-engaged scroll container.
overflow: "hidden",
background: "var(--background)",
containerType: "inline-size",
}}
@@ -258,7 +266,7 @@ function DialogContent({
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
// gutter even when callers override max-w-*: tailwind-merge drops
// a base max-w in favor of the caller's, but leaves width alone.
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg",
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
className,
)}
{...props}
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
@@ -232,7 +232,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
collisionPadding={collisionPadding}
className={cn(
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
+1 -1
View File
@@ -32,7 +32,7 @@ function PopoverContent({
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
+1 -1
View File
@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
}
export { Skeleton };
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless."
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
"privacy": {
"consistencyWarning": "Fingerprint consistency warning",
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
"clearTraffic": "Clear all traffic history",
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
"clearTrafficSuccess": "Traffic history cleared"
}
},
"header": {
"searchPlaceholder": "Search profiles...",
@@ -245,7 +252,13 @@
"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"
"cantModifyUpdating": "Can't modify profile while browser is updating",
"emptyTitle": "No profiles yet",
"emptyHint": "Create your first profile or import existing ones from another browser.",
"emptyCreate": "Create profile",
"emptyImport": "Import profiles",
"emptyFilteredTitle": "No profiles found",
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one."
},
"actions": {
"launch": "Launch",
@@ -1294,7 +1307,28 @@
"domains": "domains",
"fresh": "Fresh",
"stale": "Stale",
"notCached": "Not cached"
"notCached": "Not cached",
"tabBlocklists": "Blocklists",
"tabCustom": "Custom lists",
"custom.description": "Build your own blocklist from source URLs and manual rules. Allowed domains always override blocked ones.",
"custom.sourcesLabel": "Blocklist source URLs",
"custom.sourcesPlaceholder": "One URL per line",
"custom.blockLabel": "Blocked domains",
"custom.blockPlaceholder": "One domain per line",
"custom.allowLabel": "Allowed domains",
"custom.allowPlaceholder": "One domain per line",
"custom.allowHint": "Allowed domains are removed from the compiled blocklist, overriding any block rule.",
"custom.saved": "Custom DNS rules saved",
"custom.imported": "Custom DNS rules imported",
"custom.exported": "Custom DNS rules exported",
"custom.exportTxt": "Export TXT",
"custom.exportJson": "Export JSON",
"customLevel": "Custom",
"custom.allowlistModeLabel": "Allowlist mode",
"custom.allowlistModeOn": "Only the domains below are reachable; everything else is blocked.",
"custom.allowlistModeOff": "Block listed domains; allow everything else.",
"custom.allowedOnlyLabel": "Allowed domains (only these)",
"custom.allowedOnlyHint": "Subdomains of a listed domain are allowed too. An empty list disables filtering."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Imported",
"statusSkipped": "Skipped",
"statusFailed": "Failed",
"importButtonCount": "Import ({{count}})"
"importButtonCount": "Import ({{count}})",
"vpnOptional": "VPN (Optional)",
"noVpn": "No VPN",
"advancedOptions": "Advanced options",
"configureFingerprint": "Configure fingerprint (optional)"
},
"syncTooltips": {
"syncing": "Syncing...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Sent",
"receivedLegend": "Received",
"tooltipSent": "↑ Sent: ",
"tooltipReceived": "↓ Received: "
"tooltipReceived": "↓ Received: ",
"clearHistory": "Clear history",
"clearHistoryTitle": "Clear traffic history",
"clearHistoryDescription": "Permanently and securely erase all recorded traffic history for \"{{name}}\". This can't be undone.",
"tabOverview": "Overview",
"tabTopDomains": "Top domains",
"searchDomains": "Search domains…",
"noDomainMatch": "No domains match your search."
},
"proxyCheck": {
"unknownLocation": "Unknown",
@@ -1818,7 +1863,14 @@
"importNoItems": "Nothing selected to import",
"browserNotDownloaded": "No downloaded version of {{browser}} is available. Download it first, then retry the import.",
"archiveExtractionFailed": "Failed to extract the archive: {{detail}}",
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported."
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported.",
"clearOnCloseUnavailable": "Clear-on-close isn't available for ephemeral or password-protected profiles.",
"proxyAndVpnMutuallyExclusive": "A profile can use either a proxy or a VPN, not both.",
"invalidDnsRulesJson": "The selected file isn't valid DNS rules JSON.",
"unsupportedDnsRulesFormat": "Unsupported rules format: {{format}}",
"dnsRulesSaveFailed": "Failed to save the DNS rules.",
"dnsRulesExportFailed": "Failed to export the DNS rules.",
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy."
},
"rail": {
"profiles": "Profiles",
@@ -1831,7 +1883,9 @@
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"keyboardShortcuts": "Keyboard shortcuts",
"keyboardShortcutsHint": "View all shortcuts"
"keyboardShortcutsHint": "View all shortcuts",
"about": "About Donut Browser",
"aboutHint": "Version and app information"
},
"network": "Network",
"integrations": "Integrations",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Launch {{name}}",
"stopProfile": "Stop {{name}}",
"profileInfo": "Info — {{name}}"
"profileInfo": "Info — {{name}}",
"createProfile": "Create profile",
"about": "About Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "No other Wayfern profiles selected"
},
"about": {
"title": "About",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Open-source anti-detect browser, licensed under AGPL-3.0.",
"website": "Website"
},
"clearOnClose": {
"label": "Clear data on close",
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
},
"consistencyWarning": {
"title": "Fingerprint mismatch",
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
"timezoneTitle": "Timezone mismatch",
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
"languageTitle": "Language mismatch",
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
"dontWarnAgain": "Don't warn again for this profile",
"matchToProxy": "Match fingerprint to proxy",
"matching": "Matching…",
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso."
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
"privacy": {
"consistencyWarning": "Advertencia de consistencia de huella digital",
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
"clearTraffic": "Borrar todo el historial de tráfico",
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
"clearTrafficSuccess": "Historial de tráfico borrado"
}
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
@@ -245,7 +252,13 @@
"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"
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador",
"emptyTitle": "Aún no hay perfiles",
"emptyHint": "Crea tu primer perfil o importa perfiles existentes desde otro navegador.",
"emptyCreate": "Crear perfil",
"emptyImport": "Importar perfiles",
"emptyFilteredTitle": "No se encontraron perfiles",
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo."
},
"actions": {
"launch": "Iniciar",
@@ -1294,7 +1307,28 @@
"domains": "dominios",
"fresh": "Actualizado",
"stale": "Desactualizado",
"notCached": "Sin caché"
"notCached": "Sin caché",
"tabBlocklists": "Listas de bloqueo",
"tabCustom": "Listas personalizadas",
"custom.description": "Crea tu propia lista de bloqueo a partir de URLs de origen y reglas manuales. Los dominios permitidos siempre prevalecen sobre los bloqueados.",
"custom.sourcesLabel": "URLs de origen de listas de bloqueo",
"custom.sourcesPlaceholder": "Una URL por línea",
"custom.blockLabel": "Dominios bloqueados",
"custom.blockPlaceholder": "Un dominio por línea",
"custom.allowLabel": "Dominios permitidos",
"custom.allowPlaceholder": "Un dominio por línea",
"custom.allowHint": "Los dominios permitidos se eliminan de la lista de bloqueo compilada, anulando cualquier regla de bloqueo.",
"custom.saved": "Reglas DNS personalizadas guardadas",
"custom.imported": "Reglas DNS personalizadas importadas",
"custom.exported": "Reglas DNS personalizadas exportadas",
"custom.exportTxt": "Exportar TXT",
"custom.exportJson": "Exportar JSON",
"customLevel": "Personalizada",
"custom.allowlistModeLabel": "Modo lista de permitidos",
"custom.allowlistModeOn": "Solo se puede acceder a los dominios de abajo; todo lo demás se bloquea.",
"custom.allowlistModeOff": "Bloquear los dominios de la lista; permitir todo lo demás.",
"custom.allowedOnlyLabel": "Dominios permitidos (solo estos)",
"custom.allowedOnlyHint": "También se permiten los subdominios de un dominio de la lista. Una lista vacía desactiva el filtrado."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Importado",
"statusSkipped": "Omitido",
"statusFailed": "Fallido",
"importButtonCount": "Importar ({{count}})"
"importButtonCount": "Importar ({{count}})",
"vpnOptional": "VPN (opcional)",
"noVpn": "Sin VPN",
"advancedOptions": "Opciones avanzadas",
"configureFingerprint": "Configurar huella digital (opcional)"
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Enviado",
"receivedLegend": "Recibido",
"tooltipSent": "↑ Enviado: ",
"tooltipReceived": "↓ Recibido: "
"tooltipReceived": "↓ Recibido: ",
"clearHistory": "Borrar historial",
"clearHistoryTitle": "Borrar historial de tráfico",
"clearHistoryDescription": "Elimina de forma permanente y segura todo el historial de tráfico registrado de \"{{name}}\". Esta acción no se puede deshacer.",
"tabOverview": "Resumen",
"tabTopDomains": "Dominios principales",
"searchDomains": "Buscar dominios…",
"noDomainMatch": "Ningún dominio coincide con tu búsqueda."
},
"proxyCheck": {
"unknownLocation": "Desconocido",
@@ -1818,7 +1863,14 @@
"importNoItems": "No hay nada seleccionado para importar",
"browserNotDownloaded": "No hay ninguna versión descargada de {{browser}}. Descárgala primero y vuelve a intentar la importación.",
"archiveExtractionFailed": "No se pudo extraer el archivo: {{detail}}",
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP."
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP.",
"clearOnCloseUnavailable": "El borrado al cerrar no está disponible para perfiles efímeros o protegidos con contraseña.",
"proxyAndVpnMutuallyExclusive": "Un perfil puede usar un proxy o una VPN, pero no ambos.",
"invalidDnsRulesJson": "El archivo seleccionado no es un JSON de reglas DNS válido.",
"unsupportedDnsRulesFormat": "Formato de reglas no compatible: {{format}}",
"dnsRulesSaveFailed": "No se pudieron guardar las reglas DNS.",
"dnsRulesExportFailed": "No se pudieron exportar las reglas DNS.",
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy."
},
"rail": {
"profiles": "Perfiles",
@@ -1831,7 +1883,9 @@
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"keyboardShortcuts": "Atajos de teclado",
"keyboardShortcutsHint": "Ver todos los atajos"
"keyboardShortcutsHint": "Ver todos los atajos",
"about": "Acerca de Donut Browser",
"aboutHint": "Versión e información de la aplicación"
},
"network": "Red",
"integrations": "Integraciones",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Iniciar {{name}}",
"stopProfile": "Detener {{name}}",
"profileInfo": "Información — {{name}}"
"profileInfo": "Información — {{name}}",
"createProfile": "Crear perfil",
"about": "Acerca de Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "No hay otros perfiles Wayfern seleccionados"
},
"about": {
"title": "Acerca de",
"version": "Versión {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navegador anti-detección de código abierto, con licencia AGPL-3.0.",
"website": "Sitio web"
},
"clearOnClose": {
"label": "Borrar datos al cerrar",
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
},
"consistencyWarning": {
"title": "Discrepancia de huella digital",
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
"timezoneTitle": "Discrepancia de zona horaria",
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
"languageTitle": "Discrepancia de idioma",
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
"dontWarnAgain": "No volver a advertir para este perfil",
"matchToProxy": "Ajustar huella al proxy",
"matching": "Ajustando…",
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas."
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
"privacy": {
"consistencyWarning": "Avertissement de cohérence d'empreinte",
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
"clearTraffic": "Effacer tout l'historique de trafic",
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
"clearTrafficSuccess": "Historique de trafic effacé"
}
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
@@ -245,7 +252,13 @@
"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"
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur",
"emptyTitle": "Aucun profil pour l'instant",
"emptyHint": "Créez votre premier profil ou importez des profils existants depuis un autre navigateur.",
"emptyCreate": "Créer un profil",
"emptyImport": "Importer des profils",
"emptyFilteredTitle": "Aucun profil trouvé",
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un."
},
"actions": {
"launch": "Lancer",
@@ -1294,7 +1307,28 @@
"domains": "domaines",
"fresh": "À jour",
"stale": "Obsolète",
"notCached": "Non mis en cache"
"notCached": "Non mis en cache",
"tabBlocklists": "Listes de blocage",
"tabCustom": "Listes personnalisées",
"custom.description": "Créez votre propre liste de blocage à partir d'URL sources et de règles manuelles. Les domaines autorisés priment toujours sur les domaines bloqués.",
"custom.sourcesLabel": "URL sources de listes de blocage",
"custom.sourcesPlaceholder": "Une URL par ligne",
"custom.blockLabel": "Domaines bloqués",
"custom.blockPlaceholder": "Un domaine par ligne",
"custom.allowLabel": "Domaines autorisés",
"custom.allowPlaceholder": "Un domaine par ligne",
"custom.allowHint": "Les domaines autorisés sont retirés de la liste de blocage compilée et priment sur toute règle de blocage.",
"custom.saved": "Règles DNS personnalisées enregistrées",
"custom.imported": "Règles DNS personnalisées importées",
"custom.exported": "Règles DNS personnalisées exportées",
"custom.exportTxt": "Exporter en TXT",
"custom.exportJson": "Exporter en JSON",
"customLevel": "Personnalisée",
"custom.allowlistModeLabel": "Mode liste d'autorisation",
"custom.allowlistModeOn": "Seuls les domaines ci-dessous sont accessibles ; tout le reste est bloqué.",
"custom.allowlistModeOff": "Bloquer les domaines listés ; autoriser tout le reste.",
"custom.allowedOnlyLabel": "Domaines autorisés (uniquement ceux-ci)",
"custom.allowedOnlyHint": "Les sous-domaines d'un domaine listé sont aussi autorisés. Une liste vide désactive le filtrage."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Importé",
"statusSkipped": "Ignoré",
"statusFailed": "Échec",
"importButtonCount": "Importer ({{count}})"
"importButtonCount": "Importer ({{count}})",
"vpnOptional": "VPN (facultatif)",
"noVpn": "Sans VPN",
"advancedOptions": "Options avancées",
"configureFingerprint": "Configurer l'empreinte (facultatif)"
},
"syncTooltips": {
"syncing": "Synchronisation...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Envoyé",
"receivedLegend": "Reçu",
"tooltipSent": "↑ Envoyé : ",
"tooltipReceived": "↓ Reçu : "
"tooltipReceived": "↓ Reçu : ",
"clearHistory": "Effacer l'historique",
"clearHistoryTitle": "Effacer l'historique de trafic",
"clearHistoryDescription": "Efface définitivement et en toute sécurité tout l'historique de trafic enregistré pour « {{name}} ». Cette action est irréversible.",
"tabOverview": "Vue d'ensemble",
"tabTopDomains": "Principaux domaines",
"searchDomains": "Rechercher des domaines…",
"noDomainMatch": "Aucun domaine ne correspond à votre recherche."
},
"proxyCheck": {
"unknownLocation": "Inconnu",
@@ -1818,7 +1863,14 @@
"importNoItems": "Rien n'est sélectionné pour l'importation",
"browserNotDownloaded": "Aucune version téléchargée de {{browser}} n'est disponible. Téléchargez-la d'abord, puis réessayez l'importation.",
"archiveExtractionFailed": "Échec de l'extraction de l'archive : {{detail}}",
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge."
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge.",
"clearOnCloseUnavailable": "L'effacement à la fermeture n'est pas disponible pour les profils éphémères ou protégés par mot de passe.",
"proxyAndVpnMutuallyExclusive": "Un profil peut utiliser un proxy ou un VPN, mais pas les deux.",
"invalidDnsRulesJson": "Le fichier sélectionné n'est pas un JSON de règles DNS valide.",
"unsupportedDnsRulesFormat": "Format de règles non pris en charge : {{format}}",
"dnsRulesSaveFailed": "Échec de l'enregistrement des règles DNS.",
"dnsRulesExportFailed": "Échec de l'exportation des règles DNS.",
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy."
},
"rail": {
"profiles": "Profils",
@@ -1831,7 +1883,9 @@
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"keyboardShortcuts": "Raccourcis clavier",
"keyboardShortcutsHint": "Voir tous les raccourcis"
"keyboardShortcutsHint": "Voir tous les raccourcis",
"about": "À propos de Donut Browser",
"aboutHint": "Version et informations sur l'application"
},
"network": "Réseau",
"integrations": "Intégrations",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Lancer {{name}}",
"stopProfile": "Arrêter {{name}}",
"profileInfo": "Informations — {{name}}"
"profileInfo": "Informations — {{name}}",
"createProfile": "Créer un profil",
"about": "À propos de Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "Aucun autre profil Wayfern sélectionné"
},
"about": {
"title": "À propos",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navigateur anti-détection open source, sous licence AGPL-3.0.",
"website": "Site web"
},
"clearOnClose": {
"label": "Effacer les données à la fermeture",
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
},
"consistencyWarning": {
"title": "Incohérence d'empreinte",
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
"timezoneTitle": "Incohérence de fuseau horaire",
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
"languageTitle": "Incohérence de langue",
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
"dontWarnAgain": "Ne plus avertir pour ce profil",
"matchToProxy": "Aligner l'empreinte sur le proxy",
"matching": "Alignement…",
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。"
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
"privacy": {
"consistencyWarning": "フィンガープリント整合性の警告",
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
"clearTraffic": "すべてのトラフィック履歴を消去",
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
"clearTrafficSuccess": "トラフィック履歴を消去しました"
}
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
@@ -245,7 +252,13 @@
"cantModifyRunning": "実行中のプロファイルは変更できません",
"cantModifyLaunching": "起動中はプロファイルを変更できません",
"cantModifyStopping": "停止中はプロファイルを変更できません",
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません"
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません",
"emptyTitle": "プロファイルはまだありません",
"emptyHint": "最初のプロファイルを作成するか、他のブラウザから既存のプロファイルをインポートしてください。",
"emptyCreate": "プロファイルを作成",
"emptyImport": "プロファイルをインポート",
"emptyFilteredTitle": "プロファイルが見つかりません",
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。"
},
"actions": {
"launch": "起動",
@@ -1294,7 +1307,28 @@
"domains": "ドメイン",
"fresh": "最新",
"stale": "期限切れ",
"notCached": "キャッシュなし"
"notCached": "キャッシュなし",
"tabBlocklists": "ブロックリスト",
"tabCustom": "カスタムリスト",
"custom.description": "ソース URL と手動ルールから独自のブロックリストを作成します。許可ドメインは常にブロックより優先されます。",
"custom.sourcesLabel": "ブロックリストのソース URL",
"custom.sourcesPlaceholder": "1 行につき 1 つの URL",
"custom.blockLabel": "ブロックするドメイン",
"custom.blockPlaceholder": "1 行につき 1 つのドメイン",
"custom.allowLabel": "許可するドメイン",
"custom.allowPlaceholder": "1 行につき 1 つのドメイン",
"custom.allowHint": "許可ドメインはコンパイル済みブロックリストから除外され、あらゆるブロックルールより優先されます。",
"custom.saved": "カスタム DNS ルールを保存しました",
"custom.imported": "カスタム DNS ルールをインポートしました",
"custom.exported": "カスタム DNS ルールをエクスポートしました",
"custom.exportTxt": "TXT をエクスポート",
"custom.exportJson": "JSON をエクスポート",
"customLevel": "カスタム",
"custom.allowlistModeLabel": "許可リストモード",
"custom.allowlistModeOn": "下記のドメインのみアクセス可能で、それ以外はすべてブロックされます。",
"custom.allowlistModeOff": "リストのドメインをブロックし、それ以外はすべて許可します。",
"custom.allowedOnlyLabel": "許可するドメイン(これらのみ)",
"custom.allowedOnlyHint": "リスト内ドメインのサブドメインも許可されます。リストが空の場合はフィルタリングが無効になります。"
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "インポート済み",
"statusSkipped": "スキップ",
"statusFailed": "失敗",
"importButtonCount": "インポート ({{count}})"
"importButtonCount": "インポート ({{count}})",
"vpnOptional": "VPN(任意)",
"noVpn": "VPNなし",
"advancedOptions": "詳細オプション",
"configureFingerprint": "フィンガープリントを設定(任意)"
},
"syncTooltips": {
"syncing": "同期中...",
@@ -1618,7 +1656,14 @@
"sentLegend": "送信",
"receivedLegend": "受信",
"tooltipSent": "↑ 送信: ",
"tooltipReceived": "↓ 受信: "
"tooltipReceived": "↓ 受信: ",
"clearHistory": "履歴を消去",
"clearHistoryTitle": "トラフィック履歴を消去",
"clearHistoryDescription": "「{{name}}」の記録されたトラフィック履歴をすべて完全かつ安全に消去します。この操作は元に戻せません。",
"tabOverview": "概要",
"tabTopDomains": "上位ドメイン",
"searchDomains": "ドメインを検索…",
"noDomainMatch": "検索に一致するドメインはありません。"
},
"proxyCheck": {
"unknownLocation": "不明",
@@ -1818,7 +1863,14 @@
"importNoItems": "インポートする項目が選択されていません",
"browserNotDownloaded": "{{browser}}のダウンロード済みバージョンがありません。先にダウンロードしてから、再度インポートしてください。",
"archiveExtractionFailed": "アーカイブの展開に失敗しました:{{detail}}",
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。"
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。",
"clearOnCloseUnavailable": "終了時消去は、一時プロファイルやパスワード保護されたプロファイルでは利用できません。",
"proxyAndVpnMutuallyExclusive": "プロファイルにはプロキシまたは VPN のいずれかを設定できます(両方は設定できません)。",
"invalidDnsRulesJson": "選択したファイルは有効な DNS ルール JSON ではありません。",
"unsupportedDnsRulesFormat": "サポートされていないルール形式: {{format}}",
"dnsRulesSaveFailed": "DNS ルールを保存できませんでした。",
"dnsRulesExportFailed": "DNS ルールをエクスポートできませんでした。",
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -1831,7 +1883,9 @@
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"keyboardShortcuts": "キーボードショートカット",
"keyboardShortcutsHint": "すべてのショートカットを表示"
"keyboardShortcutsHint": "すべてのショートカットを表示",
"about": "Donut Browser について",
"aboutHint": "バージョンとアプリ情報"
},
"network": "ネットワーク",
"integrations": "連携",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "{{name}} を起動",
"stopProfile": "{{name}} を停止",
"profileInfo": "情報 — {{name}}"
"profileInfo": "情報 — {{name}}",
"createProfile": "プロファイルを作成",
"about": "Donut Browser について"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "他に Wayfern プロファイルが選択されていません"
},
"about": {
"title": "このアプリについて",
"version": "バージョン {{version}}",
"portableBadge": "ポータブル",
"licenseNotice": "AGPL-3.0 ライセンスのオープンソース・アンチディテクトブラウザです。",
"website": "ウェブサイト"
},
"clearOnClose": {
"label": "終了時にデータを消去",
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
},
"consistencyWarning": {
"title": "フィンガープリントの不一致",
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
"timezoneTitle": "タイムゾーンの不一致",
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
"languageTitle": "言語の不一致",
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
"dontWarnAgain": "このプロファイルでは今後警告しない",
"matchToProxy": "フィンガープリントをプロキシに合わせる",
"matching": "調整中…",
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다."
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
"privacy": {
"consistencyWarning": "핑거프린트 일관성 경고",
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
"clearTraffic": "모든 트래픽 기록 지우기",
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
}
},
"header": {
"searchPlaceholder": "프로필 검색...",
@@ -245,7 +252,13 @@
"cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다",
"cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다",
"cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다",
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다"
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다",
"emptyTitle": "아직 프로필이 없습니다",
"emptyHint": "첫 프로필을 만들거나 다른 브라우저에서 기존 프로필을 가져오세요.",
"emptyCreate": "프로필 생성",
"emptyImport": "프로필 가져오기",
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요."
},
"actions": {
"launch": "실행",
@@ -1294,7 +1307,28 @@
"domains": "도메인",
"fresh": "최신",
"stale": "오래됨",
"notCached": "캐시되지 않음"
"notCached": "캐시되지 않음",
"tabBlocklists": "차단 목록",
"tabCustom": "사용자 지정 목록",
"custom.description": "소스 URL과 수동 규칙으로 나만의 차단 목록을 만드세요. 허용 도메인은 항상 차단 도메인보다 우선합니다.",
"custom.sourcesLabel": "차단 목록 소스 URL",
"custom.sourcesPlaceholder": "한 줄에 URL 하나",
"custom.blockLabel": "차단할 도메인",
"custom.blockPlaceholder": "한 줄에 도메인 하나",
"custom.allowLabel": "허용할 도메인",
"custom.allowPlaceholder": "한 줄에 도메인 하나",
"custom.allowHint": "허용 도메인은 컴파일된 차단 목록에서 제거되어 모든 차단 규칙보다 우선합니다.",
"custom.saved": "사용자 지정 DNS 규칙이 저장되었습니다",
"custom.imported": "사용자 지정 DNS 규칙을 가져왔습니다",
"custom.exported": "사용자 지정 DNS 규칙을 내보냈습니다",
"custom.exportTxt": "TXT 내보내기",
"custom.exportJson": "JSON 내보내기",
"customLevel": "사용자 지정",
"custom.allowlistModeLabel": "허용 목록 모드",
"custom.allowlistModeOn": "아래 도메인만 접근할 수 있으며, 그 외에는 모두 차단됩니다.",
"custom.allowlistModeOff": "목록의 도메인을 차단하고, 그 외에는 모두 허용합니다.",
"custom.allowedOnlyLabel": "허용 도메인 (이것만)",
"custom.allowedOnlyHint": "목록에 있는 도메인의 하위 도메인도 허용됩니다. 목록이 비어 있으면 필터링이 비활성화됩니다."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "가져옴",
"statusSkipped": "건너뜀",
"statusFailed": "실패",
"importButtonCount": "가져오기 ({{count}})"
"importButtonCount": "가져오기 ({{count}})",
"vpnOptional": "VPN (선택 사항)",
"noVpn": "VPN 없음",
"advancedOptions": "고급 옵션",
"configureFingerprint": "핑거프린트 구성 (선택 사항)"
},
"syncTooltips": {
"syncing": "동기화 중...",
@@ -1618,7 +1656,14 @@
"sentLegend": "보냄",
"receivedLegend": "받음",
"tooltipSent": "↑ 보냄: ",
"tooltipReceived": "↓ 받음: "
"tooltipReceived": "↓ 받음: ",
"clearHistory": "기록 지우기",
"clearHistoryTitle": "트래픽 기록 지우기",
"clearHistoryDescription": "\"{{name}}\"의 기록된 모든 트래픽 기록을 영구적이고 안전하게 지웁니다. 이 작업은 되돌릴 수 없습니다.",
"tabOverview": "개요",
"tabTopDomains": "상위 도메인",
"searchDomains": "도메인 검색…",
"noDomainMatch": "검색과 일치하는 도메인이 없습니다."
},
"proxyCheck": {
"unknownLocation": "알 수 없음",
@@ -1818,7 +1863,14 @@
"importNoItems": "가져올 항목이 선택되지 않았습니다",
"browserNotDownloaded": "{{browser}}의 다운로드된 버전이 없습니다. 먼저 다운로드한 후 가져오기를 다시 시도하세요.",
"archiveExtractionFailed": "아카이브 추출에 실패했습니다: {{detail}}",
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다."
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다.",
"clearOnCloseUnavailable": "닫을 때 지우기는 임시 프로필이나 비밀번호로 보호된 프로필에서는 사용할 수 없습니다.",
"proxyAndVpnMutuallyExclusive": "프로필에는 프록시 또는 VPN 중 하나만 사용할 수 있습니다.",
"invalidDnsRulesJson": "선택한 파일이 올바른 DNS 규칙 JSON이 아닙니다.",
"unsupportedDnsRulesFormat": "지원되지 않는 규칙 형식: {{format}}",
"dnsRulesSaveFailed": "DNS 규칙을 저장하지 못했습니다.",
"dnsRulesExportFailed": "DNS 규칙을 내보내지 못했습니다.",
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다."
},
"rail": {
"profiles": "프로필",
@@ -1831,7 +1883,9 @@
"importProfile": "프로필 가져오기",
"importProfileHint": "다른 도구에서 프로필 가져오기",
"keyboardShortcuts": "키보드 단축키",
"keyboardShortcutsHint": "모든 단축키 보기"
"keyboardShortcutsHint": "모든 단축키 보기",
"about": "Donut Browser 정보",
"aboutHint": "버전 및 앱 정보"
},
"network": "네트워크",
"integrations": "통합",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "{{name}} 실행",
"stopProfile": "{{name}} 중지",
"profileInfo": "정보 — {{name}}"
"profileInfo": "정보 — {{name}}",
"createProfile": "프로필 생성",
"about": "Donut Browser 정보"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "선택된 다른 Wayfern 프로필이 없습니다"
},
"about": {
"title": "정보",
"version": "버전 {{version}}",
"portableBadge": "포터블",
"licenseNotice": "AGPL-3.0 라이선스의 오픈 소스 안티 디텍트 브라우저입니다.",
"website": "웹사이트"
},
"clearOnClose": {
"label": "닫을 때 데이터 지우기",
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
},
"consistencyWarning": {
"title": "핑거프린트 불일치",
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
"timezoneTitle": "시간대 불일치",
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
"languageTitle": "언어 불일치",
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
"matchToProxy": "지문을 프록시에 맞추기",
"matching": "맞추는 중…",
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso."
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
"privacy": {
"consistencyWarning": "Aviso de consistência de impressão digital",
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
"clearTraffic": "Limpar todo o histórico de tráfego",
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
"clearTrafficSuccess": "Histórico de tráfego limpo"
}
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
@@ -245,7 +252,13 @@
"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"
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado",
"emptyTitle": "Nenhum perfil ainda",
"emptyHint": "Crie seu primeiro perfil ou importe perfis existentes de outro navegador.",
"emptyCreate": "Criar perfil",
"emptyImport": "Importar perfis",
"emptyFilteredTitle": "Nenhum perfil encontrado",
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo."
},
"actions": {
"launch": "Iniciar",
@@ -1294,7 +1307,28 @@
"domains": "domínios",
"fresh": "Atualizado",
"stale": "Desatualizado",
"notCached": "Sem cache"
"notCached": "Sem cache",
"tabBlocklists": "Listas de bloqueio",
"tabCustom": "Listas personalizadas",
"custom.description": "Monte sua própria lista de bloqueio a partir de URLs de origem e regras manuais. Domínios permitidos sempre têm prioridade sobre os bloqueados.",
"custom.sourcesLabel": "URLs de origem de listas de bloqueio",
"custom.sourcesPlaceholder": "Uma URL por linha",
"custom.blockLabel": "Domínios bloqueados",
"custom.blockPlaceholder": "Um domínio por linha",
"custom.allowLabel": "Domínios permitidos",
"custom.allowPlaceholder": "Um domínio por linha",
"custom.allowHint": "Os domínios permitidos são removidos da lista de bloqueio compilada, anulando qualquer regra de bloqueio.",
"custom.saved": "Regras DNS personalizadas salvas",
"custom.imported": "Regras DNS personalizadas importadas",
"custom.exported": "Regras DNS personalizadas exportadas",
"custom.exportTxt": "Exportar TXT",
"custom.exportJson": "Exportar JSON",
"customLevel": "Personalizada",
"custom.allowlistModeLabel": "Modo lista de permitidos",
"custom.allowlistModeOn": "Apenas os domínios abaixo são acessíveis; todo o resto é bloqueado.",
"custom.allowlistModeOff": "Bloquear os domínios listados; permitir todo o resto.",
"custom.allowedOnlyLabel": "Domínios permitidos (apenas estes)",
"custom.allowedOnlyHint": "Subdomínios de um domínio listado também são permitidos. Uma lista vazia desativa a filtragem."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Importado",
"statusSkipped": "Ignorado",
"statusFailed": "Falhou",
"importButtonCount": "Importar ({{count}})"
"importButtonCount": "Importar ({{count}})",
"vpnOptional": "VPN (opcional)",
"noVpn": "Sem VPN",
"advancedOptions": "Opções avançadas",
"configureFingerprint": "Configurar impressão digital (opcional)"
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Enviado",
"receivedLegend": "Recebido",
"tooltipSent": "↑ Enviado: ",
"tooltipReceived": "↓ Recebido: "
"tooltipReceived": "↓ Recebido: ",
"clearHistory": "Limpar histórico",
"clearHistoryTitle": "Limpar histórico de tráfego",
"clearHistoryDescription": "Apaga de forma permanente e segura todo o histórico de tráfego registrado de \"{{name}}\". Isso não pode ser desfeito.",
"tabOverview": "Visão geral",
"tabTopDomains": "Principais domínios",
"searchDomains": "Pesquisar domínios…",
"noDomainMatch": "Nenhum domínio corresponde à sua pesquisa."
},
"proxyCheck": {
"unknownLocation": "Desconhecido",
@@ -1818,7 +1863,14 @@
"importNoItems": "Nada selecionado para importar",
"browserNotDownloaded": "Nenhuma versão baixada de {{browser}} está disponível. Baixe-a primeiro e tente importar novamente.",
"archiveExtractionFailed": "Falha ao extrair o arquivo: {{detail}}",
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados."
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados.",
"clearOnCloseUnavailable": "A limpeza ao fechar não está disponível para perfis efêmeros ou protegidos por senha.",
"proxyAndVpnMutuallyExclusive": "Um perfil pode usar um proxy ou uma VPN, mas não ambos.",
"invalidDnsRulesJson": "O arquivo selecionado não é um JSON de regras DNS válido.",
"unsupportedDnsRulesFormat": "Formato de regras não suportado: {{format}}",
"dnsRulesSaveFailed": "Falha ao salvar as regras DNS.",
"dnsRulesExportFailed": "Falha ao exportar as regras DNS.",
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy."
},
"rail": {
"profiles": "Perfis",
@@ -1831,7 +1883,9 @@
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"keyboardShortcuts": "Atalhos de teclado",
"keyboardShortcutsHint": "Ver todos os atalhos"
"keyboardShortcutsHint": "Ver todos os atalhos",
"about": "Sobre o Donut Browser",
"aboutHint": "Versão e informações do aplicativo"
},
"network": "Rede",
"integrations": "Integrações",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Iniciar {{name}}",
"stopProfile": "Parar {{name}}",
"profileInfo": "Informações — {{name}}"
"profileInfo": "Informações — {{name}}",
"createProfile": "Criar perfil",
"about": "Sobre o Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "Nenhum outro perfil Wayfern selecionado"
},
"about": {
"title": "Sobre",
"version": "Versão {{version}}",
"portableBadge": "portátil",
"licenseNotice": "Navegador anti-detecção de código aberto, licenciado sob AGPL-3.0.",
"website": "Site"
},
"clearOnClose": {
"label": "Limpar dados ao fechar",
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
},
"consistencyWarning": {
"title": "Divergência de impressão digital",
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
"timezoneTitle": "Divergência de fuso horário",
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
"languageTitle": "Divergência de idioma",
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
"dontWarnAgain": "Não avisar novamente para este perfil",
"matchToProxy": "Ajustar impressão ao proxy",
"matching": "Ajustando…",
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной."
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
"privacy": {
"consistencyWarning": "Предупреждение о согласованности отпечатка",
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
"clearTraffic": "Очистить всю историю трафика",
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
"clearTrafficSuccess": "История трафика очищена"
}
},
"header": {
"searchPlaceholder": "Поиск профилей...",
@@ -245,7 +252,13 @@
"cantModifyRunning": "Нельзя изменить запущенный профиль",
"cantModifyLaunching": "Нельзя изменить профиль во время запуска",
"cantModifyStopping": "Нельзя изменить профиль во время остановки",
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера"
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера",
"emptyTitle": "Профилей пока нет",
"emptyHint": "Создайте свой первый профиль или импортируйте существующие из другого браузера.",
"emptyCreate": "Создать профиль",
"emptyImport": "Импортировать профили",
"emptyFilteredTitle": "Профили не найдены",
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль."
},
"actions": {
"launch": "Запустить",
@@ -1294,7 +1307,28 @@
"domains": "доменов",
"fresh": "Актуальный",
"stale": "Устаревший",
"notCached": "Не кэшировано"
"notCached": "Не кэшировано",
"tabBlocklists": "Списки блокировки",
"tabCustom": "Пользовательские списки",
"custom.description": "Составьте собственный список блокировки из URL-источников и ручных правил. Разрешённые домены всегда имеют приоритет над заблокированными.",
"custom.sourcesLabel": "URL-источники списков блокировки",
"custom.sourcesPlaceholder": "Один URL на строку",
"custom.blockLabel": "Заблокированные домены",
"custom.blockPlaceholder": "Один домен на строку",
"custom.allowLabel": "Разрешённые домены",
"custom.allowPlaceholder": "Один домен на строку",
"custom.allowHint": "Разрешённые домены исключаются из итогового списка блокировки и имеют приоритет над любым правилом блокировки.",
"custom.saved": "Пользовательские правила DNS сохранены",
"custom.imported": "Пользовательские правила DNS импортированы",
"custom.exported": "Пользовательские правила DNS экспортированы",
"custom.exportTxt": "Экспорт TXT",
"custom.exportJson": "Экспорт JSON",
"customLevel": "Пользовательский",
"custom.allowlistModeLabel": "Режим разрешённого списка",
"custom.allowlistModeOn": "Доступны только домены ниже; всё остальное блокируется.",
"custom.allowlistModeOff": "Блокировать домены из списка; всё остальное разрешено.",
"custom.allowedOnlyLabel": "Разрешённые домены (только эти)",
"custom.allowedOnlyHint": "Поддомены указанных доменов также разрешены. Пустой список отключает фильтрацию."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Импортирован",
"statusSkipped": "Пропущен",
"statusFailed": "Ошибка",
"importButtonCount": "Импортировать ({{count}})"
"importButtonCount": "Импортировать ({{count}})",
"vpnOptional": "VPN (необязательно)",
"noVpn": "Без VPN",
"advancedOptions": "Дополнительные параметры",
"configureFingerprint": "Настроить отпечаток (необязательно)"
},
"syncTooltips": {
"syncing": "Синхронизация...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Отправлено",
"receivedLegend": "Получено",
"tooltipSent": "↑ Отправлено: ",
"tooltipReceived": "↓ Получено: "
"tooltipReceived": "↓ Получено: ",
"clearHistory": "Очистить историю",
"clearHistoryTitle": "Очистить историю трафика",
"clearHistoryDescription": "Навсегда и безопасно удаляет всю записанную историю трафика для «{{name}}». Это действие нельзя отменить.",
"tabOverview": "Обзор",
"tabTopDomains": "Топ доменов",
"searchDomains": "Поиск доменов…",
"noDomainMatch": "Нет доменов, соответствующих запросу."
},
"proxyCheck": {
"unknownLocation": "Неизвестно",
@@ -1818,7 +1863,14 @@
"importNoItems": "Ничего не выбрано для импорта",
"browserNotDownloaded": "Нет загруженной версии {{browser}}. Сначала загрузите её, затем повторите импорт.",
"archiveExtractionFailed": "Не удалось распаковать архив: {{detail}}",
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы."
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы.",
"clearOnCloseUnavailable": "Очистка при закрытии недоступна для эфемерных и защищённых паролем профилей.",
"proxyAndVpnMutuallyExclusive": "Профиль может использовать либо прокси, либо VPN, но не оба сразу.",
"invalidDnsRulesJson": "Выбранный файл не является корректным JSON с правилами DNS.",
"unsupportedDnsRulesFormat": "Неподдерживаемый формат правил: {{format}}",
"dnsRulesSaveFailed": "Не удалось сохранить правила DNS.",
"dnsRulesExportFailed": "Не удалось экспортировать правила DNS.",
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси."
},
"rail": {
"profiles": "Профили",
@@ -1831,7 +1883,9 @@
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"keyboardShortcuts": "Сочетания клавиш",
"keyboardShortcutsHint": "Показать все сочетания"
"keyboardShortcutsHint": "Показать все сочетания",
"about": "О Donut Browser",
"aboutHint": "Версия и сведения о приложении"
},
"network": "Сеть",
"integrations": "Интеграции",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Запустить {{name}}",
"stopProfile": "Остановить {{name}}",
"profileInfo": "Информация — {{name}}"
"profileInfo": "Информация — {{name}}",
"createProfile": "Создать профиль",
"about": "О Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "Других выбранных Wayfern профилей нет"
},
"about": {
"title": "О приложении",
"version": "Версия {{version}}",
"portableBadge": "портативная",
"licenseNotice": "Антидетект-браузер с открытым исходным кодом, распространяется по лицензии AGPL-3.0.",
"website": "Веб-сайт"
},
"clearOnClose": {
"label": "Очищать данные при закрытии",
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
},
"consistencyWarning": {
"title": "Несовпадение отпечатка",
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
"timezoneTitle": "Несовпадение часового пояса",
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
"languageTitle": "Несовпадение языка",
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
"dontWarnAgain": "Больше не предупреждать для этого профиля",
"matchToProxy": "Подогнать отпечаток под прокси",
"matching": "Подгонка…",
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
}
}
+118 -22
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Uygulama Otomatik Güncellemelerini Devre Dışı Bırak",
"disableAutoUpdatesDescription": "Uygulamanın Donut Browser güncellemelerini otomatik olarak denetlemesini ve yüklemesini engelleyin. Tarayıcı güncellemeleri bundan etkilenmez.",
"keepDecryptedProfilesInRam": "Şifresi Çözülmüş Profilleri RAM'de Tut",
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır."
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır.",
"privacy": {
"consistencyWarning": "Parmak izi tutarlılık uyarısı",
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde başlatma sırasında uyar.",
"clearTraffic": "Tüm trafik geçmişini temizle",
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
"clearTrafficSuccess": "Trafik geçmişi temizlendi"
}
},
"header": {
"searchPlaceholder": "Profillerde ara...",
@@ -245,7 +252,13 @@
"cantModifyRunning": "Çalışan profil değiştirilemez",
"cantModifyLaunching": "Başlatılırken profil değiştirilemez",
"cantModifyStopping": "Durdurulurken profil değiştirilemez",
"cantModifyUpdating": "Tarayıcı güncellenirken profil değiştirilemez"
"cantModifyUpdating": "Tarayıcı güncellenirken profil değiştirilemez",
"emptyTitle": "Henüz profil yok",
"emptyHint": "İlk profilinizi oluşturun veya başka bir tarayıcıdan mevcut profilleri içe aktarın.",
"emptyCreate": "Profil oluştur",
"emptyImport": "Profilleri içe aktar",
"emptyFilteredTitle": "Profil bulunamadı",
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun."
},
"actions": {
"launch": "Başlat",
@@ -1294,7 +1307,28 @@
"domains": "alan adı",
"fresh": "Güncel",
"stale": "Eski",
"notCached": "Önbelleğe alınmadı"
"notCached": "Önbelleğe alınmadı",
"tabBlocklists": "Engel listeleri",
"tabCustom": "Özel listeler",
"custom.description": "Kaynak URL'lerden ve manuel kurallardan kendi engel listenizi oluşturun. İzin verilen alan adları her zaman engellenenlerin önüne geçer.",
"custom.sourcesLabel": "Engel listesi kaynak URL'leri",
"custom.sourcesPlaceholder": "Her satıra bir URL",
"custom.blockLabel": "Engellenen alan adları",
"custom.blockPlaceholder": "Her satıra bir alan adı",
"custom.allowLabel": "İzin verilen alan adları",
"custom.allowPlaceholder": "Her satıra bir alan adı",
"custom.allowHint": "İzin verilen alan adları derlenen engel listesinden çıkarılır ve tüm engelleme kurallarını geçersiz kılar.",
"custom.saved": "Özel DNS kuralları kaydedildi",
"custom.imported": "Özel DNS kuralları içe aktarıldı",
"custom.exported": "Özel DNS kuralları dışa aktarıldı",
"custom.exportTxt": "TXT olarak dışa aktar",
"custom.exportJson": "JSON olarak dışa aktar",
"customLevel": "Özel",
"custom.allowlistModeLabel": "İzin listesi modu",
"custom.allowlistModeOn": "Yalnızca aşağıdaki alan adlarına erişilebilir; diğer her şey engellenir.",
"custom.allowlistModeOff": "Listelenen alan adlarını engelle; diğer her şeye izin ver.",
"custom.allowedOnlyLabel": "İzin verilen alan adları (yalnızca bunlar)",
"custom.allowedOnlyHint": "Listelenen bir alan adının alt alan adlarına da izin verilir. Boş liste filtrelemeyi devre dışı bırakır."
},
"vpns": {
"form": {
@@ -1378,16 +1412,9 @@
"scanning": "Tarayıcı profilleri taranıyor...",
"noneFound": "Sisteminizde tarayıcı profili bulunamadı.",
"noneFoundHint": "Profilleriniz özel konumlardaysa elle içe aktarma seçeneğini deneyin.",
"selectProfile": "Profil Seçin:",
"selectProfilePlaceholder": "Algılanan bir profil seçin",
"pathLabel": "Yol:",
"browserLabel": "Tarayıcı:",
"newProfileName": "Yeni Profil Adı:",
"newProfileNamePlaceholder": "İçe aktarılan profil için bir ad girin",
"manualTitle": "Elle Profil İçe Aktarma",
"browserType": "Tarayıcı Türü:",
"loadingBrowsers": "Tarayıcılar yükleniyor...",
"selectBrowserType": "Tarayıcı türü seçin",
"profileFolderPath": "Profil Klasörü Yolu:",
"profileFolderPlaceholder": "Profil klasörünün tam yolunu girin",
"browseFolderTitle": "Klasöre göz at",
@@ -1395,17 +1422,38 @@
"selectFolderTitle": "Tarayıcı Profil Klasörünü Seçin",
"folderDialogFailed": "Klasör iletişim kutusu açılamadı",
"detectFailed": "Mevcut tarayıcı profilleri algılanamadı",
"fillFields": "Lütfen tüm alanları doldurun",
"selectAndName": "Lütfen bir profil seçin ve bir ad girin",
"profileNotFound": "Seçilen profil bulunamadı",
"importedSuccess": "\"{{name}}\" profili başarıyla içe aktarıldı",
"notInstalled": "{{browser}} yüklü değil. Lütfen önce ana pencereden {{browser}} indirin, ardından içe aktarmayı yeniden deneyin.",
"importFailed": "Profil içe aktarılamadı: {{error}}",
"proxyOptional": "Proxy (İsteğe Bağlı)",
"noProxy": "Proxy yok",
"nextButton": "İleri",
"importButton": "İçe Aktar",
"importedAs": "Bu profil bir {{browser}} profili olarak içe aktarılacak."
"importedAs": "Bu profil bir {{browser}} profili olarak içe aktarılacak.",
"selectAll": "Tümünü seç",
"selectedCount": "{{count}} seçildi",
"scanButton": "Tara",
"manualHint": "Bir profil klasörü, tarayıcı kullanıcı verisi klasörü, dışa aktarılmış profiller içeren bir klasör veya bir ZIP arşivi seçin.",
"selectArchiveTitle": "ZIP arşivi seç",
"noProfilesInLocation": "Bu konumda profil bulunamadı.",
"selectAtLeastOne": "İçe aktarmak için en az bir profil seçin",
"emptyNames": "Seçilen her profilin bir adı olmalı",
"profilesToImport": "İçe aktarılacak profiller",
"groupOptional": "Grup (İsteğe bağlı)",
"noGroup": "Grup yok",
"createNewGroup": "Yeni grup oluştur…",
"newGroupNamePlaceholder": "Yeni grup adı",
"duplicateStrategyLabel": "Profil adı zaten varsa",
"duplicateRename": "Otomatik olarak yeniden adlandır",
"duplicateSkip": "Atla",
"proxyRoundRobin": "Kayıtlı proxy'leri dağıt (sırayla)",
"importingTitle": "Profiller içe aktarılıyor…",
"importProgress": "{{total}} öğeden {{completed}} tanesi işlendi",
"resultsSummary": "{{imported}} içe aktarıldı, {{skipped}} atlandı, {{failed}} başarısız",
"statusImported": "İçe aktarıldı",
"statusSkipped": "Atlandı",
"statusFailed": "Başarısız",
"importButtonCount": "İçe aktar ({{count}})",
"vpnOptional": "VPN (isteğe bağlı)",
"noVpn": "VPN yok",
"advancedOptions": "Gelişmiş seçenekler",
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)"
},
"syncTooltips": {
"syncing": "Eşitleniyor...",
@@ -1608,7 +1656,14 @@
"sentLegend": "Gönderilen",
"receivedLegend": "Alınan",
"tooltipSent": "↑ Gönderilen: ",
"tooltipReceived": "↓ Alınan: "
"tooltipReceived": "↓ Alınan: ",
"clearHistory": "Geçmişi temizle",
"clearHistoryTitle": "Trafik geçmişini temizle",
"clearHistoryDescription": "\"{{name}}\" için kayıtlı tüm trafik geçmişini kalıcı ve güvenli bir şekilde siler. Bu işlem geri alınamaz.",
"tabOverview": "Genel bakış",
"tabTopDomains": "En çok kullanılan alan adları",
"searchDomains": "Alan adı ara…",
"noDomainMatch": "Aramanızla eşleşen alan adı yok."
},
"proxyCheck": {
"unknownLocation": "Bilinmiyor",
@@ -1802,7 +1857,20 @@
"updateChecksumsUnavailable": "{{version}} güncellemesi doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. Güncelleme yüklenmedi; daha sonra yeniden denenecek.",
"updateChecksumMismatch": "İndirilen güncelleme dosyası {{file}} sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
"nameCannotBeEmpty": "Ad boş olamaz",
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}."
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}.",
"profileNameExists": "\"{{name}}\" adlı bir profil zaten var",
"importSourceNotFound": "Kaynak yol mevcut değil",
"importNoItems": "İçe aktarmak için hiçbir şey seçilmedi",
"browserNotDownloaded": "{{browser}} tarayıcısının indirilmiş bir sürümü yok. Önce indirin, sonra içe aktarmayı yeniden deneyin.",
"archiveExtractionFailed": "Arşiv çıkarılamadı: {{detail}}",
"unsupportedArchiveFormat": "Desteklenmeyen arşiv biçimi. Yalnızca ZIP arşivleri desteklenir.",
"clearOnCloseUnavailable": "Kapatırken temizleme, geçici veya parola korumalı profillerde kullanılamaz.",
"proxyAndVpnMutuallyExclusive": "Bir profil ya proxy ya da VPN kullanabilir, ikisini birden kullanamaz.",
"invalidDnsRulesJson": "Seçilen dosya geçerli bir DNS kuralı JSON'u değil.",
"unsupportedDnsRulesFormat": "Desteklenmeyen kural biçimi: {{format}}",
"dnsRulesSaveFailed": "DNS kuralları kaydedilemedi.",
"dnsRulesExportFailed": "DNS kuralları dışa aktarılamadı.",
"fingerprintMatchFailed": "Parmak izi proxy'ye eşlenemedi."
},
"rail": {
"profiles": "Profiller",
@@ -1815,7 +1883,9 @@
"importProfile": "Profil içe aktar",
"importProfileHint": "Başka bir araçtan profil getirin",
"keyboardShortcuts": "Klavye kısayolları",
"keyboardShortcutsHint": "Tüm kısayolları görüntüle"
"keyboardShortcutsHint": "Tüm kısayolları görüntüle",
"about": "Donut Browser Hakkında",
"aboutHint": "Sürüm ve uygulama bilgileri"
},
"network": "Ağ",
"integrations": "Entegrasyonlar",
@@ -1904,7 +1974,9 @@
"actions": {
"launchProfile": "{{name}} profilini başlat",
"stopProfile": "{{name}} profilini durdur",
"profileInfo": "Bilgi — {{name}}"
"profileInfo": "Bilgi — {{name}}",
"createProfile": "Profil oluştur",
"about": "Donut Browser Hakkında"
}
},
"shortcuts": {
@@ -2020,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "Başka Wayfern profili seçilmedi"
},
"about": {
"title": "Hakkında",
"version": "Sürüm {{version}}",
"portableBadge": "taşınabilir",
"licenseNotice": "AGPL-3.0 lisanslı, açık kaynak anti-detect tarayıcı.",
"website": "Web sitesi"
},
"clearOnClose": {
"label": "Kapatırken verileri temizle",
"description": "Tarayıcı kapanırken çerezleri, geçmişi ve önbelleği siler. Uzantılar ve yer imleri korunur."
},
"consistencyWarning": {
"title": "Parmak izi uyuşmazlığı",
"intro": "\"{{name}}\" için proxy çıkışı bu profilin parmak iziyle eşleşmiyor:",
"timezoneTitle": "Saat dilimi uyuşmazlığı",
"timezoneDetail": "Çıkış düğümü {{exit}} konumunda, ancak parmak izi {{fingerprint}} bildiriyor.",
"languageTitle": "Dil uyuşmazlığı",
"languageDetail": "Çıkış ülkesi {{country}}, ancak parmak izi dili {{fingerprint}}.",
"explainer": "Çıkış IP'nizle uyuşmayan bir saat dilimi veya dil, gerçek cihazınız hiç sızdırmasa bile güçlü bir anti-bot sinyalidir. Şüpheli muameleyi azaltmak için parmak izini proxy konumuyla hizalayın.",
"dontWarnAgain": "Bu profil için bir daha uyarma",
"matchToProxy": "Parmak izini proxy'ye eşle",
"matching": "Eşleniyor…",
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "Tắt tự động cập nhật ứng dụng",
"disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.",
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa."
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
"privacy": {
"consistencyWarning": "Cảnh báo nhất quán vân tay",
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng"
}
},
"header": {
"searchPlaceholder": "Tìm kiếm hồ sơ...",
@@ -245,7 +252,13 @@
"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"
"cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật",
"emptyTitle": "Chưa có hồ sơ nào",
"emptyHint": "Tạo hồ sơ đầu tiên của bạn hoặc nhập hồ sơ hiện có từ trình duyệt khác.",
"emptyCreate": "Tạo hồ sơ",
"emptyImport": "Nhập hồ sơ",
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới."
},
"actions": {
"launch": "Khởi chạy",
@@ -1294,7 +1307,28 @@
"domains": "tên miền",
"fresh": "Mới",
"stale": "Cũ",
"notCached": "Chưa lưu bộ nhớ đệm"
"notCached": "Chưa lưu bộ nhớ đệm",
"tabBlocklists": "Danh sách chặn",
"tabCustom": "Danh sách tùy chỉnh",
"custom.description": "Tự tạo danh sách chặn của riêng bạn từ các URL nguồn và quy tắc thủ công. Tên miền được phép luôn được ưu tiên hơn tên miền bị chặn.",
"custom.sourcesLabel": "URL nguồn danh sách chặn",
"custom.sourcesPlaceholder": "Mỗi dòng một URL",
"custom.blockLabel": "Tên miền bị chặn",
"custom.blockPlaceholder": "Mỗi dòng một tên miền",
"custom.allowLabel": "Tên miền được phép",
"custom.allowPlaceholder": "Mỗi dòng một tên miền",
"custom.allowHint": "Tên miền được phép sẽ bị loại khỏi danh sách chặn đã biên dịch, ghi đè mọi quy tắc chặn.",
"custom.saved": "Đã lưu quy tắc DNS tùy chỉnh",
"custom.imported": "Đã nhập quy tắc DNS tùy chỉnh",
"custom.exported": "Đã xuất quy tắc DNS tùy chỉnh",
"custom.exportTxt": "Xuất TXT",
"custom.exportJson": "Xuất JSON",
"customLevel": "Tùy chỉnh",
"custom.allowlistModeLabel": "Chế độ danh sách cho phép",
"custom.allowlistModeOn": "Chỉ các tên miền bên dưới có thể truy cập; mọi thứ khác đều bị chặn.",
"custom.allowlistModeOff": "Chặn các tên miền trong danh sách; cho phép mọi thứ khác.",
"custom.allowedOnlyLabel": "Tên miền được phép (chỉ những tên này)",
"custom.allowedOnlyHint": "Tên miền phụ của tên miền trong danh sách cũng được phép. Danh sách trống sẽ tắt bộ lọc."
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "Đã nhập",
"statusSkipped": "Bỏ qua",
"statusFailed": "Thất bại",
"importButtonCount": "Nhập ({{count}})"
"importButtonCount": "Nhập ({{count}})",
"vpnOptional": "VPN (tùy chọn)",
"noVpn": "Không dùng VPN",
"advancedOptions": "Tùy chọn nâng cao",
"configureFingerprint": "Cấu hình vân tay (tùy chọn)"
},
"syncTooltips": {
"syncing": "Đang đồng bộ...",
@@ -1618,7 +1656,14 @@
"sentLegend": "Đã gửi",
"receivedLegend": "Đã nhận",
"tooltipSent": "↑ Đã gửi: ",
"tooltipReceived": "↓ Đã nhận: "
"tooltipReceived": "↓ Đã nhận: ",
"clearHistory": "Xóa lịch sử",
"clearHistoryTitle": "Xóa lịch sử lưu lượng",
"clearHistoryDescription": "Xóa vĩnh viễn và an toàn toàn bộ lịch sử lưu lượng đã ghi của \"{{name}}\". Hành động này không thể hoàn tác.",
"tabOverview": "Tổng quan",
"tabTopDomains": "Tên miền hàng đầu",
"searchDomains": "Tìm kiếm tên miền…",
"noDomainMatch": "Không có tên miền nào khớp với tìm kiếm."
},
"proxyCheck": {
"unknownLocation": "Không xác định",
@@ -1818,7 +1863,14 @@
"importNoItems": "Chưa chọn mục nào để nhập",
"browserNotDownloaded": "Không có phiên bản {{browser}} nào đã tải xuống. Hãy tải xuống trước, sau đó thử nhập lại.",
"archiveExtractionFailed": "Không thể giải nén tệp: {{detail}}",
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP."
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP.",
"clearOnCloseUnavailable": "Tính năng xóa khi đóng không khả dụng với hồ sơ tạm thời hoặc hồ sơ được bảo vệ bằng mật khẩu.",
"proxyAndVpnMutuallyExclusive": "Một hồ sơ chỉ có thể dùng proxy hoặc VPN, không dùng cả hai.",
"invalidDnsRulesJson": "Tệp đã chọn không phải là JSON quy tắc DNS hợp lệ.",
"unsupportedDnsRulesFormat": "Định dạng quy tắc không được hỗ trợ: {{format}}",
"dnsRulesSaveFailed": "Không thể lưu quy tắc DNS.",
"dnsRulesExportFailed": "Không thể xuất quy tắc DNS.",
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy."
},
"rail": {
"profiles": "Profile",
@@ -1831,7 +1883,9 @@
"importProfile": "Nhập profile",
"importProfileHint": "Đưa profile từ công cụ khác",
"keyboardShortcuts": "Phím tắt",
"keyboardShortcutsHint": "Xem tất cả phím tắt"
"keyboardShortcutsHint": "Xem tất cả phím tắt",
"about": "Giới thiệu về Donut Browser",
"aboutHint": "Phiên bản và thông tin ứng dụng"
},
"network": "Mạng",
"integrations": "Tích hợp",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "Khởi chạy {{name}}",
"stopProfile": "Dừng {{name}}",
"profileInfo": "Thông tin — {{name}}"
"profileInfo": "Thông tin — {{name}}",
"createProfile": "Tạo hồ sơ",
"about": "Giới thiệu về Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "Chưa chọn profile Wayfern nào khác"
},
"about": {
"title": "Giới thiệu",
"version": "Phiên bản {{version}}",
"portableBadge": "bản portable",
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở, được cấp phép theo AGPL-3.0.",
"website": "Trang web"
},
"clearOnClose": {
"label": "Xóa dữ liệu khi đóng",
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
},
"consistencyWarning": {
"title": "Vân tay không khớp",
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
"timezoneTitle": "Múi giờ không khớp",
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
"languageTitle": "Ngôn ngữ không khớp",
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
"matchToProxy": "Khớp vân tay với proxy",
"matching": "Đang khớp…",
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
}
}
+88 -8
View File
@@ -197,7 +197,14 @@
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。"
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
"privacy": {
"consistencyWarning": "指纹一致性警告",
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
"clearTraffic": "清除所有流量历史",
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
"clearTrafficSuccess": "流量历史已清除"
}
},
"header": {
"searchPlaceholder": "搜索配置文件...",
@@ -245,7 +252,13 @@
"cantModifyRunning": "无法修改正在运行的配置文件",
"cantModifyLaunching": "启动期间无法修改配置文件",
"cantModifyStopping": "停止期间无法修改配置文件",
"cantModifyUpdating": "浏览器更新期间无法修改配置文件"
"cantModifyUpdating": "浏览器更新期间无法修改配置文件",
"emptyTitle": "暂无配置文件",
"emptyHint": "创建您的第一个配置文件,或从其他浏览器导入现有配置文件。",
"emptyCreate": "创建配置文件",
"emptyImport": "导入配置文件",
"emptyFilteredTitle": "未找到配置文件",
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。"
},
"actions": {
"launch": "启动",
@@ -1294,7 +1307,28 @@
"domains": "个域名",
"fresh": "最新",
"stale": "过期",
"notCached": "未缓存"
"notCached": "未缓存",
"tabBlocklists": "拦截列表",
"tabCustom": "自定义列表",
"custom.description": "通过源 URL 和手动规则构建您自己的拦截列表。允许的域名始终优先于被拦截的域名。",
"custom.sourcesLabel": "拦截列表源 URL",
"custom.sourcesPlaceholder": "每行一个 URL",
"custom.blockLabel": "拦截的域名",
"custom.blockPlaceholder": "每行一个域名",
"custom.allowLabel": "允许的域名",
"custom.allowPlaceholder": "每行一个域名",
"custom.allowHint": "允许的域名会从编译后的拦截列表中移除,并覆盖任何拦截规则。",
"custom.saved": "自定义 DNS 规则已保存",
"custom.imported": "自定义 DNS 规则已导入",
"custom.exported": "自定义 DNS 规则已导出",
"custom.exportTxt": "导出 TXT",
"custom.exportJson": "导出 JSON",
"customLevel": "自定义",
"custom.allowlistModeLabel": "允许列表模式",
"custom.allowlistModeOn": "仅可访问下方域名,其余全部拦截。",
"custom.allowlistModeOff": "拦截列表中的域名,允许其余所有域名。",
"custom.allowedOnlyLabel": "允许的域名(仅限这些)",
"custom.allowedOnlyHint": "列表中域名的子域名也会被允许。列表为空时将禁用过滤。"
},
"vpns": {
"form": {
@@ -1415,7 +1449,11 @@
"statusImported": "已导入",
"statusSkipped": "已跳过",
"statusFailed": "失败",
"importButtonCount": "导入 ({{count}})"
"importButtonCount": "导入 ({{count}})",
"vpnOptional": "VPN(可选)",
"noVpn": "不使用 VPN",
"advancedOptions": "高级选项",
"configureFingerprint": "配置指纹(可选)"
},
"syncTooltips": {
"syncing": "同步中...",
@@ -1618,7 +1656,14 @@
"sentLegend": "已发送",
"receivedLegend": "已接收",
"tooltipSent": "↑ 已发送: ",
"tooltipReceived": "↓ 已接收: "
"tooltipReceived": "↓ 已接收: ",
"clearHistory": "清除历史",
"clearHistoryTitle": "清除流量历史",
"clearHistoryDescription": "永久且安全地清除「{{name}}」的所有已记录流量历史。此操作无法撤销。",
"tabOverview": "概览",
"tabTopDomains": "热门域名",
"searchDomains": "搜索域名…",
"noDomainMatch": "没有与搜索匹配的域名。"
},
"proxyCheck": {
"unknownLocation": "未知",
@@ -1818,7 +1863,14 @@
"importNoItems": "未选择要导入的内容",
"browserNotDownloaded": "没有已下载的 {{browser}} 版本。请先下载,然后重试导入。",
"archiveExtractionFailed": "解压压缩包失败:{{detail}}",
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。"
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。",
"clearOnCloseUnavailable": "关闭时清除功能不适用于临时配置文件或受密码保护的配置文件。",
"proxyAndVpnMutuallyExclusive": "配置文件只能使用代理或 VPN,不能同时使用两者。",
"invalidDnsRulesJson": "所选文件不是有效的 DNS 规则 JSON。",
"unsupportedDnsRulesFormat": "不支持的规则格式:{{format}}",
"dnsRulesSaveFailed": "保存 DNS 规则失败。",
"dnsRulesExportFailed": "导出 DNS 规则失败。",
"fingerprintMatchFailed": "无法将指纹匹配到代理。"
},
"rail": {
"profiles": "配置文件",
@@ -1831,7 +1883,9 @@
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"keyboardShortcuts": "键盘快捷键",
"keyboardShortcutsHint": "查看所有快捷键"
"keyboardShortcutsHint": "查看所有快捷键",
"about": "关于 Donut Browser",
"aboutHint": "版本和应用信息"
},
"network": "网络",
"integrations": "集成",
@@ -1920,7 +1974,9 @@
"actions": {
"launchProfile": "启动 {{name}}",
"stopProfile": "停止 {{name}}",
"profileInfo": "信息 — {{name}}"
"profileInfo": "信息 — {{name}}",
"createProfile": "创建配置文件",
"about": "关于 Donut Browser"
}
},
"shortcuts": {
@@ -2036,5 +2092,29 @@
},
"cookieCopy": {
"noOtherTargets": "未选择其他 Wayfern 配置文件"
},
"about": {
"title": "关于",
"version": "版本 {{version}}",
"portableBadge": "便携版",
"licenseNotice": "开源反检测浏览器,基于 AGPL-3.0 许可证发布。",
"website": "官网"
},
"clearOnClose": {
"label": "关闭时清除数据",
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
},
"consistencyWarning": {
"title": "指纹不匹配",
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
"timezoneTitle": "时区不匹配",
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
"languageTitle": "语言不匹配",
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
"dontWarnAgain": "不再为此配置文件发出警告",
"matchToProxy": "将指纹匹配到代理",
"matching": "匹配中…",
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
}
}
+23
View File
@@ -44,6 +44,13 @@ export type BackendErrorCode =
| "BROWSER_NOT_DOWNLOADED"
| "ARCHIVE_EXTRACTION_FAILED"
| "UNSUPPORTED_ARCHIVE_FORMAT"
| "CLEAR_ON_CLOSE_UNAVAILABLE"
| "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE"
| "FINGERPRINT_MATCH_FAILED"
| "INVALID_DNS_RULES_JSON"
| "UNSUPPORTED_DNS_RULES_FORMAT"
| "DNS_RULES_SAVE_FAILED"
| "DNS_RULES_EXPORT_FAILED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -181,6 +188,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "UNSUPPORTED_ARCHIVE_FORMAT":
return t("backendErrors.unsupportedArchiveFormat");
case "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE":
return t("backendErrors.proxyAndVpnMutuallyExclusive");
case "FINGERPRINT_MATCH_FAILED":
return t("backendErrors.fingerprintMatchFailed");
case "INVALID_DNS_RULES_JSON":
return t("backendErrors.invalidDnsRulesJson");
case "UNSUPPORTED_DNS_RULES_FORMAT":
return t("backendErrors.unsupportedDnsRulesFormat", {
format: parsed.params?.format ?? "",
});
case "DNS_RULES_SAVE_FAILED":
return t("backendErrors.dnsRulesSaveFailed");
case "DNS_RULES_EXPORT_FAILED":
return t("backendErrors.dnsRulesExportFailed");
case "CLEAR_ON_CLOSE_UNAVAILABLE":
return t("backendErrors.clearOnCloseUnavailable");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+45
View File
@@ -0,0 +1,45 @@
import confetti from "canvas-confetti";
/**
* Donut-sprinkle confetti: small rounded bars tinted with the active theme's
* chart colors. Used for celebration moments (e.g. a successful profile
* import). Callers must skip it under prefers-reduced-motion.
*/
// A 12×6 capsule — reads as a donut sprinkle at small scale.
const SPRINKLE_PATH = "M3 0 h6 a3 3 0 0 1 0 6 h-6 a3 3 0 0 1 0 -6 z";
function themeChartColors(): string[] {
const styles = getComputedStyle(document.documentElement);
const colors = [1, 2, 3, 4, 5]
.map((i) => styles.getPropertyValue(`--chart-${i}`).trim())
.filter(Boolean);
return colors.length > 0 ? colors : ["#888888"];
}
export function fireSprinkleConfetti(): void {
const sprinkle = confetti.shapeFromPath({ path: SPRINKLE_PATH });
const colors = themeChartColors();
const fire = (particleCount: number, opts: confetti.Options = {}) => {
void confetti({
particleCount,
spread: 75,
startVelocity: 42,
scalar: 0.9,
ticks: 130,
shapes: [sprinkle],
colors,
origin: { y: 0.65 },
...opts,
});
};
fire(70);
window.setTimeout(() => {
fire(45, { angle: 60, origin: { x: 0.2, y: 0.7 } });
}, 180);
window.setTimeout(() => {
fire(45, { angle: 120, origin: { x: 0.8, y: 0.7 } });
}, 360);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* The DNS blocklist levels the backend accepts, mirroring
* `BlocklistLevel::as_str` in `src-tauri/src/dns_blocklist.rs`. Ordered from
* least to most restrictive, with `custom` (the user's own sources/rules) last.
*
* Every level picker reads from this list. Keeping it in one place is what
* stops a new level from reaching some surfaces and not others `custom` was
* previously missing from two pickers, so a profile already set to it rendered
* with nothing selected and could not be restored.
*
* Labels are translation keys, never the backend's `display_name`: that field
* is hardcoded English and renders untranslated to every locale.
*/
export const DNS_BLOCKLIST_LEVELS = [
{ value: "light", labelKey: "dnsBlocklist.light" },
{ value: "normal", labelKey: "dnsBlocklist.normal" },
{ value: "pro", labelKey: "dnsBlocklist.pro" },
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
{ value: "custom", labelKey: "dnsBlocklist.customLevel" },
] as const;
export type DnsBlocklistLevel = (typeof DNS_BLOCKLIST_LEVELS)[number]["value"];
/**
* Translation key for a level slug. A null/empty level means no filtering, and
* an unrecognised one (a level added backend-first) falls back to the same,
* which is the honest reading of "we don't know this level".
*/
export function dnsBlocklistLabelKey(level: string | null | undefined): string {
if (!level) {
return "dnsBlocklist.none";
}
return (
DNS_BLOCKLIST_LEVELS.find((l) => l.value === level)?.labelKey ??
"dnsBlocklist.none"
);
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Shared physics for the Donut logo easter eggs: clones an element into a
* fixed-position layer, drops it with gravity, bounces it off the floor and
* right wall, and lets the user grab it (1:1, respecting the grab offset) and
* throw it the sim continues at the pointer's release velocity. Used by the
* rail logo (5-click trigger) and the About dialog flywheel escape.
*/
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const DEFAULT_HORIZONTAL_SPEED = 350;
const DEFAULT_SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
export interface DonutLaunchOptions {
/** Initial horizontal velocity in px/s. Defaults to a rightward roll. */
initialVX?: number;
/** Initial vertical velocity in px/s (negative = upward). */
initialVY?: number;
/** Spin speed in deg/s. */
spinSpeed?: number;
/** Called once the clone has left the screen and been removed. */
onExit?: () => void;
}
/**
* Launch a physics clone of `el`. The source element is hidden (visibility)
* and stays hidden the caller decides when/if to restore it. Returns a
* cancel function that removes the clone and stops the sim.
*/
export function launchDonutClone(
el: HTMLElement,
options: DonutLaunchOptions = {},
): () => void {
// getBoundingClientRect measures the *transformed* box. A caller that rotates
// the element before launching (the About dialog spins it up to escape
// velocity) would otherwise hand us the rotated bounding box: ~37% too large
// at 45°, with an offset origin — so the clone jumps at launch and bounces off
// the floor and walls early. Suppress the transform for the measurement to get
// the layout box, then put it back.
const previousTransform = el.style.transform;
if (previousTransform) {
el.style.transform = "none";
}
const rect = el.getBoundingClientRect();
if (previousTransform) {
el.style.transform = previousTransform;
}
const startX = rect.left;
const startY = rect.top;
const clone = el.cloneNode(true) as HTMLElement;
clone.style.position = "fixed";
clone.style.left = `${startX}px`;
clone.style.top = `${startY}px`;
clone.style.zIndex = "9999";
clone.style.margin = "0";
// The fallen donut is a toy: it can be grabbed 1:1 and thrown, inheriting
// the pointer's release velocity.
clone.style.pointerEvents = "auto";
clone.style.cursor = "grab";
clone.style.touchAction = "none";
document.body.appendChild(clone);
el.style.visibility = "hidden";
let x = 0;
let y = 0;
let vy = options.initialVY ?? -500;
// Roll right first, bounce off the right wall, then escape the left.
let vx = options.initialVX ?? DEFAULT_HORIZONTAL_SPEED;
const spinSpeed = options.spinSpeed ?? DEFAULT_SPIN_SPEED;
let rotation = 0;
let lastTime = performance.now();
let grabbed = false;
let grabDX = 0;
let grabDY = 0;
let animFrame = 0;
let cancelled = false;
// Recent pointer positions (≤100ms) for release-velocity estimation.
let history: { t: number; x: number; y: number }[] = [];
// Progressive resistance past a window edge — follows less the further out.
const rubberband = (overshoot: number, dimension: number, c = 0.55) =>
(overshoot * dimension * c) / (dimension + c * Math.abs(overshoot));
const applyTransform = () => {
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
};
const onPointerDown = (e: PointerEvent) => {
if (grabbed) return;
grabbed = true;
clone.setPointerCapture(e.pointerId);
clone.style.cursor = "grabbing";
// Respect where the donut was grabbed — no snap to center.
grabDX = e.clientX - (startX + x);
grabDY = e.clientY - (startY + y);
vx = 0;
vy = 0;
history = [{ t: performance.now(), x, y }];
};
const onPointerMove = (e: PointerEvent) => {
if (!grabbed) return;
let nx = e.clientX - grabDX - startX;
let ny = e.clientY - grabDY - startY;
const minX = -startX;
const maxX = window.innerWidth - rect.width - startX;
const minY = -startY;
const maxY = window.innerHeight - rect.height - startY;
if (nx > maxX) nx = maxX + rubberband(nx - maxX, rect.width);
if (nx < minX) nx = minX + rubberband(nx - minX, rect.width);
if (ny > maxY) ny = maxY + rubberband(ny - maxY, rect.height);
if (ny < minY) ny = minY + rubberband(ny - minY, rect.height);
x = nx;
y = ny;
const now = performance.now();
history.push({ t: now, x, y });
while (history.length > 1 && now - history[0].t > 100) history.shift();
applyTransform();
};
const onPointerUp = (e: PointerEvent) => {
if (!grabbed) return;
grabbed = false;
clone.style.cursor = "grab";
try {
clone.releasePointerCapture(e.pointerId);
} catch {
// capture already gone
}
// Velocity handoff: the sim continues at the finger's speed so a flick
// throws the donut instead of dropping it.
const now = performance.now();
const oldest = history[0];
const dt = oldest ? (now - oldest.t) / 1000 : 0;
if (oldest && dt > 0.016) {
vx = (x - oldest.x) / dt;
vy = (y - oldest.y) / dt;
}
history = [];
lastTime = now;
};
clone.addEventListener("pointerdown", onPointerDown);
clone.addEventListener("pointermove", onPointerMove);
clone.addEventListener("pointerup", onPointerUp);
clone.addEventListener("pointercancel", onPointerUp);
const animate = (time: number) => {
if (cancelled) return;
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
// Read live so a mid-animation window resize moves the floor/wall.
const floorY = window.innerHeight;
const rightWall = window.innerWidth;
if (!grabbed) {
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += spinSpeed * dt * (vx > 0 ? 1 : -1);
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
vy =
Math.abs(vy) > MIN_BOUNCE_VELOCITY
? -Math.abs(vy) * BOUNCE_DAMPING
: -MIN_BOUNCE_VELOCITY * 3;
// A donut dropped with no sideways speed would hop in place forever —
// nudge it toward its left-edge exit.
if (Math.abs(vx) < 40) {
vx = -DEFAULT_HORIZONTAL_SPEED * 0.5;
}
}
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
// damping), and keep rolling. Left wall has no bounce — the donut
// exits the window off the left edge.
const currentRight = startX + x + rect.width;
if (currentRight >= rightWall && vx > 0) {
x = rightWall - startX - rect.width;
vx = -Math.abs(vx) * 0.9;
}
applyTransform();
}
const offScreenLeft = startX + x + rect.width < -200;
const offScreenBottom = startY + y > floorY + 100;
const offScreenTop = startY + y + rect.height < -200;
if (!grabbed && (offScreenLeft || offScreenBottom || offScreenTop)) {
clone.remove();
options.onExit?.();
return;
}
animFrame = requestAnimationFrame(animate);
};
animFrame = requestAnimationFrame(animate);
return () => {
cancelled = true;
cancelAnimationFrame(animFrame);
clone.remove();
};
}
+3
View File
@@ -98,11 +98,14 @@ export const SHORTCUTS: ShortcutDef[] = [
mod: true,
},
{
// Mod+Shift+A (not Mod+A): plain Mod+A must stay select-all in any
// focused text field or table context.
id: "goAccount",
labelKey: "shortcuts.goAccount",
group: "navigation",
key: "a",
mod: true,
shift: true,
},
{
id: "goSettings",
+24
View File
@@ -760,3 +760,27 @@ export function clearThemeColors(): void {
root.style.removeProperty(key as string);
});
}
/**
* Run a theme mutation inside a View Transition so the whole UI cross-fades
* (~200ms, tuned in globals.css) instead of hard-cutting between palettes.
* Falls back to an instant switch when the API is unavailable or the user
* prefers reduced motion.
*/
export function withThemeTransition(mutate: () => void): void {
if (typeof document === "undefined") {
mutate();
return;
}
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const doc = document as Document & {
startViewTransition?: (callback: () => void) => unknown;
};
if (reduced || typeof doc.startViewTransition !== "function") {
mutate();
return;
}
doc.startViewTransition(mutate);
}
+48
View File
@@ -261,3 +261,51 @@
scroll-behavior: auto;
}
}
/* Theme switches run inside a View Transition (see themes.ts
withThemeTransition) a short whole-page cross-fade instead of an abrupt
palette jump. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 200ms;
}
/* Translucent material for floating chrome (modals, popovers, menus).
Uses theme variables so it adapts to every theme; falls back to the solid
surface when the user prefers reduced transparency. Never stack two
material surfaces on top of each other. */
.surface-material {
background-color: color-mix(in oklab, var(--background) 85%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
backdrop-filter: blur(20px) saturate(1.4);
}
.surface-material-popover {
background-color: color-mix(in oklab, var(--popover) 85%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
backdrop-filter: blur(20px) saturate(1.4);
}
.surface-material-card {
background-color: color-mix(in oklab, var(--card) 85%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
backdrop-filter: blur(20px) saturate(1.4);
}
@media (prefers-reduced-transparency: reduce) {
.surface-material {
background-color: var(--background);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.surface-material-popover {
background-color: var(--popover);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.surface-material-card {
background-color: var(--card);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
+3
View File
@@ -32,6 +32,7 @@ export interface BrowserProfile {
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
ephemeral?: boolean;
clear_on_close?: boolean;
extension_group_id?: string;
proxy_bypass_rules?: string[];
created_by_id?: string;
@@ -201,7 +202,9 @@ export interface ImportProfileItem {
source_path: string;
browser_type?: string;
new_profile_name: string;
/** Mutually exclusive with `vpn_id`; the importer rejects setting both. */
proxy_id?: string | null;
vpn_id?: string | null;
}
export interface ProfileImportItemResult {