feat: full ui refresh

This commit is contained in:
zhom
2026-05-11 23:12:16 +04:00
parent 739b5e2449
commit ed3c209f35
46 changed files with 5956 additions and 1553 deletions
+2
View File
@@ -45,7 +45,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "~2.5.0",
+30
View File
@@ -55,9 +55,15 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tauri-apps/api':
specifier: ~2.11.0
version: 2.11.0
'@tauri-apps/plugin-clipboard-manager':
specifier: ^2.3.2
version: 2.3.2
'@tauri-apps/plugin-deep-link':
specifier: ^2.4.7
version: 2.4.7
@@ -2676,10 +2682,19 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
@@ -2759,6 +2774,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-deep-link@2.4.7':
resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
@@ -8347,8 +8365,16 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.14.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.14.0': {}
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.11.0':
@@ -8398,6 +8424,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.0
'@tauri-apps/cli-win32-x64-msvc': 2.11.0
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-deep-link@2.4.7':
dependencies:
'@tauri-apps/api': 2.11.0
+217 -16
View File
@@ -169,7 +169,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -198,6 +198,27 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -1121,6 +1142,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -1670,7 +1700,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1805,6 +1835,7 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
@@ -1832,6 +1863,12 @@ dependencies = [
"zip 8.6.0",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -2052,9 +2089,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "euclid"
version = "0.22.14"
@@ -2174,6 +2217,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -2498,6 +2547,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -3856,7 +3915,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4033,7 +4092,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -4393,7 +4452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4495,6 +4554,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.13.0",
]
[[package]]
name = "phf"
version = "0.12.1"
@@ -5478,7 +5548,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6146,7 +6216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6612,6 +6682,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
@@ -6852,7 +6937,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -6880,7 +6965,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7382,7 +7467,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7403,7 +7488,18 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
@@ -7464,7 +7560,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7922,6 +8018,76 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.11.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.94"
@@ -8052,7 +8218,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8567,7 +8733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8687,6 +8853,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.3"
@@ -8767,6 +8951,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
+1
View File
@@ -44,6 +44,7 @@ tauri-plugin-single-instance = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
log = "0.4"
env_logger = "0.11"
+3 -1
View File
@@ -41,6 +41,8 @@
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission",
"log:default"
"log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text"
]
}
+11
View File
@@ -108,6 +108,17 @@ pub fn dns_blocklist_dir() -> PathBuf {
cache_dir().join("dns_blocklists")
}
/// Resolve the directory that tauri-plugin-log writes to. Mirrors the
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
use tauri::Manager;
handle
.path()
.app_log_dir()
.unwrap_or_else(|_| std::env::temp_dir())
}
#[cfg(test)]
thread_local! {
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
+58 -17
View File
@@ -83,32 +83,73 @@ impl BrowserRunner {
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
async fn resolve_launch_hook_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
let Some(url) = profile.launch_hook.as_deref() else {
return Ok(None);
fn fire_launch_hook(profile: &BrowserProfile) {
let Some(raw_url) = profile.launch_hook.as_deref() else {
return;
};
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return;
}
let parsed = match url::Url::parse(trimmed) {
Ok(u) => u,
Err(e) => {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): invalid URL: {e}",
profile.name,
profile.id
);
return;
}
};
log::info!(
"Calling launch hook for profile {} (ID: {})",
profile.name,
profile.id
);
if !matches!(parsed.scheme(), "http" | "https") {
log::warn!(
"Skipping launch hook for profile {} (ID: {}): URL must be http or https",
profile.name,
profile.id
);
return;
}
PROXY_MANAGER
.fetch_proxy_from_url(url, Duration::from_millis(500))
.await
let url = parsed.to_string();
let profile_name = profile.name.clone();
let profile_id = profile.id.to_string();
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
log::warn!("Launch hook client build failed for {url}: {e}");
return;
}
};
match client.get(&url).send().await {
Ok(resp) => {
log::info!(
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
}
Err(e) => {
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
}
}
});
}
async fn resolve_launch_proxy(
&self,
profile: &BrowserProfile,
) -> Result<Option<ProxySettings>, String> {
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
return Ok(Some(proxy_settings));
}
Self::fire_launch_hook(profile);
self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
+148 -1
View File
@@ -1,6 +1,6 @@
use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
pub total_count: usize,
}
/// Lightweight cookie metadata for the profile-info dialog. Computed without
/// decrypting any cookie values, so it stays cheap even for multi-MB Chromium
/// cookie stores and never blocks the runtime for noticeable time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieStats {
pub profile_id: String,
pub browser_type: String,
pub total_count: usize,
/// Every domain the profile has cookies for, sorted by cookie count desc.
pub domains: Vec<DomainCount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainCount {
pub domain: String,
pub count: usize,
}
/// Request to copy specific cookies
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieCopyRequest {
@@ -694,6 +712,135 @@ impl CookieManager {
})
}
/// Open the cookie SQLite database read-only without acquiring any lock.
///
/// `immutable=1` tells SQLite the file will not change during the read,
/// which causes it to skip all locking. That lets us read metadata even
/// while the browser holds an exclusive lock on the cookies database —
/// the trade-off is that we may see a slightly stale snapshot, which is
/// acceptable for the badge/preview use cases this powers.
fn open_cookie_db_readonly(db_path: &Path) -> Result<Connection, String> {
let path_str = db_path.to_string_lossy();
if path_str.contains('?') || path_str.contains('#') {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": "profile path contains a reserved URI character" }
})
.to_string(),
);
}
let uri = format!("file:{path_str}?mode=ro&immutable=1");
Connection::open_with_flags(
&uri,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
let code = if e.to_string().to_lowercase().contains("locked") {
"COOKIE_DB_LOCKED"
} else {
"COOKIE_DB_UNAVAILABLE"
};
serde_json::json!({
"code": code,
"params": { "detail": e.to_string() }
})
.to_string()
})
}
/// Public API: read lightweight stats (total count + top 5 domains) for a
/// profile's cookie store. Reads from a snapshot view of the SQLite file
/// without holding a lock, so this works while the browser is running.
pub fn read_stats(profile_id: &str) -> Result<CookieStats, String> {
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profiles = profile_manager.list_profiles().map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
let db_path = Self::get_cookie_db_path(profile, &profiles_dir).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e }
})
.to_string()
})?;
let conn = Self::open_cookie_db_readonly(&db_path)?;
let (count_sql, domain_sql) = match profile.browser.as_str() {
"camoufox" => (
"SELECT COUNT(*) FROM moz_cookies",
"SELECT host, COUNT(*) FROM moz_cookies GROUP BY host ORDER BY COUNT(*) DESC, host ASC",
),
"wayfern" => (
"SELECT COUNT(*) FROM cookies",
"SELECT host_key, COUNT(*) FROM cookies GROUP BY host_key ORDER BY COUNT(*) DESC, host_key ASC",
),
_ => {
return Err(
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": format!("unsupported browser: {}", profile.browser) }
})
.to_string(),
)
}
};
let total_count: usize = conn
.query_row(count_sql, [], |row| row.get::<_, i64>(0))
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})? as usize;
let mut stmt = conn.prepare(domain_sql).map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
let domains: Vec<DomainCount> = stmt
.query_map([], |row| {
Ok(DomainCount {
domain: row.get::<_, String>(0)?,
count: row.get::<_, i64>(1)? as usize,
})
})
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
.map_err(|e| {
serde_json::json!({
"code": "COOKIE_DB_UNAVAILABLE",
"params": { "detail": e.to_string() }
})
.to_string()
})?;
Ok(CookieStats {
profile_id: profile_id.to_string(),
browser_type: profile.browser.clone(),
total_count,
domains,
})
}
/// Public API: Copy cookies between profiles
pub async fn copy_cookies(
app_handle: &AppHandle,
+58 -10
View File
@@ -93,16 +93,16 @@ use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
save_table_sorting_settings, should_show_launch_on_login_prompt,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
};
use sync::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
};
use tag_manager::get_all_tags;
@@ -310,8 +310,21 @@ async fn import_proxies_from_parsed(
}
#[tauri::command]
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
cookie_manager::CookieManager::read_cookies(&profile_id)
async fn read_profile_cookies(
profile_id: String,
) -> Result<cookie_manager::CookieReadResult, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_cookies(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookies: {e}"))?
}
#[tauri::command]
async fn get_profile_cookie_stats(
profile_id: String,
) -> Result<cookie_manager::CookieStats, String> {
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_stats(&profile_id))
.await
.map_err(|e| format!("Failed to read profile cookie stats: {e}"))?
}
#[tauri::command]
@@ -753,6 +766,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
}
#[tauri::command]
async fn get_profile_traffic_snapshot(
profile_id: String,
) -> Result<Option<crate::traffic_stats::TrafficSnapshot>, String> {
Ok(crate::traffic_stats::get_traffic_snapshot_for_profile(
&profile_id,
))
}
#[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats()
@@ -1186,7 +1208,11 @@ pub fn run() {
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.max_file_size(100_000) // 100KB
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
@@ -1222,6 +1248,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1244,7 +1271,7 @@ pub fn run() {
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(840.0, 500.0)
.inner_size(880.0, 500.0)
.resizable(false)
.fullscreen(false)
.center()
@@ -1735,7 +1762,23 @@ pub fn run() {
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
for profile in profiles {
// Only walk profiles that either have a stored PID or that we last
// saw as running — for users with hundreds of idle profiles this
// turns an O(N) sysinfo scan into an O(running) scan. The Rust
// launch path always emits profile-running-changed when a profile
// STARTS, so newly-running profiles still get tracked here.
let profiles_to_check: Vec<_> = profiles
.into_iter()
.filter(|p| {
p.process_id.is_some()
|| last_running_states
.get(&p.id.to_string())
.copied()
.unwrap_or(false)
})
.collect();
for profile in profiles_to_check {
// Check browser status and track changes
match runner
.check_browser_status(app_handle_status.clone(), &profile)
@@ -1974,6 +2017,8 @@ pub fn run() {
rename_profile,
get_app_settings,
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
@@ -2041,6 +2086,7 @@ pub fn run() {
stop_api_server,
get_api_server_status,
get_all_traffic_snapshots,
get_profile_traffic_snapshot,
clear_all_traffic_stats,
get_traffic_stats_for_period,
get_sync_settings,
@@ -2060,7 +2106,9 @@ pub fn run() {
set_e2e_password,
check_has_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
get_profile_cookie_stats,
copy_profile_cookies,
import_cookies_from_file,
export_profile_cookies,
+54
View File
@@ -2082,6 +2082,38 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("http or https"));
}
#[test]
fn test_validate_launch_hook_accepts_https_url() {
let result = super::validate_launch_hook(Some("https://example.com/track")).unwrap();
assert_eq!(result.as_deref(), Some("https://example.com/track"));
}
#[test]
fn test_validate_launch_hook_rejects_garbage_with_code() {
let err = super::validate_launch_hook(Some("not a url")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_rejects_non_http_scheme_with_code() {
let err = super::validate_launch_hook(Some("ftp://example.com/hook")).unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
}
#[test]
fn test_validate_launch_hook_empty_clears_hook() {
let result = super::validate_launch_hook(Some("")).unwrap();
assert!(result.is_none());
let result_ws = super::validate_launch_hook(Some(" ")).unwrap();
assert!(result_ws.is_none());
let result_none = super::validate_launch_hook(None).unwrap();
assert!(result_none.is_none());
}
}
#[allow(clippy::too_many_arguments)]
@@ -2180,12 +2212,34 @@ pub fn update_profile_note(
.map_err(|e| format!("Failed to update profile note: {e}"))
}
/// 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.
pub(crate) fn validate_launch_hook(launch_hook: Option<&str>) -> Result<Option<String>, String> {
let Some(raw) = launch_hook else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
let ok = url::Url::parse(trimmed)
.ok()
.map(|u| matches!(u.scheme(), "http" | "https"))
.unwrap_or(false);
if !ok {
return Err(serde_json::json!({ "code": "INVALID_LAUNCH_HOOK_URL" }).to_string());
}
Ok(Some(trimmed.to_string()))
}
#[tauri::command]
pub fn update_profile_launch_hook(
app_handle: tauri::AppHandle,
profile_id: String,
launch_hook: Option<String>,
) -> Result<BrowserProfile, String> {
validate_launch_hook(launch_hook.as_deref())?;
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
-404
View File
@@ -1115,149 +1115,6 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
pub async fn fetch_proxy_from_url(
&self,
url: &str,
timeout: std::time::Duration,
) -> Result<Option<ProxySettings>, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("Launch hook returned status {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Launch hook returned empty response".to_string());
}
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
return Ok(Some(settings));
}
match Self::parse_dynamic_proxy_text(body) {
Ok(settings) => Ok(Some(settings)),
Err(text_error) => Err(format!(
"Failed to parse launch hook response: {text_error}"
)),
}
}
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
let obj = json
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let raw_host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
// and extract the proxy type from it if no explicit type field is provided
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
(rest.to_string(), None)
} else if let Some((proto, rest)) = raw_host.split_once("://") {
(rest.to_string(), Some(proto.to_lowercase()))
} else {
(raw_host.to_string(), None)
};
let port = obj
.get("port")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
as u16;
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
.or(protocol_from_host)
.unwrap_or_else(|| "http".to_string());
let username = obj
.get("username")
.or_else(|| obj.get("user"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let password = obj
.get("password")
.or_else(|| obj.get("pass"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
}
// Parse plain text proxy payload using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
return Err("Empty text response".to_string());
}
match Self::parse_single_proxy_line(line) {
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
}),
ProxyParseResult::Ambiguous {
possible_formats, ..
} => Err(format!(
"Ambiguous proxy format. Could be: {}",
possible_formats.join(" or ")
)),
ProxyParseResult::Invalid { reason, .. } => {
Err(format!("Failed to parse proxy response: {reason}"))
}
}
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2317,8 +2174,6 @@ mod tests {
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3587,263 +3442,4 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.port, 1080);
// Protocol in host should be used as proxy_type when no explicit type field
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.host, "10.0.0.1");
assert_eq!(result2.proxy_type, "http");
// Explicit type field takes precedence over protocol in host
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.host, "10.0.0.1");
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_json_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_text_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 1080);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_proxy_from_url_respects_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let err = pm
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
.await
.unwrap_err();
assert!(err.contains("Failed to fetch launch hook"));
}
}
+99
View File
@@ -820,6 +820,105 @@ pub async fn save_app_settings(
Ok(settings)
}
/// Read the most recent N log files concatenated into a single string,
/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the
/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to
/// keep clipboard payloads sane.
#[tauri::command]
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
return Err("Log directory does not exist yet".to_string());
}
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
.map_err(|e| format!("Failed to read log dir: {e}"))?
.filter_map(|r| r.ok())
.filter_map(|e| {
let p = e.path();
let m = e.metadata().ok()?.modified().ok()?;
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
if p.is_file() && (ext == "log" || ext == "txt") {
Some((p, m))
} else {
None
}
})
.collect();
entries.sort_by_key(|(_, m)| *m);
const MAX_BYTES: usize = 5 * 1024 * 1024;
let mut out = String::with_capacity(64 * 1024);
for (path, _) in entries.iter().rev() {
let header = format!("===== {} =====\n", path.display());
if out.len() + header.len() >= MAX_BYTES {
break;
}
out.push_str(&header);
if let Ok(content) = std::fs::read_to_string(path) {
let take = MAX_BYTES.saturating_sub(out.len());
if take == 0 {
break;
}
if content.len() > take {
// Tail truncation — keep the END of older files so newest data is preserved.
out.push_str("[…truncated — older content elided…]\n");
out.push_str(&content[content.len() - take + 64..]);
} else {
out.push_str(&content);
}
if !out.ends_with('\n') {
out.push('\n');
}
}
}
// Reverse the per-file order so chronological newest is at the bottom.
// (We pushed newest-first above to budget the tail; flip now.)
let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect();
sections.reverse();
let final_out = sections
.into_iter()
.map(|s| format!("===== {s}"))
.collect::<String>();
Ok(final_out)
}
/// Reveal the log directory in the OS file manager.
#[tauri::command]
pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> {
let dir = crate::app_dirs::log_dir(&app_handle);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?;
}
let path = dir.to_string_lossy().to_string();
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open log dir: {e}"))?;
}
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
+127 -3
View File
@@ -4,10 +4,40 @@ use aes_gcm::{
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use std::collections::HashMap;
use std::sync::Mutex;
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
const E2E_FILE_VERSION: u8 = 1;
/// Argon2id is intentionally expensive (~80150 ms per call). During an
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
/// extension group, profile metadata) goes through `derive_profile_key`,
/// which without caching means hundreds of sequential 100 ms derivations.
///
/// Cache the derived key keyed on (sha256(password), salt). Entries are
/// evicted on `set_e2e_password` / `delete_e2e_password` so a password
/// change cannot use stale keys.
type DerivedKeyCache = HashMap<([u8; 32], String), [u8; 32]>;
static KEY_CACHE: std::sync::LazyLock<Mutex<DerivedKeyCache>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
fn password_fingerprint(pwd: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(pwd.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
fn invalidate_key_cache() {
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.clear();
}
}
fn get_e2e_password_path() -> std::path::PathBuf {
crate::app_dirs::settings_dir().join("e2e_password.dat")
}
@@ -17,6 +47,7 @@ fn get_vault_password() -> String {
}
pub fn store_e2e_password(password: &str) -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if let Some(parent) = file_path.parent() {
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
}
pub fn remove_e2e_password() -> Result<(), String> {
invalidate_key_cache();
let file_path = get_e2e_password_path();
if file_path.exists() {
std::fs::remove_file(&file_path)
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
Ok(())
}
/// Derive a per-profile encryption key using Argon2id
/// Derive a per-profile encryption key using Argon2id, with an in-process
/// cache keyed on `(sha256(password), salt)`. Repeated calls with the same
/// password+salt are O(1); a password change calls `invalidate_key_cache`
/// to drop stale entries.
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
let pwd_fp = password_fingerprint(user_password);
let cache_key = (pwd_fp, profile_salt.to_string());
if let Ok(cache) = KEY_CACHE.lock() {
if let Some(cached) = cache.get(&cache_key) {
return Ok(*cached);
}
}
let salt_bytes = BASE64
.decode(profile_salt)
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
@@ -175,6 +219,11 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes[..32]);
if let Ok(mut cache) = KEY_CACHE.lock() {
cache.insert(cache_key, key);
}
Ok(key)
}
@@ -220,13 +269,75 @@ pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String
.map_err(|e| format!("Decryption failed: {e}"))
}
/// Versioned encryption envelope used for non-profile entities (proxies,
/// VPNs, groups, extensions, extension groups). Each upload has its own
/// random per-entity salt so the bucket can't be rainbow-table-attacked
/// even with a shared password across many entities.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct EncryptedEnvelope {
/// Format version. Increment when changing how `ct` is structured.
pub v: u32,
/// Base64 of the per-entity salt. Plaintext on the wire — salts are public.
pub salt: String,
/// Base64 of `nonce(12B) || AES-256-GCM ciphertext` (output of `encrypt_bytes`).
pub ct: String,
}
/// Wrap a plaintext JSON byte slice into an encrypted envelope if the user
/// has E2E enabled. Returns `(payload_bytes, content_type)` ready to upload.
/// On no-password, returns the original JSON unchanged.
pub fn maybe_seal_for_upload(json: &[u8]) -> Result<(Vec<u8>, &'static str), String> {
let pwd = match load_e2e_password()? {
Some(p) => p,
None => return Ok((json.to_vec(), "application/json")),
};
let salt = generate_salt();
let key = derive_profile_key(&pwd, &salt)?;
let ct = encrypt_bytes(&key, json)?;
let envelope = EncryptedEnvelope {
v: 1,
salt,
ct: BASE64.encode(&ct),
};
let payload =
serde_json::to_vec(&envelope).map_err(|e| format!("Failed to serialize envelope: {e}"))?;
Ok((payload, "application/json"))
}
/// Reverse of `maybe_seal_for_upload`. Returns the inner plaintext JSON
/// bytes regardless of whether `raw` was an envelope or legacy plaintext.
///
/// Distinguishes three cases:
/// - `raw` is plaintext JSON, no password set → returns `raw` unchanged.
/// - `raw` is an envelope, password set → decrypts and returns plaintext.
/// - `raw` is an envelope, no password set → returns `Err(EncryptedEnvelope)`
/// so callers (subscription / startup probe) can show "enter password to
/// continue syncing" UI.
pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
// Try parsing as envelope first; envelopes are JSON objects with a "v" field.
if let Ok(env) = serde_json::from_slice::<EncryptedEnvelope>(raw) {
if env.v != 1 {
return Err(format!("Unsupported envelope version: {}", env.v));
}
let pwd = load_e2e_password()?.ok_or_else(|| "ENCRYPTION_PASSWORD_REQUIRED".to_string())?;
let key = derive_profile_key(&pwd, &env.salt)?;
let ct = BASE64
.decode(&env.ct)
.map_err(|e| format!("Invalid envelope ciphertext: {e}"))?;
return decrypt_bytes(&key, &ct);
}
// Not an envelope — legacy plaintext. Caller will JSON-parse it directly.
Ok(raw.to_vec())
}
// Tauri commands
#[tauri::command]
pub fn set_e2e_password(password: String) -> Result<(), String> {
pub async fn set_e2e_password(password: String) -> Result<(), String> {
if password.len() < 8 {
return Err("Password must be at least 8 characters".to_string());
}
enforce_team_owner_for_encryption_change().await?;
store_e2e_password(&password)
}
@@ -236,10 +347,23 @@ pub fn check_has_e2e_password() -> bool {
}
#[tauri::command]
pub fn delete_e2e_password() -> Result<(), String> {
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
remove_e2e_password()
}
/// On Team plans, only the team owner is allowed to flip the E2E password
/// state — otherwise members could lock each other out by changing the key.
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
use crate::cloud_auth::CLOUD_AUTH;
if let Some(state) = CLOUD_AUTH.get_user().await {
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
return Err("TEAM_OWNER_ONLY".to_string());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+245 -27
View File
@@ -716,7 +716,9 @@ impl SyncEngine {
}
let presign = self.client.presign_download(key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal profile metadata: {e}")))?;
let profile: BrowserProfile = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
@@ -794,15 +796,18 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
Ok(())
@@ -1392,17 +1397,20 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local proxy with new last_sync
// Update local proxy with new last_sync (always write plaintext locally)
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
fs::write(&proxy_file, &json).map_err(|e| {
@@ -1423,7 +1431,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("proxies/{}.json", proxy_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal proxy: {e}")))?;
let mut proxy: crate::proxy_manager::StoredProxy = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse proxy JSON: {e}")))?;
@@ -1534,14 +1545,17 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local group with new last_sync
@@ -1563,7 +1577,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("groups/{}.json", group_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal group: {e}")))?;
let mut group: crate::group_manager::ProfileGroup = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse group JSON: {e}")))?;
@@ -1738,14 +1755,17 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local VPN with new last_sync
@@ -1767,7 +1787,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("vpns/{}.json", vpn_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal VPN: {e}")))?;
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
@@ -1883,17 +1906,21 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.await?;
// Also upload the extension file data
// Also upload the extension file data — encrypted as a sealed envelope
// when E2E is on (the binary is the secret here, not just the metadata).
let file_path = {
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let file_dir = manager.get_file_dir_public(&ext.id);
@@ -1908,18 +1935,17 @@ impl SyncEngine {
))
})?;
let (file_payload, file_content_type) = encryption::maybe_seal_for_upload(&file_data)
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension file: {e}")))?;
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
let file_presign = self
.client
.presign_upload(&file_remote_key, Some("application/octet-stream"))
.presign_upload(&file_remote_key, Some(file_content_type))
.await?;
self
.client
.upload_bytes(
&file_presign.url,
&file_data,
Some("application/octet-stream"),
)
.upload_bytes(&file_presign.url, &file_payload, Some(file_content_type))
.await?;
}
@@ -1942,7 +1968,9 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("extensions/{}.json", ext_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension: {e}")))?;
let mut ext: crate::extension_manager::Extension = serde_json::from_slice(&data)
.map_err(|e| SyncError::SerializationError(format!("Failed to parse extension JSON: {e}")))?;
@@ -1960,7 +1988,9 @@ impl SyncEngine {
let file_stat = self.client.stat(&file_remote_key).await?;
if file_stat.exists {
let file_presign = self.client.presign_download(&file_remote_key).await?;
let file_data = self.client.download_bytes(&file_presign.url).await?;
let file_raw = self.client.download_bytes(&file_presign.url).await?;
let file_data = encryption::maybe_unseal_after_download(&file_raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension file: {e}")))?;
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let file_dir = manager.get_file_dir_public(&ext.id);
@@ -2085,14 +2115,17 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some("application/json"))
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?;
// Update local group with new last_sync
@@ -2114,7 +2147,10 @@ impl SyncEngine {
) -> SyncResult<()> {
let remote_key = format!("extension_groups/{}.json", group_id);
let presign = self.client.presign_download(&remote_key).await?;
let data = self.client.download_bytes(&presign.url).await?;
let raw = self.client.download_bytes(&presign.url).await?;
let data = encryption::maybe_unseal_after_download(&raw)
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension group: {e}")))?;
let mut group: crate::extension_manager::ExtensionGroup = serde_json::from_slice(&data)
.map_err(|e| {
@@ -3689,6 +3725,188 @@ pub async fn set_extension_group_sync_enabled(
Ok(())
}
/// Re-upload every sync-enabled entity under the current encryption state.
/// Called after the user sets, changes, or clears their E2E password —
/// existing remote bytes are still in the prior state, so without this they'd
/// remain plaintext (or worse, undecryptable) until the next per-entity edit.
///
/// Order: profiles first (so the user can resume work as soon as profile sync
/// completes), then proxies, groups, VPNs, extensions, extension groups.
/// Running profiles' associated entities are deferred by 5s so the active
/// browser session isn't disrupted mid-keystroke.
///
/// Progress is emitted via `e2e-rollover-progress` events with `{ stage, done, total }`.
#[tauri::command]
pub async fn rollover_encryption_for_all_entities(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ());
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let synced_profiles: Vec<_> = profiles
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({
"stage": "profiles",
"done": i + 1,
"total": total_profiles,
}),
);
}
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &profiles {
if running_profile_ids.contains(&p.id) {
if let Some(id) = &p.proxy_id {
deferred_proxy_ids.insert(id.clone());
}
if let Some(id) = &p.vpn_id {
deferred_vpn_ids.insert(id.clone());
}
if let Some(id) = &p.group_id {
deferred_group_ids.insert(id.clone());
}
}
}
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
);
}
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
};
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
);
}
let vpns = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
};
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
);
}
let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))?
};
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
);
}
let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))?
};
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
);
}
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+3 -3
View File
@@ -14,9 +14,9 @@ pub use engine::{
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
trigger_sync_for_profile, SyncEngine,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
+313 -113
View File
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
@@ -16,7 +17,6 @@ import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
@@ -32,6 +32,7 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { type AppPage, RailNav } from "@/components/rail-nav";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
@@ -141,6 +142,13 @@ export default function Home() {
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
// Tracks which tab inside the shared proxy-management page should be active.
// The VPN rail item routes to the same page but pre-selects the VPN tab.
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
"proxies" | "vpns"
>("proxies");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -175,7 +183,7 @@ export default function Home() {
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[]
>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
const [selectedGroupId, setSelectedGroupId] = useState<string>("__all__");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[]
>([]);
@@ -221,6 +229,53 @@ export default function Home() {
setSelectedProfiles([]);
}, []);
const handleRailNavigate = useCallback((page: AppPage) => {
// Always reset every sub-page-able dialog before opening the next one,
// so navigating from one rail item to another doesn't stack two
// sub-pages on top of each other.
setSettingsDialogOpen(false);
setProxyManagementDialogOpen(false);
setExtensionManagementDialogOpen(false);
setGroupManagementDialogOpen(false);
setIntegrationsDialogOpen(false);
setImportProfileDialogOpen(false);
setAccountDialogOpen(false);
setCurrentPage(page);
switch (page) {
case "profiles":
break;
case "settings":
setSettingsDialogOpen(true);
break;
case "proxies":
setProxyManagementInitialTab("proxies");
setProxyManagementDialogOpen(true);
break;
case "extensions":
setExtensionManagementDialogOpen(true);
break;
case "groups":
setGroupManagementDialogOpen(true);
break;
case "integrations":
setIntegrationsDialogOpen(true);
break;
case "import":
setImportProfileDialogOpen(true);
break;
case "vpns":
// VPNs share the proxy management page; pre-select the VPN tab so
// the user lands directly on the right list.
setProxyManagementInitialTab("vpns");
setProxyManagementDialogOpen(true);
break;
case "account":
setAccountDialogOpen(true);
break;
}
}, []);
// Check for missing binaries and offer to download them
const checkMissingBinaries = useCallback(async () => {
try {
@@ -1042,6 +1097,84 @@ export default function Home() {
profiles.length,
]);
// E2E encryption listeners — surface password-required prompts and rollover
// progress so the user isn't left guessing whether sealing finished.
useEffect(() => {
let unlistenRequired: (() => void) | undefined;
let unlistenStarted: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
let unlistenCompleted: (() => void) | undefined;
void (async () => {
unlistenRequired = await listen(
"profile-sync-e2e-password-required",
() => {
showToast({
id: "e2e-password-required",
type: "error",
title: t("encryption.required.title"),
description: t("encryption.required.description"),
duration: 12000,
action: {
label: t("encryption.required.openSettings"),
onClick: () => {
setSettingsDialogOpen(true);
setCurrentPage("settings");
},
},
});
},
);
unlistenStarted = await listen("e2e-rollover-started", () => {
showToast({
id: "e2e-rollover",
type: "loading",
title: t("encryption.rollover.startedTitle"),
description: t("encryption.rollover.startedDescription"),
duration: Number.POSITIVE_INFINITY,
});
});
unlistenProgress = await listen<{
stage: string;
done: number;
total: number;
}>("e2e-rollover-progress", (event) => {
const { stage, done, total } = event.payload;
showToast({
id: "e2e-rollover",
type: "loading",
title: t("encryption.rollover.progressTitle", {
stage: t(`encryption.rollover.stage.${stage}`),
}),
description: t("encryption.rollover.progressDescription", {
done,
total,
}),
duration: Number.POSITIVE_INFINITY,
});
});
unlistenCompleted = await listen("e2e-rollover-completed", () => {
showToast({
id: "e2e-rollover",
type: "success",
title: t("encryption.rollover.completedTitle"),
description: t("encryption.rollover.completedDescription"),
duration: 5000,
});
});
})();
return () => {
unlistenRequired?.();
unlistenStarted?.();
unlistenProgress?.();
unlistenCompleted?.();
};
}, [t]);
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => {
if (profiles.length === 0) return;
@@ -1109,8 +1242,11 @@ export default function Home() {
const filteredProfiles = useMemo(() => {
let filtered = profiles;
// Filter by group
if (!selectedGroupId || selectedGroupId === "default") {
// Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles.
if (selectedGroupId === "__all__") {
filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
@@ -1142,67 +1278,162 @@ export default function Home() {
// Update loading states
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
const subPageTitle =
currentPage === "profiles"
? undefined
: currentPage === "import"
? t("pageTitle.import")
: t(`pageTitle.${currentPage}`);
return (
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col items-center w-full max-w-4xl px-3">
<div className="w-full">
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
onImportProfileDialogOpen={setImportProfileDialogOpen}
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
</div>
<div className="w-full mt-2.5">
<GroupBadges
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
groups={groupsData}
isLoading={isLoading}
/>
<ProfilesDataTable
profiles={filteredProfiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
onSetPassword={handleSetPassword}
onChangePassword={handleChangePassword}
onRemovePassword={handleRemovePassword}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
/>
</div>
</main>
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
/>
<div className="flex flex-1 min-h-0">
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
{currentPage === "profiles" && (
<div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
{isLoading && groupsData.length === 0 ? null : null}
<ProfilesDataTable
profiles={filteredProfiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onCloneProfile={handleCloneProfile}
onSetPassword={handleSetPassword}
onChangePassword={handleChangePassword}
onRemovePassword={handleRemovePassword}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onBulkExtensionGroupAssignment={
handleBulkExtensionGroupAssignment
}
onAssignExtensionGroup={handleAssignExtensionGroup}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => {
setSyncLeaderProfile(profile);
}}
/>
</div>
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
onClose={() => {
setSettingsDialogOpen(false);
setCurrentPage("profiles");
}}
onIntegrationsOpen={() => {
setSettingsDialogOpen(false);
setIntegrationsDialogOpen(true);
setCurrentPage("integrations");
}}
subPage={currentPage === "settings"}
/>
)}
{integrationsDialogOpen && (
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
/>
)}
{proxyManagementDialogOpen && (
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "proxies" || currentPage === "vpns"}
initialTab={proxyManagementInitialTab}
/>
)}
{groupManagementDialogOpen && (
<GroupManagementDialog
isOpen={groupManagementDialogOpen}
onClose={() => {
setGroupManagementDialogOpen(false);
setCurrentPage("profiles");
}}
onGroupManagementComplete={handleGroupManagementComplete}
subPage={currentPage === "groups"}
/>
)}
{extensionManagementDialogOpen && (
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => {
setExtensionManagementDialogOpen(false);
setCurrentPage("profiles");
}}
limitedMode={false}
subPage={currentPage === "extensions"}
/>
)}
{importProfileDialogOpen && (
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
setCurrentPage("profiles");
}}
crossOsUnlocked={crossOsUnlocked}
subPage={currentPage === "import"}
/>
)}
{accountDialogOpen && (
<AccountPage
isOpen={accountDialogOpen}
onClose={() => {
setAccountDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "account"}
onOpenSignIn={() => {
setAccountDialogOpen(false);
setCurrentPage("profiles");
setDeviceCodeDialogOpen(true);
}}
/>
)}
</main>
</div>
<CreateProfileDialog
isOpen={createProfileDialogOpen}
@@ -1214,39 +1445,6 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<SettingsDialog
isOpen={settingsDialogOpen}
onClose={() => {
setSettingsDialogOpen(false);
}}
onIntegrationsOpen={() => {
setSettingsDialogOpen(false);
setIntegrationsDialogOpen(true);
}}
/>
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
}}
/>
<ImportProfileDialog
isOpen={importProfileDialogOpen}
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
@@ -1288,6 +1486,7 @@ export default function Home() {
profile={passwordDialogProfile}
mode={passwordDialogMode}
onSuccess={(p) => {
// Resume pending launch after unlock.
if (
passwordDialogMode === "unlock" &&
pendingLaunchAfterUnlockRef.current?.id === p.id
@@ -1296,6 +1495,23 @@ export default function Home() {
pendingLaunchAfterUnlockRef.current = null;
void launchProfile(target);
}
// On set/change/remove, the profile's encryption state changed.
// Push that state to the sync server immediately so other devices
// see the new envelope before they next pull. Skip if the profile
// is currently running — its files would be in flux.
if (
(passwordDialogMode === "set" ||
passwordDialogMode === "change" ||
passwordDialogMode === "remove") &&
!runningProfiles.has(p.id) &&
p.sync_mode !== "Disabled"
) {
void invoke("request_profile_sync", { profileId: p.id }).catch(
(err: unknown) => {
console.error("post-password sync failed", err);
},
);
}
}}
/>
@@ -1315,22 +1531,6 @@ export default function Home() {
crossOsUnlocked={crossOsUnlocked}
/>
<GroupManagementDialog
isOpen={groupManagementDialogOpen}
onClose={() => {
setGroupManagementDialogOpen(false);
}}
onGroupManagementComplete={handleGroupManagementComplete}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => {
setExtensionManagementDialogOpen(false);
}}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen}
onClose={() => {
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface AccountPageProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
onOpenSignIn: () => void;
}
export function AccountPage({
isOpen,
onClose,
subPage,
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshProfile();
showSuccessToast(t("account.refreshed"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsRefreshing(false);
}
};
const handleLogout = async () => {
try {
await logout();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
<>
<h2 className="text-base font-semibold truncate">
{user.email}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.plan", {
plan: user.plan,
period: user.planPeriod ?? "—",
})}
</p>
</>
) : (
<>
<h2 className="text-base font-semibold">
{t("account.signedOut")}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.signedOutDescription")}
</p>
</>
)}
</div>
</div>
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.plan")}
</p>
<p className="mt-0.5 font-medium uppercase">{user.plan}</p>
</div>
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.status")}
</p>
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
</div>
{user.teamRole && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.teamRole")}
</p>
<p className="mt-0.5">{user.teamRole}</p>
</div>
)}
{user.planPeriod && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.period")}
</p>
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
void handleRefresh();
}}
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
{t("account.refresh")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
{t("account.logout")}
</Button>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -568,7 +568,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
+2 -2
View File
@@ -255,7 +255,7 @@ export function UnifiedToast(props: ToastProps) {
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{ width: `${progress.percentage}%` }}
/>
</div>
@@ -275,7 +275,7 @@ export function UnifiedToast(props: ToastProps) {
<div className="flex items-center space-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
+28 -1
View File
@@ -3,7 +3,9 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuExternalLink } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -16,6 +18,8 @@ import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface DeviceCodeVerifyDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
@@ -36,6 +40,19 @@ export function DeviceCodeVerifyDialog({
const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [isOpeningLogin, setIsOpeningLogin] = useState(false);
const handleOpenLogin = async () => {
setIsOpeningLogin(true);
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
} finally {
setIsOpeningLogin(false);
}
};
// Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate.
@@ -75,12 +92,22 @@ export function DeviceCodeVerifyDialog({
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
<DialogTitle>{t("sync.cloud.signInTitle")}</DialogTitle>
<DialogDescription>
{t("sync.cloud.deviceLinkInstructions")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Button
type="button"
variant="outline"
onClick={() => void handleOpenLogin()}
disabled={isOpeningLogin}
className="w-full gap-1.5"
>
<LuExternalLink className="w-3.5 h-3.5" />
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
<Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")}
+23 -15
View File
@@ -96,12 +96,14 @@ interface ExtensionManagementDialogProps {
isOpen: boolean;
onClose: () => void;
limitedMode: boolean;
subPage?: boolean;
}
export function ExtensionManagementDialog({
isOpen,
onClose,
limitedMode,
subPage,
}: ExtensionManagementDialogProps) {
const { t } = useTranslation();
const [extensions, setExtensions] = useState<Extension[]>([]);
@@ -526,18 +528,22 @@ export function ExtensionManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>{t("extensions.description")}</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>
{t("extensions.description")}
</DialogDescription>
</DialogHeader>
)}
<ScrollArea className="overflow-y-auto flex-1">
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
<div className="relative">
{limitedMode && (
<>
@@ -985,11 +991,13 @@ export function ExtensionManagementDialog({
</div>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+18 -12
View File
@@ -93,12 +93,14 @@ interface GroupManagementDialogProps {
isOpen: boolean;
onClose: () => void;
onGroupManagementComplete: () => void;
subPage?: boolean;
}
export function GroupManagementDialog({
isOpen,
onClose,
onGroupManagementComplete,
subPage,
}: GroupManagementDialogProps) {
const { t } = useTranslation();
const [groups, setGroups] = useState<GroupWithCount[]>([]);
@@ -249,14 +251,16 @@ export function GroupManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle>{t("groups.management")}</DialogTitle>
<DialogDescription>
{t("groups.noGroupDescription")}
</DialogDescription>
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
@@ -418,11 +422,13 @@ export function GroupManagementDialog({
)}
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+277 -311
View File
@@ -1,245 +1,290 @@
import { useCallback, useEffect, useRef, useState } from "react";
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuSearch,
LuUsers,
LuX,
} from "react-icons/lu";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Input } from "./ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
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";
const HOLD_MS = 150;
const DRAG_THRESHOLD_PX = 3;
function useLogoEasterEgg() {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
const isTextInputTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return false;
const el = target.closest(
"input, select, textarea, [contenteditable=''], [contenteditable='true']",
);
return el !== 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 floorY = window.innerHeight;
const leftWall = 0;
const rightWall = window.innerWidth;
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;
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;
vy += GRAVITY * dt;
x += vx * dt;
y += vy * dt;
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
// Floor bounce
const currentBottom = startY + y + rect.height;
if (currentBottom >= floorY && vy > 0) {
y = floorY - startY - rect.height;
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
vy = -Math.abs(vy) * BOUNCE_DAMPING;
} else {
vy = -MIN_BOUNCE_VELOCITY * 3;
}
}
// Left wall bounce only — right wall lets it fly off screen
const currentLeft = startX + x;
if (currentLeft <= leftWall && vx < 0) {
x = leftWall - startX;
vx = Math.abs(vx) * 1.1;
}
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
const currentTop = startY + y;
const offScreenRight = startX + x > rightWall + 50;
const offScreenBottom = currentTop > floorY + 100;
const offScreenTop = currentTop + rect.height < -200;
if (offScreenRight || offScreenBottom || offScreenTop) {
clone.remove();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
triggerFall();
} else {
setWobbleKey((k) => k + 1);
}
}, [isFalling, isHidden, triggerFall]);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
};
}
const ALL_FILTER_ID = "__all__";
interface Props {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
onGroupManagementDialogOpen: (open: boolean) => void;
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
onExtensionManagementDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
}
const HomeHeader = ({
onSettingsDialogOpen,
onProxyManagementDialogOpen,
onGroupManagementDialogOpen,
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
onExtensionManagementDialogOpen,
searchQuery,
onSearchQueryChange,
groups,
selectedGroupId,
onGroupSelect,
pageTitle,
}: Props) => {
const { t } = useTranslation();
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
const [platform, setPlatform] = useState<string>("macos");
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
const isMacOS = platform === "macos";
const showProfileToolbar = !pageTitle;
const totalProfiles = useMemo(
() => groups.reduce((sum, g) => sum + g.count, 0),
[groups],
);
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const dragStartedRef = useRef(false);
const activePointerIdRef = useRef<number | null>(null);
const dragRootRef = useRef<HTMLDivElement | null>(null);
const clearHold = useCallback(() => {
if (holdTimeoutRef.current !== null) {
window.clearTimeout(holdTimeoutRef.current);
holdTimeoutRef.current = null;
}
}, []);
const beginDrag = useCallback(() => {
if (dragStartedRef.current) return;
dragStartedRef.current = true;
clearHold();
void getCurrentWindow().startDragging();
}, [clearHold]);
useEffect(() => {
return () => {
clearHold();
};
}, [clearHold]);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (isTextInputTarget(e.target)) return;
dragStartedRef.current = false;
dragStartRef.current = { x: e.clientX, y: e.clientY };
activePointerIdRef.current = e.pointerId;
clearHold();
holdTimeoutRef.current = window.setTimeout(() => {
holdTimeoutRef.current = null;
beginDrag();
}, HOLD_MS);
},
[beginDrag, clearHold],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (
dragStartedRef.current ||
dragStartRef.current === null ||
activePointerIdRef.current !== e.pointerId
) {
return;
}
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
beginDrag();
}
},
[beginDrag],
);
const handlePointerEnd = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== e.pointerId) return;
clearHold();
dragStartRef.current = null;
activePointerIdRef.current = null;
dragStartedRef.current = false;
},
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
useEffect(() => {
const el = groupsScrollRef.current;
if (!el) return;
const update = () => {
setGroupsFadeLeft(el.scrollLeft > 1);
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, []);
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
<div
ref={dragRootRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
className="flex items-center gap-2 h-11 px-3 border-b border-border bg-card select-none"
>
{isMacOS && (
<div
aria-hidden="true"
className="flex items-center gap-[7px] mr-1 shrink-0"
>
{/* Reserve space for the macOS native traffic lights the OS draws
the colored buttons here through the transparent titlebar. */}
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
<div className="w-[11px] h-[11px] rounded-full" />
</div>
)}
{pageTitle ? (
<span className="text-xs font-semibold text-card-foreground ml-2">
{pageTitle}
</span>
) : null}
{showProfileToolbar && (
<div className="relative flex-1 min-w-0 flex items-center">
{groupsFadeLeft && (
<button
type="button"
aria-label={t("header.scrollGroupsLeft")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: -el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronLeft className="w-3 h-3" />
</button>
)}
<div
ref={groupsScrollRef}
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{
paddingLeft: groupsFadeLeft ? 22 : 0,
paddingRight: groupsFadeRight ? 22 : 0,
}}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle>
</div>
<div className="flex gap-2 items-center">
<div className="relative">
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return (
<button
key={group.id}
type="button"
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
active
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</div>
{groupsFadeRight && (
<button
type="button"
aria-label={t("header.scrollGroupsRight")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: el.clientWidth * 0.6,
behavior: "smooth",
});
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronRight className="w-3 h-3" />
</button>
)}
</div>
)}
{!showProfileToolbar && <div className="flex-1" />}
{showProfileToolbar && (
<div className="relative shrink-0">
<Input
type="text"
placeholder={t("header.searchPlaceholder")}
@@ -247,122 +292,43 @@ const HomeHeader = ({
onChange={(e) => {
onSearchQueryChange(e.target.value);
}}
className="pr-8 pl-10 w-48"
className="pr-7 pl-8 w-52 h-7 text-xs"
/>
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
{searchQuery && (
<LuSearch className="absolute left-2.5 top-1/2 w-3.5 h-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
<button
type="button"
onClick={() => {
onSearchQueryChange("");
}}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={t("header.clearSearch")}
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuX className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("header.moreActions")}</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onSettingsDialogOpen(true);
}}
>
<GoGear className="mr-2 w-4 h-4" />
{t("header.menu.settings")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
{t("header.menu.proxies")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onGroupManagementDialogOpen(true);
}}
>
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
}}
>
<LuCloud className="mr-2 w-4 h-4" />
{t("header.menu.syncService")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onIntegrationsDialogOpen(true);
}}
>
<LuPlug className="mr-2 w-4 h-4" />
{t("header.menu.integrations")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onImportProfileDialogOpen(true);
}}
>
<FaDownload className="mr-2 w-4 h-4" />
{t("header.menu.importProfile")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<span className="shrink-0">
<Button
size="sm"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
className="flex gap-2 items-center h-[36px]"
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="w-3.5 h-3.5" />
{t("header.newProfile")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
{t("header.createProfile")}
</TooltipContent>
<TooltipContent>{t("header.createProfile")}</TooltipContent>
</Tooltip>
</div>
)}
</div>
);
};
+21 -10
View File
@@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
@@ -30,6 +29,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -43,12 +43,14 @@ interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
subPage?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
subPage,
}: ImportProfileDialogProps) {
const { t } = useTranslation();
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
@@ -292,11 +294,13 @@ export function ImportProfileDialog({
}, [isOpen, loadDetectedProfiles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
@@ -600,12 +604,19 @@ export function ImportProfileDialog({
)}
</div>
<DialogFooter className="flex-shrink-0">
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
{!subPage && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
@@ -635,7 +646,7 @@ export function ImportProfileDialog({
</LoadingButton>
</>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
+8 -3
View File
@@ -36,11 +36,13 @@ interface McpConfig {
interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
}
export function IntegrationsDialog({
isOpen,
onClose,
subPage,
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -206,11 +208,14 @@ export function IntegrationsDialog({
onOpenChange={(open) => {
if (!open) onClose();
}}
subPage={subPage}
>
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
</DialogHeader>
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
+420 -115
View File
@@ -9,6 +9,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
@@ -23,7 +24,9 @@ import {
LuCookie,
LuInfo,
LuLock,
LuPlay,
LuPuzzle,
LuSquare,
LuTrash2,
LuTriangleAlert,
LuUsers,
@@ -51,7 +54,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
@@ -68,12 +70,12 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
getCurrentOS,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
@@ -83,6 +85,7 @@ import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProxyCheckResult,
StoredProxy,
@@ -154,6 +157,15 @@ interface TableMeta {
vpnId: string | null,
) => void | Promise<void>;
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
React.SetStateAction<BrowserProfile | null>
>;
// Selection helpers
isProfileSelected: (id: string) => boolean;
handleToggleAll: (checked: boolean) => void;
@@ -298,6 +310,187 @@ function getProfileSyncStatusDot(
}
}
// Inline extension-group dropdown for the Ext column. Matches the
// proxy column's Popover-style picker — no nested dialog.
function ExtCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const groupId = profile.extension_group_id ?? null;
const group = groupId
? meta.extensionGroups.find((g) => g.id === groupId)
: undefined;
const label = group?.name ?? meta.t("profiles.table.extDefault");
const onPick = async (nextId: string | null) => {
setIsSaving(true);
try {
await invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: nextId,
});
} catch (err) {
console.error("Failed to assign extension group:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
>
<LuPuzzle className="w-3 h-3 shrink-0" />
<span className="truncate flex-1" title={label}>
{label}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder={meta.t("profiles.table.extSearch")} />
<CommandList>
<CommandEmpty>{meta.t("profiles.table.extEmpty")}</CommandEmpty>
<CommandGroup>
<CommandItem
value="__default__"
onSelect={() => {
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.extDefault")}
</span>
</CommandItem>
{meta.extensionGroups.map((g) => (
<CommandItem
key={g.id}
value={g.name}
onSelect={() => {
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// Inline DNS blocklist dropdown — same Popover/Command pattern as Ext.
function DnsCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
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 onPick = async (nextLevel: string | null) => {
setIsSaving(true);
try {
await invoke("update_profile_dns_blocklist", {
profileId: profile.id,
level: nextLevel,
});
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
: meta.t("dnsBlocklist.none")
}
>
<FiWifi className="w-3 h-3 shrink-0" />
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide">
{level ?? "—"}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
</CommandItem>
{LEVELS.map((l) => (
<CommandItem
key={l.value}
value={l.value}
onSelect={() => {
void onPick(l.value);
}}
>
{level === l.value && (
<LuCheck className="mr-2 w-3.5 h-3.5" />
)}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -1023,6 +1216,36 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
// Extension groups for the Ext column lookup. Refreshed when the
// backend emits 'extensions-changed' (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = React.useState<
ExtensionGroup[]
>([]);
React.useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
@@ -1552,6 +1775,11 @@ export function ProfilesDataTable({
vpnOverrides,
handleVpnSelection,
// Extension groups
extensionGroups,
onAssignExtensionGroup,
setDnsBlocklistProfile,
// Selection helpers
isProfileSelected: (id: string) => selectedProfiles.includes(id),
handleToggleAll,
@@ -1643,6 +1871,8 @@ export function ProfilesDataTable({
vpnConfigs,
vpnOverrides,
handleVpnSelection,
extensionGroups,
onAssignExtensionGroup,
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -1743,7 +1973,7 @@ export function ProfilesDataTable({
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1852,7 +2082,7 @@ export function ProfilesDataTable({
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1861,11 +2091,11 @@ export function ProfilesDataTable({
},
enableSorting: false,
enableHiding: false,
size: 40,
size: 28,
},
{
id: "actions",
size: 100,
size: 48,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -1983,11 +2213,18 @@ export function ProfilesDataTable({
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
aria-label={
isRunning
? meta.t("profiles.actions.stop")
: meta.t("profiles.actions.launch")
}
className={cn(
"min-w-[80px] h-7 px-3",
"h-7 w-7 p-0 grid place-items-center",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
isRunning &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
)}
onClick={() =>
isRunning
@@ -1996,13 +2233,11 @@ export function ProfilesDataTable({
}
>
{isLaunching || isStopping ? (
<div className="flex gap-1 items-center">
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
</div>
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : isRunning ? (
meta.t("profiles.actions.stop")
<LuSquare className="w-3.5 h-3.5 fill-current" />
) : (
meta.t("profiles.actions.launch")
<LuPlay className="w-3.5 h-3.5 fill-current" />
)}
</RippleButton>
</span>
@@ -2092,11 +2327,15 @@ export function ProfilesDataTable({
const display =
name.length < 14 ? (
<div className="font-medium text-left leading-none">{name}</div>
<div className="font-medium text-left leading-none truncate">
{name}
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="leading-none">{trimName(name, 14)}</span>
<span className="leading-none block truncate">
{trimName(name, 14)}
</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
@@ -2114,11 +2353,11 @@ export function ProfilesDataTable({
const isLocked = meta.isProfileLockedByAnother(profile.id);
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
<button
type="button"
className={cn(
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
@@ -2159,7 +2398,7 @@ export function ProfilesDataTable({
},
{
id: "tags",
size: 110,
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
@@ -2192,7 +2431,7 @@ export function ProfilesDataTable({
},
{
id: "note",
size: 110,
size: 80,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
@@ -2223,7 +2462,7 @@ export function ProfilesDataTable({
},
{
id: "proxy",
size: 130,
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
@@ -2282,17 +2521,19 @@ export function ProfilesDataTable({
(snapshot?.current_bytes_received ?? 0);
return (
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
<div className="overflow-hidden min-w-0">
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<div className="flex overflow-hidden gap-2 items-center min-w-0">
<Popover
open={isSelectorOpen}
onOpenChange={(open) => {
@@ -2498,10 +2739,36 @@ export function ProfilesDataTable({
);
},
},
{
id: "ext",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.ext");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <ExtCell profile={profile} meta={meta} />;
},
},
{
id: "dns",
size: 95,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.dns");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "sync",
header: "",
size: 24,
size: 28,
cell: ({ row, table }) => {
const profile = row.original;
const meta = table.options.meta as TableMeta;
@@ -2525,7 +2792,7 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className="flex justify-center items-center h-9 w-full">
{dot.encrypted ? (
<LuLock
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
@@ -2544,16 +2811,16 @@ export function ProfilesDataTable({
},
{
id: "settings",
size: 40,
size: 32,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
return (
<div className="flex justify-end items-center">
<div className="flex justify-end items-center h-9 w-full">
<Button
variant="ghost"
className="p-0 w-8 h-8"
className="p-0 w-7 h-7"
disabled={!meta.isClient}
onClick={() => {
setProfileForInfoDialog(profile);
@@ -2595,98 +2862,136 @@ export function ProfilesDataTable({
meta: tableMeta,
});
const platform = getCurrentOS();
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const sortedRows = table.getRowModel().rows;
useScrollFade(scrollParentRef);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
const ROW_HEIGHT = 36;
const rowVirtualizer = useVirtualizer({
count: sortedRows.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - virtualRows[virtualRows.length - 1].end
: 0;
return (
<>
<ScrollArea
className={cn(
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
platform === "macos" ? "h-[340px]" : "h-[280px]",
)}
>
<Table className="overflow-visible table-fixed">
<TableHeader className="overflow-visible">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible"
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
>
<Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="overflow-visible !border-0"
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="overflow-visible">
{sortedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
) : (
<>
{paddingTop > 0 && (
<tr style={{ height: `${paddingTop}px` }}>
<td colSpan={columns.length} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = sortedRows[virtualRow.index];
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(
row.original.host_os ||
row.original.camoufox_config?.os ||
row.original.wayfern_config?.os ||
"",
),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
"overflow-visible hover:bg-accent/50 !border-0",
rowIsCrossOs && "opacity-60",
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("profiles.table.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="overflow-visible py-0"
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<tr style={{ height: `${paddingBottom}px` }}>
<td colSpan={columns.length} />
</tr>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => {
File diff suppressed because it is too large Load Diff
+47 -17
View File
@@ -40,6 +40,7 @@ import {
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
@@ -100,11 +101,16 @@ function getSyncStatusDot(
interface ProxyManagementDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
/** Which tab to display first when the dialog mounts; defaults to "proxies". */
initialTab?: "proxies" | "vpns";
}
export function ProxyManagementDialog({
isOpen,
onClose,
subPage,
initialTab = "proxies",
}: ProxyManagementDialogProps) {
const { t } = useTranslation();
// Proxy state
@@ -391,22 +397,44 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
<DialogDescription>
{t("proxies.management.description")}
</DialogDescription>
</DialogHeader>
{!subPage && (
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
<DialogDescription>
{t("proxies.management.description")}
</DialogDescription>
</DialogHeader>
)}
<ScrollArea className="overflow-y-auto flex-1">
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
<Tabs key={initialTab} defaultValue={initialTab}>
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="proxies"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
{t("proxies.management.tabProxies")}
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
<TabsTrigger
value="vpns"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
{t("proxies.management.tabVpns")}
</TabsTrigger>
</TabsList>
@@ -844,11 +872,13 @@ export function ProxyManagementDialog({
</Tabs>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
{!subPage && (
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
+472
View File
@@ -0,0 +1,472 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuPlug,
LuPuzzle,
LuShieldCheck,
LuUser,
LuUsers,
} from "react-icons/lu";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export type AppPage =
| "profiles"
| "proxies"
| "extensions"
| "groups"
| "vpns"
| "settings"
| "integrations"
| "account"
| "import";
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({
currentPage,
onNavigate,
}: {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}) {
const clickTimestamps = useRef<number[]>([]);
const [isPressed, setIsPressed] = useState(false);
const [wobbleKey, setWobbleKey] = useState(0);
const [isFalling, setIsFalling] = useState(false);
/**
* Click count toward the bounce trigger while the user is on the profiles
* page. Capped at 4: each click here grows the logo by 25%, so step 4 has
* doubled the original size. Click 5 fires `triggerFall` and resets.
*/
const [growStep, setGrowStep] = useState(0);
const resetTimeoutRef = useRef<number | null>(null);
const [isHidden, setIsHidden] = useState(() => {
try {
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
} catch {
return false;
}
});
const logoRef = useRef<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(0);
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 floorY = window.innerHeight;
const rightWall = window.innerWidth;
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;
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();
try {
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
} catch {
// ignore — sessionStorage unavailable in some Tauri WebViews
}
setIsHidden(true);
setIsFalling(false);
return;
}
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
}, [isFalling]);
useEffect(() => {
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, []);
const handleClick = useCallback(() => {
if (isFalling || isHidden) return;
// First behaviour: any click from elsewhere in the app just routes the
// user back to the profiles list. Growing the donut requires the user
// to already be home — that keeps the easter egg from accidentally
// firing during normal navigation.
if (currentPage !== "profiles") {
onNavigate("profiles");
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
return;
}
const now = Date.now();
clickTimestamps.current = clickTimestamps.current.filter(
(t) => now - t < CLICK_WINDOW_MS,
);
clickTimestamps.current.push(now);
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
triggerFall();
} else {
setGrowStep(
Math.min(clickTimestamps.current.length, CLICK_THRESHOLD - 1),
);
setWobbleKey((k) => k + 1);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = window.setTimeout(() => {
clickTimestamps.current = [];
setGrowStep(0);
resetTimeoutRef.current = null;
}, CLICK_WINDOW_MS);
}
}, [currentPage, isFalling, isHidden, onNavigate, triggerFall]);
// Leaving the profiles page mid-streak cancels growth so we never end up
// with an outsized logo when the user returns later.
useEffect(() => {
if (currentPage !== "profiles") {
clickTimestamps.current = [];
setGrowStep(0);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
}
}, [currentPage]);
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
};
}, []);
return {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
};
}
interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
}
interface RailItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
}
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
];
interface MoreMenuItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
hintKey: string;
}
const MORE_ITEMS: MoreMenuItem[] = [
{
page: "import",
Icon: FaDownload,
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
page: "vpns",
Icon: LuShieldCheck,
labelKey: "rail.more.vpns",
hintKey: "rail.more.vpnsHint",
},
{
page: "integrations",
Icon: LuPlug,
labelKey: "rail.more.integrations",
hintKey: "rail.more.integrationsHint",
},
{
page: "account",
Icon: LuCloud,
labelKey: "rail.more.account",
hintKey: "rail.more.accountHint",
},
];
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
growStep,
handleClick,
} = useLogoEasterEgg({ currentPage, onNavigate });
return (
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
{!isHidden ? (
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
className="grid place-items-center w-7 h-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
}}
onPointerUp={() => {
setIsPressed(false);
}}
onPointerLeave={() => {
setIsPressed(false);
}}
>
{/* Inner wrapper survives clicks (no `key`) so the scale change
animates smoothly across the wiggle layer's remounts. */}
<span
style={{
transform: isPressed
? `scale(${(1 + growStep * 0.25) * 0.9})`
: `scale(${1 + growStep * 0.25})`,
}}
className="inline-grid place-items-center transition-transform duration-300 ease-out will-change-transform"
>
<span
key={wobbleKey}
className={cn(
"inline-grid place-items-center",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
>
<Logo className="w-5 h-5 will-change-transform" />
</span>
</span>
</button>
) : (
<div className="w-7 h-7" />
)}
<div className="w-5 h-px bg-border my-1" />
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
const active = currentPage === page;
return (
<Tooltip key={page} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate(page);
}}
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
</Tooltip>
);
})}
<div className="flex-1" />
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setMoreOpen((v) => !v);
}}
aria-label={t("rail.more.label")}
aria-expanded={moreOpen}
className={cn(
"grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
moreOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
<GoKebabHorizontal className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onNavigate("settings");
}}
aria-label={t("rail.settings")}
aria-current={currentPage === "settings" ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
{currentPage === "settings" && (
<span
aria-hidden="true"
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<GoGear className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
</Tooltip>
{moreOpen && (
<>
<button
type="button"
aria-label={t("rail.more.closeAriaLabel")}
className="fixed inset-0 z-30 bg-transparent cursor-default"
onClick={() => {
setMoreOpen(false);
}}
/>
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
<button
key={page}
type="button"
onClick={() => {
setMoreOpen(false);
onNavigate(page);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
>
<span className="grid place-items-center w-5 h-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="w-3 h-3" />
</span>
<span className="flex flex-col min-w-0">
<span className="text-xs font-medium text-foreground truncate">
{t(labelKey)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{t(hintKey)}
</span>
</span>
</button>
))}
</div>
</>
)}
</nav>
);
}
+85 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import Color from "color";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -53,6 +54,7 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -83,12 +85,14 @@ interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onIntegrationsOpen?: () => void;
subPage?: boolean;
}
export function SettingsDialog({
isOpen,
onClose,
onIntegrationsOpen,
subPage,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -603,13 +607,20 @@ export function SettingsDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
)}
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
<div
className={cn(
"grid overflow-y-auto flex-1 gap-6 min-h-0",
subPage ? "py-2" : "py-4",
)}
>
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
@@ -1000,6 +1011,7 @@ export function SettingsDialog({
await invoke("delete_e2e_password");
setHasE2ePassword(false);
showSuccessToast(t("settings.encryption.removed"));
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
}
@@ -1056,6 +1068,7 @@ export function SettingsDialog({
showSuccessToast(
t("settings.encryption.passwordSaved"),
);
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
@@ -1170,6 +1183,40 @@ export function SettingsDialog({
<p className="text-xs text-muted-foreground">
{t("settings.advanced.clearCacheDescription")}
</p>
<div className="grid grid-cols-2 gap-2 pt-2">
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
const content = await invoke<string>("read_log_files");
await writeClipboardText(content);
showSuccessToast(t("settings.advanced.copyLogsSuccess"));
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.copyLogs")}
</RippleButton>
<RippleButton
variant="outline"
className="text-xs"
onClick={async () => {
try {
await invoke("open_log_directory");
} catch (err) {
showErrorToast(String(err));
}
}}
>
{t("settings.advanced.openLogDir")}
</RippleButton>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.copyLogsDescription")}
</p>
</div>
{/* System Info */}
@@ -1182,22 +1229,39 @@ export function SettingsDialog({
)}
</div>
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
{subPage ? (
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
<LoadingButton
size="sm"
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</div>
) : (
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={() => {
handleSave().catch((err: unknown) => {
console.error(err);
});
}}
disabled={isLoading || !hasChanges}
>
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
)}
</DialogContent>
</Dialog>
<DnsBlocklistDialog
+2 -2
View File
@@ -55,12 +55,12 @@ export function CopyToClipboard({
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
</span>
<LuCopy
className={`h-4 w-4 transition-all duration-300 ${
className={`h-4 w-4 transition-all duration-150 ${
copied ? "scale-0" : "scale-100"
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-300 ${
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-150 ${
copied ? "scale-100" : "scale-0"
}`}
/>
+93 -11
View File
@@ -14,14 +14,21 @@ import { WindowDragArea } from "../window-drag-area";
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps["onOpenChange"];
subPage: boolean;
container: HTMLElement | null | undefined;
};
const [DialogProvider, useDialog] =
getStrictContext<DialogContextType>("DialogContext");
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root> & {
/** Render in a portal container as an in-flow sub-page instead of a centered modal. */
subPage?: boolean;
/** Portal container target. Required when subPage=true; ignored otherwise. */
container?: HTMLElement | null;
};
function Dialog(props: DialogProps) {
function Dialog({ subPage, container, children, ...props }: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
@@ -29,12 +36,27 @@ function Dialog(props: DialogProps) {
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogProvider
value={{
isOpen,
setIsOpen,
subPage: !!subPage,
container: container ?? undefined,
}}
>
{/* In sub-page mode the Dialog isn't a modal it's an in-flow page.
Forcing `modal={false}` prevents Radix from locking pointer-events
and aria-hiding everything outside the dialog. Children are passed
explicitly (not via spread) so React doesn't have to guess where
the JSX subtree should mount. */}
<DialogPrimitive.Root
data-slot="dialog"
{...props}
modal={subPage ? false : props.modal}
onOpenChange={setIsOpen}
/>
>
{children}
</DialogPrimitive.Root>
</DialogProvider>
);
}
@@ -51,7 +73,7 @@ type DialogPortalProps = Omit<
>;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
const { isOpen, container } = useDialog();
return (
<AnimatePresence>
@@ -59,6 +81,7 @@ function DialogPortal(props: DialogPortalProps) {
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
container={container ?? props.container}
{...props}
/>
)}
@@ -102,8 +125,54 @@ type DialogContentProps = Omit<
> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
/**
* Suppress the built-in top-right close X. Use when the dialog renders
* its own header bar with a custom close control to avoid two X buttons
* stacking near the corner.
*/
hideClose?: boolean;
};
function SubPageContent({
children,
}: {
className?: string;
children?: React.ReactNode;
}) {
const { isOpen } = useDialog();
if (!isOpen) return null;
// Inline styles deliberately override any className the caller passed
// for the modal mode (max-w-*, max-h-*, my-*). tailwind-merge inside the
// shared dialog wrappers turned out to be unreliable when both classnames
// and !important variants competed — inline styles guarantee the layout.
return (
<motion.div
data-slot="sub-page"
data-sub-page="true"
initial={false}
animate={{ opacity: 1 }}
style={{
position: "relative",
display: "flex",
flexDirection: "column",
flex: "1 1 0%",
minHeight: 0,
width: "100%",
maxWidth: "none",
height: "100%",
maxHeight: "none",
margin: 0,
padding: 12,
gap: 12,
overflow: "auto",
background: "var(--background)",
}}
>
{children}
</motion.div>
);
}
function DialogContent({
className,
children,
@@ -113,14 +182,25 @@ function DialogContent({
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition,
hideClose,
...props
}: DialogContentProps) {
const { t } = useTranslation();
const { subPage } = useDialog();
const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
const finalTransition = transition ?? {
type: "spring",
stiffness: 220,
damping: 26,
};
if (subPage) {
return <SubPageContent>{children}</SubPageContent>;
}
return (
<DialogPortal data-slot="dialog-portal">
@@ -158,7 +238,7 @@ function DialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
transition={finalTransition}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className,
@@ -166,10 +246,12 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
{!hideClose && (
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</DialogPortal>
+50 -63
View File
@@ -43,23 +43,20 @@ export function WindowDragArea() {
return null;
}
// macOS: transparent drag area overlay
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return (
<button
type="button"
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
data-window-drag-area="true"
onPointerDown={handlePointerDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
);
return null;
}
// Windows: custom title bar with drag area + minimize/close buttons
// Windows: minimize/close controls anchored at the top-right corner of
// the sys-bar. The HomeHeader's own drag-region overlay handles window
// dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
@@ -75,64 +72,54 @@ export function WindowDragArea() {
console.error("Failed to close window:", error);
}
};
void handlePointerDown; // kept for backwards-compat; not used on Windows now
return (
<div
className="fixed top-0 right-0 left-0 h-10 z-[999999] flex items-center select-none"
data-window-drag-area="true"
className="fixed top-0 right-0 z-50 flex items-center h-11 select-none"
aria-hidden="false"
>
{/* Draggable area */}
<button
type="button"
className="flex-1 h-full bg-transparent border-0 cursor-default"
onPointerDown={handlePointerDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onClick={() => {
void handleMinimize();
}}
/>
{/* Window control buttons */}
<div className="flex items-center h-full">
<button
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex items-center justify-center w-12 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
className="flex items-center justify-center w-11 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex items-center justify-center w-12 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex items-center justify-center w-11 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
</div>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
interface CommonControlledStateProps<T> {
value?: T;
defaultValue?: T;
}
/**
* Returns either the caller-controlled `value` (read straight from props) or
* an internal state when uncontrolled. The previous implementation kept the
* controlled prop in a useEffect-synced state, which lagged one render
* behind when two sibling consumers flipped their `value` props in the
* same React batch, both saw stale state for one render and the wrong tree
* mounted briefly. Returning the prop directly when controlled makes the
* component synchronous in the controlled case, matching React's controlled
* input pattern.
*/
export function useControlledState<T, Rest extends unknown[] = []>(
props: CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
},
): readonly [T, (next: T, ...args: Rest) => void] {
const { value, defaultValue, onChange } = props;
const [internalState, setInternalState] = React.useState<T>(
value ?? (defaultValue as T),
);
const isControlled = value !== undefined;
const currentState = isControlled ? value : internalState;
const setState = React.useCallback(
(next: T, ...args: Rest) => {
// Always notify caller via onChange so a controlled consumer can
// update its own state. Internal state is only relevant in the
// uncontrolled case but we keep it in sync so the hook reads the
// right value if the consumer later removes its controlled prop.
if (!isControlled) {
setInternalState(next);
}
onChange?.(next, ...args);
},
[isControlled, onChange],
);
return [currentState, setState] as const;
}
+17 -53
View File
@@ -122,61 +122,25 @@ export function useProfileEvents(): UseProfileEventsReturn {
};
}, [loadProfiles, loadGroups]);
// Sync profile running states periodically to ensure consistency
// Hydrate the initial runningProfiles set from the loaded list — every
// profile that has a stored process_id is a candidate. The Rust status
// checker emits profile-running-changed for any transitions; we then
// mutate the Set incrementally instead of fan-out-polling all N profiles
// every 30s (which was O(N) sysinfo scans and saturated the runtime for
// users with hundreds of profiles).
useEffect(() => {
const syncRunningStates = async () => {
if (profiles.length === 0) return;
try {
const statusChecks = profiles.map(async (profile) => {
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
return { id: profile.id, isRunning };
} catch (error) {
console.error(
`Failed to check status for profile ${profile.name}:`,
error,
);
return { id: profile.id, isRunning: false };
}
});
const statuses = await Promise.all(statusChecks);
setRunningProfiles((prev) => {
const next = new Set(prev);
let hasChanges = false;
statuses.forEach(({ id, isRunning }) => {
if (isRunning && !prev.has(id)) {
next.add(id);
hasChanges = true;
} else if (!isRunning && prev.has(id)) {
next.delete(id);
hasChanges = true;
}
});
return hasChanges ? next : prev;
});
} catch (error) {
console.error("Failed to sync profile running states:", error);
setRunningProfiles((prev) => {
const next = new Set(prev);
for (const p of profiles) {
if (p.process_id != null) next.add(p.id);
}
};
// Initial sync
void syncRunningStates();
// Sync every 30 seconds to catch any missed events
const interval = setInterval(() => {
void syncRunningStates();
}, 30000);
return () => {
clearInterval(interval);
};
// Drop ids for profiles that no longer exist
const valid = new Set(profiles.map((p) => p.id));
for (const id of next) {
if (!valid.has(id)) next.delete(id);
}
return next;
});
}, [profiles]);
return {
+55
View File
@@ -0,0 +1,55 @@
import { type RefObject, useEffect } from "react";
/**
* Track scroll position on a vertical scroll container and write the result
* to `data-fade-top` / `data-fade-bottom` attributes on the element. The
* `.scroll-fade` CSS utility in `globals.css` reads these attributes and
* shows fade gradients only in directions that are actually scrollable.
*
* A ResizeObserver watches the container AND its direct children so internal
* content height changes (e.g. virtualizer padding rows growing/shrinking
* as the user scrolls) recompute the fade state automatically.
*/
export function useScrollFade<T extends HTMLElement>(
ref: RefObject<T | null>,
): void {
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const fadeTop = el.scrollTop > 1;
const fadeBottom = el.scrollHeight - el.clientHeight - el.scrollTop > 1;
el.setAttribute("data-fade-top", fadeTop ? "true" : "false");
el.setAttribute("data-fade-bottom", fadeBottom ? "true" : "false");
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
for (const child of Array.from(el.children)) {
ro.observe(child);
}
// MutationObserver picks up DOM additions (virtualizer mounts new rows)
// and re-attaches the ResizeObserver to the new children. Without this,
// newly inserted rows wouldn't trigger a fade recompute.
const mo = new MutationObserver(() => {
ro.disconnect();
ro.observe(el);
for (const child of Array.from(el.children)) {
ro.observe(child);
}
update();
});
mo.observe(el, { childList: true, subtree: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [ref]);
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "Save Settings",
"moreInfo": "More info",
"downloading": "Downloading...",
"minimize": "Minimize"
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved"
},
"status": {
"active": "Active",
@@ -169,7 +171,11 @@
"title": "Advanced",
"clearCache": "Clear All Version Cache",
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers.",
"clearCacheFailed": "Failed to clear cache"
"clearCacheFailed": "Failed to clear cache",
"copyLogs": "Copy logs",
"openLogDir": "Open log folder",
"copyLogsSuccess": "Logs copied to clipboard",
"copyLogsDescription": "Bundles the most recent log files (up to 5 MB) into your clipboard for sharing in bug reports."
},
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
@@ -189,7 +195,11 @@
"integrations": "Integrations",
"importProfile": "Import Profile",
"extensions": "Extensions"
}
},
"newProfile": "New",
"donutLogo": "Donut Browser logo",
"scrollGroupsLeft": "Scroll groups left",
"scrollGroupsRight": "Scroll groups right"
},
"profiles": {
"title": "Profiles",
@@ -208,7 +218,13 @@
"proxy": "Proxy / VPN",
"lastLaunch": "Last Launch",
"empty": "No profiles found.",
"notSelected": "Not Selected"
"notSelected": "Not Selected",
"ext": "EXT",
"dns": "DNS",
"extDefault": "Default",
"dnsLevel": "DNS blocklist: {{level}}",
"extSearch": "Search groups…",
"extEmpty": "No extension groups"
},
"actions": {
"launch": "Launch",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "Assign Extension Group",
"copyCookies": "Copy Cookies"
},
"passwordProtectedBadge": "Password Protected"
"passwordProtectedBadge": "Password Protected",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "Create New Profile",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "Failed to load profiles",
"unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups",
"loading": "Loading groups..."
"loading": "Loading groups...",
"all": "All"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "Login",
"linkCodeLabel": "Login code",
"linkCodePlaceholder": "Paste the code from the website",
"signInTitle": "Sign in",
"verifyAndLogin": "Verify & Log In",
"loggingIn": "Logging in...",
"connected": "Connected",
@@ -1060,13 +1081,22 @@
"lastLaunched": "Last Launched",
"hostOs": "Host OS",
"ephemeral": "Ephemeral",
"extensionGroup": "Extension Group"
"extensionGroup": "Extension Group",
"totalSessions": "Total sessions",
"syncMode": "Sync mode",
"proxy": "PROXY",
"vpn": "VPN",
"cookieCount": "Cookies stored",
"localDataTransfer": "Local data transfer"
},
"values": {
"none": "None",
"never": "Never",
"copied": "Copied!",
"yes": "Yes"
"yes": "Yes",
"activeNow": "Active now",
"direct": "Direct",
"loading": "Loading…"
},
"network": {
"bypassRules": "Proxy Bypass Rules",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "Launch Hook URL",
"label": "Launch Hook URL",
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "Donut Browser will send a GET request to this URL whenever the profile is launched.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "Enter a valid http:// or https:// URL."
},
"actions": {
"manageCookies": "Manage Cookies",
@@ -1092,6 +1123,48 @@
"description": "Enter a name for the cloned profile",
"namePlaceholder": "Profile name",
"button": "Clone"
},
"duplicate": "Duplicate",
"breadcrumbRoot": "Profile",
"openDialog": "Open settings",
"sections": {
"overview": "Overview",
"fingerprint": "Fingerprint",
"network": "Network",
"cookies": "Cookies",
"extensions": "Extensions",
"sync": "Sync",
"automation": "Automation",
"security": "Security",
"delete": "Delete profile",
"activity": "Activity",
"launchHook": "Launch hook"
},
"sectionDesc": {
"fingerprint": "Configure how this profile appears to fingerprinting scripts.",
"network": "Manage the proxy or VPN this profile uses to reach the internet.",
"cookies": "Import, copy, or wipe cookies for this profile.",
"extensions": "Choose which extensions load when this profile launches.",
"sync": "Configure how this profile is mirrored to your other devices.",
"automation": "Run a command or script every time this profile launches.",
"security": "Encrypt the profile data with a password.",
"launchHook": "Send a GET request to this URL every time the profile launches."
},
"badges": {
"locked": "LOCKED",
"active": "ACTIVE"
},
"cookies": {
"runningNotice": "Cookies can't be read while the browser is running. Close this profile first.",
"domainsHeader": "Domains ({{count}})"
},
"security": {
"protected": "This profile is encrypted with a password.",
"unprotected": "This profile is not encrypted. Set a password to encrypt its data at rest.",
"cannotWhileRunning": "Stop the profile before changing its password."
},
"fingerprint": {
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles."
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "Password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirm": "Confirm password"
"confirm": "Confirm password",
"confirmPassword": "Confirm new password"
},
"errors": {
"oldPasswordRequired": "Current password is required",
"passwordRequired": "Password is required",
"tooShort": "Password must be at least {{min}} characters",
"tooShort": "Password must be at least 8 characters",
"mismatch": "Passwords do not match"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "Important: this password is not recoverable",
"forgetWarningBody": "Donut Browser cannot reset, recover, or bypass this password. If you forget it, you will permanently lose access to this profile's data."
},
"modes": {
"set": "Set",
"change": "Change",
"remove": "Remove"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "Profile is locked. Enter the password first.",
"invalidProfileId": "Invalid profile id",
"passwordTooShort": "Password must be at least {{min}} characters",
"internal": "Something went wrong: {{detail}}"
"internal": "Something went wrong: {{detail}}",
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable."
},
"rail": {
"profiles": "Profiles",
"proxies": "Proxies",
"extensions": "Extensions",
"groups": "Groups",
"settings": "Settings",
"more": {
"label": "More",
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"vpns": "VPN configs",
"vpnsHint": "WireGuard tunnels",
"integrations": "Integrations",
"integrationsHint": "Slack, MCP, automations",
"account": "Account",
"accountHint": "Cloud, billing, sign-in"
}
},
"pageTitle": {
"proxies": "Proxies",
"extensions": "Extensions",
"groups": "Groups",
"vpns": "VPNs",
"settings": "Settings",
"integrations": "Integrations",
"account": "Account",
"import": "Import profile"
},
"encryption": {
"required": {
"title": "Sync paused — password required",
"description": "Encrypted data was downloaded but no E2E password is set on this device. Open Settings → Encryption and enter the password to resume sync.",
"openSettings": "Open Settings"
},
"rollover": {
"startedTitle": "Re-encrypting your data",
"startedDescription": "We're re-uploading every synced item under the new password. Profiles first, then proxies, groups, VPNs, and extensions.",
"progressTitle": "Re-encrypting {{stage}}",
"progressDescription": "{{done}} of {{total}}",
"completedTitle": "Re-encryption complete",
"completedDescription": "All synced data is sealed under the new password.",
"stage": {
"profiles": "profiles",
"proxies": "proxies",
"groups": "groups",
"vpns": "VPNs",
"extensions": "extensions",
"extension_groups": "extension groups"
}
}
},
"account": {
"refreshed": "Account refreshed",
"loggedOut": "Logged out",
"signedOut": "Signed out",
"signedOutDescription": "Sign in to enable cloud sync, encrypted profiles, and team features.",
"plan": "Plan: {{plan}} · {{period}}",
"refresh": "Refresh",
"logout": "Sign out",
"signIn": "Sign in",
"fields": {
"plan": "Plan",
"status": "Status",
"teamRole": "Team role",
"period": "Billing period"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "Guardar Configuración",
"moreInfo": "Más información",
"downloading": "Descargando...",
"minimize": "Minimizar"
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado"
},
"status": {
"active": "Activo",
@@ -169,7 +171,11 @@
"title": "Avanzado",
"clearCache": "Limpiar Toda la Caché de Versiones",
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores.",
"clearCacheFailed": "Error al limpiar la caché"
"clearCacheFailed": "Error al limpiar la caché",
"copyLogs": "Copiar registros",
"openLogDir": "Abrir carpeta de registros",
"copyLogsSuccess": "Registros copiados al portapapeles",
"copyLogsDescription": "Une los archivos de registro más recientes (hasta 5 MB) en tu portapapeles para compartirlos en informes de error."
},
"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.",
@@ -189,7 +195,11 @@
"integrations": "Integraciones",
"importProfile": "Importar Perfil",
"extensions": "Extensiones"
}
},
"newProfile": "Nuevo",
"donutLogo": "Logotipo de Donut Browser",
"scrollGroupsLeft": "Desplazar grupos a la izquierda",
"scrollGroupsRight": "Desplazar grupos a la derecha"
},
"profiles": {
"title": "Perfiles",
@@ -208,7 +218,13 @@
"proxy": "Proxy / VPN",
"lastLaunch": "Último Inicio",
"empty": "No se encontraron perfiles.",
"notSelected": "No seleccionado"
"notSelected": "No seleccionado",
"ext": "EXT",
"dns": "DNS",
"extDefault": "Predet.",
"dnsLevel": "Lista DNS: {{level}}",
"extSearch": "Buscar grupos…",
"extEmpty": "Sin grupos de extensiones"
},
"actions": {
"launch": "Iniciar",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "Asignar grupo de extensiones",
"copyCookies": "Copiar cookies"
},
"passwordProtectedBadge": "Protegido por Contraseña"
"passwordProtectedBadge": "Protegido por Contraseña",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "Crear Nuevo Perfil",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "Error al cargar los perfiles",
"unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos..."
"loading": "Cargando grupos...",
"all": "Todos"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "Iniciar sesión",
"linkCodeLabel": "Código de inicio de sesión",
"linkCodePlaceholder": "Pega el código del sitio web",
"signInTitle": "Iniciar Sesión",
"verifyAndLogin": "Verificar e Iniciar Sesión",
"loggingIn": "Iniciando sesión...",
"connected": "Conectado",
@@ -1060,13 +1081,22 @@
"lastLaunched": "Último Lanzamiento",
"hostOs": "SO Host",
"ephemeral": "Efímero",
"extensionGroup": "Grupo de Extensiones"
"extensionGroup": "Grupo de Extensiones",
"totalSessions": "Sesiones totales",
"syncMode": "Modo de sinc.",
"proxy": "PROXY",
"vpn": "VPN",
"cookieCount": "Cookies guardadas",
"localDataTransfer": "Transferencia de datos local"
},
"values": {
"none": "Ninguno",
"never": "Nunca",
"copied": "¡Copiado!",
"yes": "Sí"
"yes": "Sí",
"activeNow": "Activo ahora",
"direct": "Directa",
"loading": "Cargando…"
},
"network": {
"bypassRules": "Reglas de Omisión de Proxy",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "URL del hook de inicio",
"label": "URL del hook de inicio",
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "Donut Browser enviará una solicitud GET a esta URL cada vez que se inicie el perfil.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "Introduce una URL válida http:// o https://."
},
"actions": {
"manageCookies": "Administrar Cookies",
@@ -1092,6 +1123,48 @@
"description": "Ingrese un nombre para el perfil clonado",
"namePlaceholder": "Nombre del perfil",
"button": "Clonar"
},
"duplicate": "Duplicar",
"breadcrumbRoot": "Perfil",
"openDialog": "Abrir ajustes",
"sections": {
"overview": "Resumen",
"fingerprint": "Huella digital",
"network": "Red",
"cookies": "Cookies",
"extensions": "Extensiones",
"sync": "Sincronización",
"automation": "Automatización",
"security": "Seguridad",
"delete": "Eliminar perfil",
"activity": "Actividad",
"launchHook": "Hook de inicio"
},
"sectionDesc": {
"fingerprint": "Configura cómo aparece este perfil para los scripts de fingerprinting.",
"network": "Administra el proxy o la VPN que usa este perfil.",
"cookies": "Importa, copia o borra cookies de este perfil.",
"extensions": "Elige las extensiones que se cargan con este perfil.",
"sync": "Configura cómo se replica este perfil entre tus dispositivos.",
"automation": "Ejecuta un comando o script al iniciar este perfil.",
"security": "Cifra los datos del perfil con una contraseña.",
"launchHook": "Envía una solicitud GET a esta URL cada vez que se inicie el perfil."
},
"badges": {
"locked": "BLOQUEADO",
"active": "ACTIVO"
},
"cookies": {
"runningNotice": "No se pueden leer las cookies mientras el navegador está en ejecución. Cierra este perfil primero.",
"domainsHeader": "Dominios ({{count}})"
},
"security": {
"protected": "Este perfil está cifrado con una contraseña.",
"unprotected": "Este perfil no está cifrado. Establece una contraseña para cifrar sus datos.",
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
},
"fingerprint": {
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern."
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "Contraseña",
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"confirm": "Confirmar contraseña"
"confirm": "Confirmar contraseña",
"confirmPassword": "Confirmar nueva contraseña"
},
"errors": {
"oldPasswordRequired": "Se requiere la contraseña actual",
"passwordRequired": "Se requiere la contraseña",
"tooShort": "La contraseña debe tener al menos {{min}} caracteres",
"tooShort": "La contraseña debe tener al menos 8 caracteres",
"mismatch": "Las contraseñas no coinciden"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "Importante: esta contraseña no se puede recuperar",
"forgetWarningBody": "Donut Browser no puede restablecer, recuperar ni omitir esta contraseña. Si la olvidas, perderás permanentemente el acceso a los datos de este perfil."
},
"modes": {
"set": "Establecer",
"change": "Cambiar",
"remove": "Quitar"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
"invalidProfileId": "ID de perfil no válido",
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
"internal": "Algo salió mal: {{detail}}"
"internal": "Algo salió mal: {{detail}}",
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible."
},
"rail": {
"profiles": "Perfiles",
"proxies": "Proxies",
"extensions": "Extensiones",
"groups": "Grupos",
"settings": "Ajustes",
"more": {
"label": "Más",
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"vpns": "Configuraciones VPN",
"vpnsHint": "Túneles WireGuard",
"integrations": "Integraciones",
"integrationsHint": "Slack, MCP, automatizaciones",
"account": "Cuenta",
"accountHint": "Nube, facturación, sesión"
}
},
"pageTitle": {
"proxies": "Proxies",
"extensions": "Extensiones",
"groups": "Grupos",
"vpns": "VPN",
"settings": "Ajustes",
"integrations": "Integraciones",
"account": "Cuenta",
"import": "Importar perfil"
},
"encryption": {
"required": {
"title": "Sincronización en pausa — contraseña requerida",
"description": "Se descargaron datos cifrados pero no hay contraseña E2E configurada en este dispositivo. Abre Ajustes → Cifrado e introduce la contraseña para reanudar.",
"openSettings": "Abrir ajustes"
},
"rollover": {
"startedTitle": "Recifrando tus datos",
"startedDescription": "Estamos volviendo a subir cada elemento sincronizado con la nueva contraseña. Primero los perfiles, luego proxies, grupos, VPN y extensiones.",
"progressTitle": "Recifrando {{stage}}",
"progressDescription": "{{done}} de {{total}}",
"completedTitle": "Recifrado completo",
"completedDescription": "Todos los datos sincronizados están sellados con la nueva contraseña.",
"stage": {
"profiles": "perfiles",
"proxies": "proxies",
"groups": "grupos",
"vpns": "VPN",
"extensions": "extensiones",
"extension_groups": "grupos de extensiones"
}
}
},
"account": {
"refreshed": "Cuenta actualizada",
"loggedOut": "Sesión cerrada",
"signedOut": "Sin sesión",
"signedOutDescription": "Inicia sesión para activar la sincronización en la nube, perfiles cifrados y funciones de equipo.",
"plan": "Plan: {{plan}} · {{period}}",
"refresh": "Actualizar",
"logout": "Cerrar sesión",
"signIn": "Iniciar sesión",
"fields": {
"plan": "Plan",
"status": "Estado",
"teamRole": "Rol en el equipo",
"period": "Período"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "Enregistrer les paramètres",
"moreInfo": "En savoir plus",
"downloading": "Téléchargement...",
"minimize": "Réduire"
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré"
},
"status": {
"active": "Actif",
@@ -169,7 +171,11 @@
"title": "Avancé",
"clearCache": "Effacer tout le cache des versions",
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs.",
"clearCacheFailed": "Échec de la suppression du cache"
"clearCacheFailed": "Échec de la suppression du cache",
"copyLogs": "Copier les journaux",
"openLogDir": "Ouvrir le dossier des journaux",
"copyLogsSuccess": "Journaux copiés dans le presse-papiers",
"copyLogsDescription": "Regroupe les derniers fichiers de journal (jusqu’à 5 Mo) dans votre presse-papiers pour les rapports de bug."
},
"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.",
@@ -189,7 +195,11 @@
"integrations": "Intégrations",
"importProfile": "Importer un profil",
"extensions": "Extensions"
}
},
"newProfile": "Nouveau",
"donutLogo": "Logo de Donut Browser",
"scrollGroupsLeft": "Faire défiler les groupes vers la gauche",
"scrollGroupsRight": "Faire défiler les groupes vers la droite"
},
"profiles": {
"title": "Profils",
@@ -208,7 +218,13 @@
"proxy": "Proxy / VPN",
"lastLaunch": "Dernier lancement",
"empty": "Aucun profil trouvé.",
"notSelected": "Non sélectionné"
"notSelected": "Non sélectionné",
"ext": "EXT",
"dns": "DNS",
"extDefault": "Défaut",
"dnsLevel": "Liste DNS : {{level}}",
"extSearch": "Rechercher des groupes…",
"extEmpty": "Aucun groupe dextensions"
},
"actions": {
"launch": "Lancer",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "Assigner un groupe dextensions",
"copyCookies": "Copier les cookies"
},
"passwordProtectedBadge": "Protégé par mot de passe"
"passwordProtectedBadge": "Protégé par mot de passe",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "Créer un nouveau profil",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "Échec du chargement des profils",
"unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes..."
"loading": "Chargement des groupes...",
"all": "Tous"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "Se connecter",
"linkCodeLabel": "Code de connexion",
"linkCodePlaceholder": "Collez le code du site web",
"signInTitle": "Se Connecter",
"verifyAndLogin": "Vérifier et Se Connecter",
"loggingIn": "Connexion en cours...",
"connected": "Connecté",
@@ -1060,13 +1081,22 @@
"lastLaunched": "Dernier Lancement",
"hostOs": "OS Hôte",
"ephemeral": "Éphémère",
"extensionGroup": "Groupe d'Extensions"
"extensionGroup": "Groupe d'Extensions",
"totalSessions": "Sessions totales",
"syncMode": "Mode de sync.",
"proxy": "PROXY",
"vpn": "VPN",
"cookieCount": "Cookies stockés",
"localDataTransfer": "Transfert de données local"
},
"values": {
"none": "Aucun",
"never": "Jamais",
"copied": "Copié !",
"yes": "Oui"
"yes": "Oui",
"activeNow": "Actif maintenant",
"direct": "Direct",
"loading": "Chargement…"
},
"network": {
"bypassRules": "Règles de Contournement du Proxy",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "URL du hook de lancement",
"label": "URL du hook de lancement",
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "Donut Browser enverra une requête GET à cette URL à chaque lancement du profil.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "Saisissez une URL valide http:// ou https://."
},
"actions": {
"manageCookies": "Gérer les Cookies",
@@ -1092,6 +1123,48 @@
"description": "Entrez un nom pour le profil cloné",
"namePlaceholder": "Nom du profil",
"button": "Cloner"
},
"duplicate": "Dupliquer",
"breadcrumbRoot": "Profil",
"openDialog": "Ouvrir les paramètres",
"sections": {
"overview": "Aperçu",
"fingerprint": "Empreinte",
"network": "Réseau",
"cookies": "Cookies",
"extensions": "Extensions",
"sync": "Synchronisation",
"automation": "Automatisation",
"security": "Sécurité",
"delete": "Supprimer le profil",
"activity": "Activité",
"launchHook": "Hook de lancement"
},
"sectionDesc": {
"fingerprint": "Configurez lapparence de ce profil pour les scripts de fingerprinting.",
"network": "Gérez le proxy ou le VPN utilisé par ce profil.",
"cookies": "Importer, copier ou effacer les cookies de ce profil.",
"extensions": "Choisissez les extensions à charger avec ce profil.",
"sync": "Configurez la réplication de ce profil entre vos appareils.",
"automation": "Exécutez une commande au lancement de ce profil.",
"security": "Chiffrez les données du profil avec un mot de passe.",
"launchHook": "Envoie une requête GET à cette URL à chaque lancement du profil."
},
"badges": {
"locked": "VERROUILLÉ",
"active": "ACTIF"
},
"cookies": {
"runningNotice": "Impossible de lire les cookies pendant l'exécution du navigateur. Fermez d'abord ce profil.",
"domainsHeader": "Domaines ({{count}})"
},
"security": {
"protected": "Ce profil est chiffré par mot de passe.",
"unprotected": "Ce profil nest pas chiffré. Définissez un mot de passe pour chiffrer ses données.",
"cannotWhileRunning": "Arrêtez le profil avant de modifier son mot de passe."
},
"fingerprint": {
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern."
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "Mot de passe",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirm": "Confirmer le mot de passe"
"confirm": "Confirmer le mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe"
},
"errors": {
"oldPasswordRequired": "Le mot de passe actuel est requis",
"passwordRequired": "Le mot de passe est requis",
"tooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"tooShort": "Le mot de passe doit comporter au moins 8 caractères",
"mismatch": "Les mots de passe ne correspondent pas"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "Important : ce mot de passe ne peut pas être récupéré",
"forgetWarningBody": "Donut Browser ne peut ni réinitialiser, ni récupérer, ni contourner ce mot de passe. Si vous l'oubliez, vous perdrez définitivement l'accès aux données de ce profil."
},
"modes": {
"set": "Définir",
"change": "Modifier",
"remove": "Supprimer"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
"invalidProfileId": "Identifiant de profil non valide",
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"internal": "Une erreur s'est produite : {{detail}}"
"internal": "Une erreur s'est produite : {{detail}}",
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible."
},
"rail": {
"profiles": "Profils",
"proxies": "Proxys",
"extensions": "Extensions",
"groups": "Groupes",
"settings": "Paramètres",
"more": {
"label": "Plus",
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"vpns": "Configurations VPN",
"vpnsHint": "Tunnels WireGuard",
"integrations": "Intégrations",
"integrationsHint": "Slack, MCP, automatisations",
"account": "Compte",
"accountHint": "Cloud, facturation, connexion"
}
},
"pageTitle": {
"proxies": "Proxys",
"extensions": "Extensions",
"groups": "Groupes",
"vpns": "VPN",
"settings": "Paramètres",
"integrations": "Intégrations",
"account": "Compte",
"import": "Importer un profil"
},
"encryption": {
"required": {
"title": "Synchronisation en pause — mot de passe requis",
"description": "Des données chiffrées ont été téléchargées mais aucun mot de passe E2E n'est défini sur cet appareil. Ouvrez Paramètres → Chiffrement et entrez le mot de passe pour reprendre.",
"openSettings": "Ouvrir les paramètres"
},
"rollover": {
"startedTitle": "Rechiffrement de vos données",
"startedDescription": "Nous réuploadons chaque élément synchronisé avec le nouveau mot de passe. Profils d'abord, puis proxys, groupes, VPN et extensions.",
"progressTitle": "Rechiffrement {{stage}}",
"progressDescription": "{{done}} sur {{total}}",
"completedTitle": "Rechiffrement terminé",
"completedDescription": "Toutes les données synchronisées sont scellées avec le nouveau mot de passe.",
"stage": {
"profiles": "profils",
"proxies": "proxys",
"groups": "groupes",
"vpns": "VPN",
"extensions": "extensions",
"extension_groups": "groupes d'extensions"
}
}
},
"account": {
"refreshed": "Compte actualisé",
"loggedOut": "Déconnecté",
"signedOut": "Déconnecté",
"signedOutDescription": "Connectez-vous pour activer la synchronisation cloud, les profils chiffrés et les fonctionnalités d’équipe.",
"plan": "Plan : {{plan}} · {{period}}",
"refresh": "Actualiser",
"logout": "Se déconnecter",
"signIn": "Se connecter",
"fields": {
"plan": "Plan",
"status": "Statut",
"teamRole": "Rôle d’équipe",
"period": "Période"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "設定を保存",
"moreInfo": "詳細",
"downloading": "ダウンロード中...",
"minimize": "最小化"
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました"
},
"status": {
"active": "アクティブ",
@@ -169,7 +171,11 @@
"title": "詳細設定",
"clearCache": "すべてのバージョンキャッシュをクリア",
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。",
"clearCacheFailed": "キャッシュのクリアに失敗しました"
"clearCacheFailed": "キャッシュのクリアに失敗しました",
"copyLogs": "ログをコピー",
"openLogDir": "ログフォルダを開く",
"copyLogsSuccess": "ログをクリップボードにコピーしました",
"copyLogsDescription": "最新のログファイル(最大 5 MB)をクリップボードにまとめ、不具合報告で共有できるようにします。"
},
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
@@ -189,7 +195,11 @@
"integrations": "統合",
"importProfile": "プロファイルをインポート",
"extensions": "拡張機能"
}
},
"newProfile": "新規",
"donutLogo": "Donut Browser ロゴ",
"scrollGroupsLeft": "グループを左へスクロール",
"scrollGroupsRight": "グループを右へスクロール"
},
"profiles": {
"title": "プロファイル",
@@ -208,7 +218,13 @@
"proxy": "プロキシ / VPN",
"lastLaunch": "最終起動",
"empty": "プロファイルが見つかりません。",
"notSelected": "未選択"
"notSelected": "未選択",
"ext": "拡張",
"dns": "DNS",
"extDefault": "既定",
"dnsLevel": "DNS ブロックリスト: {{level}}",
"extSearch": "グループを検索…",
"extEmpty": "拡張機能グループがありません"
},
"actions": {
"launch": "起動",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "拡張機能グループを割り当て",
"copyCookies": "Cookieをコピー"
},
"passwordProtectedBadge": "パスワード保護"
"passwordProtectedBadge": "パスワード保護",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "新しいプロファイルを作成",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "プロファイルの読み込みに失敗しました",
"unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中..."
"loading": "グループを読み込み中...",
"all": "すべて"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "ログイン",
"linkCodeLabel": "ログインコード",
"linkCodePlaceholder": "ウェブサイトのコードを貼り付け",
"signInTitle": "サインイン",
"verifyAndLogin": "認証してログイン",
"loggingIn": "ログイン中...",
"connected": "接続済み",
@@ -1060,13 +1081,22 @@
"lastLaunched": "最終起動",
"hostOs": "ホストOS",
"ephemeral": "エフェメラル",
"extensionGroup": "拡張機能グループ"
"extensionGroup": "拡張機能グループ",
"totalSessions": "合計セッション",
"syncMode": "同期モード",
"proxy": "プロキシ",
"vpn": "VPN",
"cookieCount": "保存された Cookie",
"localDataTransfer": "ローカルデータ転送量"
},
"values": {
"none": "なし",
"never": "なし",
"copied": "コピーしました!",
"yes": "はい"
"yes": "はい",
"activeNow": "現在アクティブ",
"direct": "直接",
"loading": "読み込み中…"
},
"network": {
"bypassRules": "プロキシバイパスルール",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "起動フックURL",
"label": "起動フックURL",
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエスト送信ます。",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "プロファイルが起動るたびに、このURLにGETリクエスト送信されます。",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "有効な http:// または https:// URL を入力してください。"
},
"actions": {
"manageCookies": "Cookieを管理",
@@ -1092,6 +1123,48 @@
"description": "複製されたプロフィールの名前を入力してください",
"namePlaceholder": "プロフィール名",
"button": "複製"
},
"duplicate": "複製",
"breadcrumbRoot": "プロファイル",
"openDialog": "設定を開く",
"sections": {
"overview": "概要",
"fingerprint": "フィンガープリント",
"network": "ネットワーク",
"cookies": "Cookie",
"extensions": "拡張機能",
"sync": "同期",
"automation": "自動化",
"security": "セキュリティ",
"delete": "プロファイルを削除",
"activity": "アクティビティ",
"launchHook": "起動フック"
},
"sectionDesc": {
"fingerprint": "フィンガープリント対策スクリプトに対するこのプロファイルの表示を設定します。",
"network": "このプロファイルが使用するプロキシまたは VPN を管理します。",
"cookies": "このプロファイルの Cookie をインポート、コピー、消去します。",
"extensions": "このプロファイル起動時に読み込む拡張機能を選択します。",
"sync": "他のデバイス間でのこのプロファイルの同期方法を設定します。",
"automation": "このプロファイル起動時に実行するコマンドを設定します。",
"security": "プロファイルデータをパスワードで暗号化します。",
"launchHook": "プロファイルが起動するたびに、このURLにGETリクエストを送信します。"
},
"badges": {
"locked": "ロック中",
"active": "有効"
},
"cookies": {
"runningNotice": "ブラウザの実行中は Cookie を読み取れません。先にこのプロファイルを閉じてください。",
"domainsHeader": "ドメイン ({{count}})"
},
"security": {
"protected": "このプロファイルはパスワードで暗号化されています。",
"unprotected": "このプロファイルは暗号化されていません。パスワードを設定して保護してください。",
"cannotWhileRunning": "パスワードを変更する前にプロファイルを停止してください。"
},
"fingerprint": {
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。"
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "パスワード",
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"confirm": "パスワードの確認"
"confirm": "パスワードの確認",
"confirmPassword": "新しいパスワード(確認)"
},
"errors": {
"oldPasswordRequired": "現在のパスワードが必要です",
"passwordRequired": "パスワードが必要です",
"tooShort": "パスワードは {{min}} 文字以上必要です",
"tooShort": "パスワードは 8 文字以上必要です",
"mismatch": "パスワードが一致しません"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "重要: このパスワードは復元できません",
"forgetWarningBody": "Donut Browserはこのパスワードをリセット、復元、回避することはできません。忘れた場合、このプロファイルのデータへのアクセスは永続的に失われます。"
},
"modes": {
"set": "設定",
"change": "変更",
"remove": "削除"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
"invalidProfileId": "無効なプロファイルIDです",
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"internal": "問題が発生しました: {{detail}}"
"internal": "問題が発生しました: {{detail}}",
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。"
},
"rail": {
"profiles": "プロファイル",
"proxies": "プロキシ",
"extensions": "拡張機能",
"groups": "グループ",
"settings": "設定",
"more": {
"label": "その他",
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"vpns": "VPN 設定",
"vpnsHint": "WireGuard トンネル",
"integrations": "連携",
"integrationsHint": "Slack、MCP、自動化",
"account": "アカウント",
"accountHint": "クラウド、請求、サインイン"
}
},
"pageTitle": {
"proxies": "プロキシ",
"extensions": "拡張機能",
"groups": "グループ",
"vpns": "VPN",
"settings": "設定",
"integrations": "連携",
"account": "アカウント",
"import": "プロファイルをインポート"
},
"encryption": {
"required": {
"title": "同期一時停止 — パスワードが必要です",
"description": "暗号化されたデータがダウンロードされましたが、このデバイスにはE2Eパスワードが設定されていません。設定 → 暗号化を開いてパスワードを入力し、同期を再開してください。",
"openSettings": "設定を開く"
},
"rollover": {
"startedTitle": "データを再暗号化しています",
"startedDescription": "同期済みのすべての項目を新しいパスワードで再アップロードしています。最初にプロファイル、次にプロキシ、グループ、VPN、拡張機能の順です。",
"progressTitle": "{{stage}}を再暗号化中",
"progressDescription": "{{done}}/{{total}}",
"completedTitle": "再暗号化完了",
"completedDescription": "同期されたすべてのデータが新しいパスワードで封印されました。",
"stage": {
"profiles": "プロファイル",
"proxies": "プロキシ",
"groups": "グループ",
"vpns": "VPN",
"extensions": "拡張機能",
"extension_groups": "拡張機能グループ"
}
}
},
"account": {
"refreshed": "アカウントを更新しました",
"loggedOut": "ログアウトしました",
"signedOut": "未ログイン",
"signedOutDescription": "クラウド同期、暗号化プロファイル、チーム機能を有効にするにはサインインしてください。",
"plan": "プラン: {{plan}} · {{period}}",
"refresh": "更新",
"logout": "サインアウト",
"signIn": "サインイン",
"fields": {
"plan": "プラン",
"status": "ステータス",
"teamRole": "チームロール",
"period": "請求周期"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "Salvar Configurações",
"moreInfo": "Mais informações",
"downloading": "Baixando...",
"minimize": "Minimizar"
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo"
},
"status": {
"active": "Ativo",
@@ -169,7 +171,11 @@
"title": "Avançado",
"clearCache": "Limpar Todo o Cache de Versões",
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores.",
"clearCacheFailed": "Falha ao limpar o cache"
"clearCacheFailed": "Falha ao limpar o cache",
"copyLogs": "Copiar logs",
"openLogDir": "Abrir pasta de logs",
"copyLogsSuccess": "Logs copiados para a área de transferência",
"copyLogsDescription": "Junta os arquivos de log mais recentes (até 5 MB) na sua área de transferência para compartilhar em relatórios de bug."
},
"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.",
@@ -189,7 +195,11 @@
"integrations": "Integrações",
"importProfile": "Importar Perfil",
"extensions": "Extensões"
}
},
"newProfile": "Novo",
"donutLogo": "Logotipo do Donut Browser",
"scrollGroupsLeft": "Rolar grupos para a esquerda",
"scrollGroupsRight": "Rolar grupos para a direita"
},
"profiles": {
"title": "Perfis",
@@ -208,7 +218,13 @@
"proxy": "Proxy / VPN",
"lastLaunch": "Último Início",
"empty": "Nenhum perfil encontrado.",
"notSelected": "Não selecionado"
"notSelected": "Não selecionado",
"ext": "EXT",
"dns": "DNS",
"extDefault": "Padrão",
"dnsLevel": "Lista DNS: {{level}}",
"extSearch": "Pesquisar grupos…",
"extEmpty": "Sem grupos de extensões"
},
"actions": {
"launch": "Iniciar",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "Atribuir grupo de extensões",
"copyCookies": "Copiar cookies"
},
"passwordProtectedBadge": "Protegido por Senha"
"passwordProtectedBadge": "Protegido por Senha",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "Criar Novo Perfil",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "Falha ao carregar os perfis",
"unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos..."
"loading": "Carregando grupos...",
"all": "Todos"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "Entrar",
"linkCodeLabel": "Código de login",
"linkCodePlaceholder": "Cole o código do site",
"signInTitle": "Entrar",
"verifyAndLogin": "Verificar e Entrar",
"loggingIn": "Entrando...",
"connected": "Conectado",
@@ -1060,13 +1081,22 @@
"lastLaunched": "Último Lançamento",
"hostOs": "SO Host",
"ephemeral": "Efêmero",
"extensionGroup": "Grupo de Extensões"
"extensionGroup": "Grupo de Extensões",
"totalSessions": "Sessões totais",
"syncMode": "Modo de sinc.",
"proxy": "PROXY",
"vpn": "VPN",
"cookieCount": "Cookies armazenados",
"localDataTransfer": "Transferência de dados local"
},
"values": {
"none": "Nenhum",
"never": "Nunca",
"copied": "Copiado!",
"yes": "Sim"
"yes": "Sim",
"activeNow": "Ativo agora",
"direct": "Direto",
"loading": "Carregando…"
},
"network": {
"bypassRules": "Regras de Bypass de Proxy",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "URL do hook de inicialização",
"label": "URL do hook de inicialização",
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "O Donut Browser enviará uma requisição GET para esta URL toda vez que o perfil for iniciado.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "Insira uma URL válida http:// ou https://."
},
"actions": {
"manageCookies": "Gerenciar Cookies",
@@ -1092,6 +1123,48 @@
"description": "Digite um nome para o perfil clonado",
"namePlaceholder": "Nome do perfil",
"button": "Clonar"
},
"duplicate": "Duplicar",
"breadcrumbRoot": "Perfil",
"openDialog": "Abrir configurações",
"sections": {
"overview": "Visão geral",
"fingerprint": "Impressão digital",
"network": "Rede",
"cookies": "Cookies",
"extensions": "Extensões",
"sync": "Sincronização",
"automation": "Automação",
"security": "Segurança",
"delete": "Excluir perfil",
"activity": "Atividade",
"launchHook": "Hook de inicialização"
},
"sectionDesc": {
"fingerprint": "Configure como este perfil aparece para scripts de fingerprinting.",
"network": "Gerencie o proxy ou VPN usado por este perfil.",
"cookies": "Importe, copie ou apague cookies deste perfil.",
"extensions": "Escolha quais extensões carregar com este perfil.",
"sync": "Configure como este perfil é espelhado entre seus dispositivos.",
"automation": "Execute um comando ao iniciar este perfil.",
"security": "Criptografe os dados do perfil com uma senha.",
"launchHook": "Envia uma requisição GET para esta URL toda vez que o perfil é iniciado."
},
"badges": {
"locked": "BLOQUEADO",
"active": "ATIVO"
},
"cookies": {
"runningNotice": "Não é possível ler cookies enquanto o navegador está em execução. Feche este perfil primeiro.",
"domainsHeader": "Domínios ({{count}})"
},
"security": {
"protected": "Este perfil está criptografado com senha.",
"unprotected": "Este perfil não está criptografado. Defina uma senha para criptografá-lo.",
"cannotWhileRunning": "Pare o perfil antes de alterar a senha."
},
"fingerprint": {
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern."
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "Senha",
"currentPassword": "Senha atual",
"newPassword": "Nova senha",
"confirm": "Confirmar senha"
"confirm": "Confirmar senha",
"confirmPassword": "Confirmar nova senha"
},
"errors": {
"oldPasswordRequired": "A senha atual é obrigatória",
"passwordRequired": "A senha é obrigatória",
"tooShort": "A senha deve ter pelo menos {{min}} caracteres",
"tooShort": "A senha deve ter pelo menos 8 caracteres",
"mismatch": "As senhas não coincidem"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "Importante: esta senha não pode ser recuperada",
"forgetWarningBody": "O Donut Browser não pode redefinir, recuperar ou contornar esta senha. Se você esquecê-la, perderá permanentemente o acesso aos dados deste perfil."
},
"modes": {
"set": "Definir",
"change": "Alterar",
"remove": "Remover"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
"invalidProfileId": "ID de perfil inválido",
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
"internal": "Algo deu errado: {{detail}}"
"internal": "Algo deu errado: {{detail}}",
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível."
},
"rail": {
"profiles": "Perfis",
"proxies": "Proxies",
"extensions": "Extensões",
"groups": "Grupos",
"settings": "Configurações",
"more": {
"label": "Mais",
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"vpns": "Configurações VPN",
"vpnsHint": "Túneis WireGuard",
"integrations": "Integrações",
"integrationsHint": "Slack, MCP, automações",
"account": "Conta",
"accountHint": "Nuvem, cobrança, login"
}
},
"pageTitle": {
"proxies": "Proxies",
"extensions": "Extensões",
"groups": "Grupos",
"vpns": "VPN",
"settings": "Configurações",
"integrations": "Integrações",
"account": "Conta",
"import": "Importar perfil"
},
"encryption": {
"required": {
"title": "Sincronização pausada — senha necessária",
"description": "Dados criptografados foram baixados, mas nenhuma senha E2E está configurada neste dispositivo. Abra Configurações → Criptografia e insira a senha para retomar a sincronização.",
"openSettings": "Abrir configurações"
},
"rollover": {
"startedTitle": "Recriptografando seus dados",
"startedDescription": "Estamos reenviando cada item sincronizado com a nova senha. Primeiro os perfis, depois proxies, grupos, VPNs e extensões.",
"progressTitle": "Recriptografando {{stage}}",
"progressDescription": "{{done}} de {{total}}",
"completedTitle": "Recriptografia concluída",
"completedDescription": "Todos os dados sincronizados estão selados com a nova senha.",
"stage": {
"profiles": "perfis",
"proxies": "proxies",
"groups": "grupos",
"vpns": "VPNs",
"extensions": "extensões",
"extension_groups": "grupos de extensões"
}
}
},
"account": {
"refreshed": "Conta atualizada",
"loggedOut": "Sessão encerrada",
"signedOut": "Sem sessão",
"signedOutDescription": "Entre para ativar sincronização na nuvem, perfis criptografados e recursos de equipe.",
"plan": "Plano: {{plan}} · {{period}}",
"refresh": "Atualizar",
"logout": "Sair",
"signIn": "Entrar",
"fields": {
"plan": "Plano",
"status": "Status",
"teamRole": "Função na equipe",
"period": "Período"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "Сохранить настройки",
"moreInfo": "Подробнее",
"downloading": "Загрузка...",
"minimize": "Свернуть"
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено"
},
"status": {
"active": "Активен",
@@ -169,7 +171,11 @@
"title": "Дополнительно",
"clearCache": "Очистить весь кэш версий",
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров.",
"clearCacheFailed": "Не удалось очистить кэш"
"clearCacheFailed": "Не удалось очистить кэш",
"copyLogs": "Скопировать логи",
"openLogDir": "Открыть папку логов",
"copyLogsSuccess": "Логи скопированы в буфер обмена",
"copyLogsDescription": "Собирает последние файлы логов (до 5 МБ) в буфер обмена для прикрепления к багам."
},
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
@@ -189,7 +195,11 @@
"integrations": "Интеграции",
"importProfile": "Импорт профиля",
"extensions": "Расширения"
}
},
"newProfile": "Новый",
"donutLogo": "Логотип Donut Browser",
"scrollGroupsLeft": "Прокрутить группы влево",
"scrollGroupsRight": "Прокрутить группы вправо"
},
"profiles": {
"title": "Профили",
@@ -208,7 +218,13 @@
"proxy": "Прокси / VPN",
"lastLaunch": "Последний запуск",
"empty": "Профили не найдены.",
"notSelected": "Не выбрано"
"notSelected": "Не выбрано",
"ext": "РАСШ",
"dns": "DNS",
"extDefault": "По умолч.",
"dnsLevel": "DNS-блок-лист: {{level}}",
"extSearch": "Поиск групп…",
"extEmpty": "Нет групп расширений"
},
"actions": {
"launch": "Запустить",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "Назначить группу расширений",
"copyCookies": "Копировать cookies"
},
"passwordProtectedBadge": "Защищено паролем"
"passwordProtectedBadge": "Защищено паролем",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "Создать новый профиль",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "Не удалось загрузить профили",
"unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп..."
"loading": "Загрузка групп...",
"all": "Все"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "Войти",
"linkCodeLabel": "Код входа",
"linkCodePlaceholder": "Вставьте код с сайта",
"signInTitle": "Войти",
"verifyAndLogin": "Подтвердить и Войти",
"loggingIn": "Вход...",
"connected": "Подключено",
@@ -1060,13 +1081,22 @@
"lastLaunched": "Последний запуск",
"hostOs": "ОС хоста",
"ephemeral": "Эфемерный",
"extensionGroup": "Группа расширений"
"extensionGroup": "Группа расширений",
"totalSessions": "Всего сессий",
"syncMode": "Режим синх.",
"proxy": "ПРОКСИ",
"vpn": "VPN",
"cookieCount": "Хранится Cookie",
"localDataTransfer": "Локальный трафик"
},
"values": {
"none": "Нет",
"never": "Никогда",
"copied": "Скопировано!",
"yes": "Да"
"yes": "Да",
"activeNow": "Сейчас активен",
"direct": "Без прокси",
"loading": "Загрузка…"
},
"network": {
"bypassRules": "Правила обхода прокси",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "URL хука запуска",
"label": "URL хука запуска",
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "Donut Browser будет отправлять GET-запрос на этот URL при каждом запуске профиля.",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "Введите корректный URL http:// или https://."
},
"actions": {
"manageCookies": "Управление Cookie",
@@ -1092,6 +1123,48 @@
"description": "Введите имя для клонированного профиля",
"namePlaceholder": "Имя профиля",
"button": "Клонировать"
},
"duplicate": "Дублировать",
"breadcrumbRoot": "Профиль",
"openDialog": "Открыть настройки",
"sections": {
"overview": "Обзор",
"fingerprint": "Отпечаток",
"network": "Сеть",
"cookies": "Cookie",
"extensions": "Расширения",
"sync": "Синхронизация",
"automation": "Автоматизация",
"security": "Безопасность",
"delete": "Удалить профиль",
"activity": "Активность",
"launchHook": "Хук запуска"
},
"sectionDesc": {
"fingerprint": "Настройте, как этот профиль выглядит для скриптов отпечатков.",
"network": "Управляйте прокси или VPN, используемым этим профилем.",
"cookies": "Импортируйте, копируйте или удаляйте cookie этого профиля.",
"extensions": "Выберите, какие расширения загружать с этим профилем.",
"sync": "Настройте репликацию этого профиля между устройствами.",
"automation": "Запускайте команду при старте этого профиля.",
"security": "Зашифруйте данные профиля паролем.",
"launchHook": "Отправлять GET-запрос на этот URL при каждом запуске профиля."
},
"badges": {
"locked": "ЗАБЛОК.",
"active": "АКТИВЕН"
},
"cookies": {
"runningNotice": "Куки нельзя прочитать, пока браузер запущен. Сначала закройте этот профиль.",
"domainsHeader": "Домены ({{count}})"
},
"security": {
"protected": "Этот профиль зашифрован паролем.",
"unprotected": "Этот профиль не зашифрован. Задайте пароль, чтобы зашифровать его данные.",
"cannotWhileRunning": "Остановите профиль перед сменой пароля."
},
"fingerprint": {
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern."
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "Пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirm": "Подтвердите пароль"
"confirm": "Подтвердите пароль",
"confirmPassword": "Подтвердите новый пароль"
},
"errors": {
"oldPasswordRequired": "Требуется текущий пароль",
"passwordRequired": "Требуется пароль",
"tooShort": "Пароль должен быть не короче {{min}} символов",
"tooShort": "Пароль должен содержать не менее 8 символов",
"mismatch": "Пароли не совпадают"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "Важно: пароль восстановить нельзя",
"forgetWarningBody": "Donut Browser не может сбросить, восстановить или обойти этот пароль. Если вы его забудете, доступ к данным этого профиля будет утрачен навсегда."
},
"modes": {
"set": "Задать",
"change": "Изменить",
"remove": "Удалить"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
"invalidProfileId": "Недействительный идентификатор профиля",
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"internal": "Что-то пошло не так: {{detail}}"
"internal": "Что-то пошло не так: {{detail}}",
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно."
},
"rail": {
"profiles": "Профили",
"proxies": "Прокси",
"extensions": "Расширения",
"groups": "Группы",
"settings": "Настройки",
"more": {
"label": "Ещё",
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"vpns": "Конфигурации VPN",
"vpnsHint": "WireGuard-туннели",
"integrations": "Интеграции",
"integrationsHint": "Slack, MCP, автоматизации",
"account": "Аккаунт",
"accountHint": "Облако, оплата, вход"
}
},
"pageTitle": {
"proxies": "Прокси",
"extensions": "Расширения",
"groups": "Группы",
"vpns": "VPN",
"settings": "Настройки",
"integrations": "Интеграции",
"account": "Аккаунт",
"import": "Импорт профиля"
},
"encryption": {
"required": {
"title": "Синхронизация приостановлена — нужен пароль",
"description": "Загружены зашифрованные данные, но на этом устройстве не задан E2E-пароль. Откройте Настройки → Шифрование и введите пароль, чтобы продолжить синхронизацию.",
"openSettings": "Открыть настройки"
},
"rollover": {
"startedTitle": "Перешифровываем ваши данные",
"startedDescription": "Мы заново загружаем каждый синхронизированный элемент под новым паролем. Сначала профили, затем прокси, группы, VPN и расширения.",
"progressTitle": "Перешифровка: {{stage}}",
"progressDescription": "{{done}} из {{total}}",
"completedTitle": "Перешифровка завершена",
"completedDescription": "Все синхронизированные данные запечатаны новым паролем.",
"stage": {
"profiles": "профили",
"proxies": "прокси",
"groups": "группы",
"vpns": "VPN",
"extensions": "расширения",
"extension_groups": "группы расширений"
}
}
},
"account": {
"refreshed": "Аккаунт обновлён",
"loggedOut": "Вы вышли",
"signedOut": "Не выполнен вход",
"signedOutDescription": "Войдите, чтобы включить облачную синхронизацию, зашифрованные профили и командные функции.",
"plan": "Тариф: {{plan}} · {{period}}",
"refresh": "Обновить",
"logout": "Выйти",
"signIn": "Войти",
"fields": {
"plan": "Тариф",
"status": "Статус",
"teamRole": "Роль в команде",
"period": "Период"
}
}
}
+163 -13
View File
@@ -30,7 +30,9 @@
"saveSettings": "保存设置",
"moreInfo": "了解更多",
"downloading": "下载中...",
"minimize": "最小化"
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存"
},
"status": {
"active": "活跃",
@@ -169,7 +171,11 @@
"title": "高级",
"clearCache": "清除所有版本缓存",
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。",
"clearCacheFailed": "清除缓存失败"
"clearCacheFailed": "清除缓存失败",
"copyLogs": "复制日志",
"openLogDir": "打开日志文件夹",
"copyLogsSuccess": "日志已复制到剪贴板",
"copyLogsDescription": "将最近的日志文件(最多 5 MB)合并到剪贴板,便于在反馈问题时分享。"
},
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
@@ -189,7 +195,11 @@
"integrations": "集成",
"importProfile": "导入配置文件",
"extensions": "扩展程序"
}
},
"newProfile": "新建",
"donutLogo": "Donut Browser 标识",
"scrollGroupsLeft": "向左滚动分组",
"scrollGroupsRight": "向右滚动分组"
},
"profiles": {
"title": "配置文件",
@@ -208,7 +218,13 @@
"proxy": "代理 / VPN",
"lastLaunch": "最后启动",
"empty": "未找到配置文件。",
"notSelected": "未选择"
"notSelected": "未选择",
"ext": "扩展",
"dns": "DNS",
"extDefault": "默认",
"dnsLevel": "DNS 屏蔽列表: {{level}}",
"extSearch": "搜索分组…",
"extEmpty": "没有扩展组"
},
"actions": {
"launch": "启动",
@@ -270,7 +286,10 @@
"assignExtensionGroup": "分配扩展分组",
"copyCookies": "复制 Cookie"
},
"passwordProtectedBadge": "密码保护"
"passwordProtectedBadge": "密码保护",
"launchHook": {
"placeholder": "https://example.com/track-launch"
}
},
"createProfile": {
"title": "创建新配置文件",
@@ -508,7 +527,8 @@
"loadProfilesFailed": "加载配置文件失败",
"unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组..."
"loading": "正在加载组...",
"all": "全部"
},
"sync": {
"mode": {
@@ -560,6 +580,7 @@
"openLogin": "登录",
"linkCodeLabel": "登录代码",
"linkCodePlaceholder": "粘贴网站的代码",
"signInTitle": "登录",
"verifyAndLogin": "验证并登录",
"loggingIn": "登录中...",
"connected": "已连接",
@@ -1060,13 +1081,22 @@
"lastLaunched": "上次启动",
"hostOs": "主机操作系统",
"ephemeral": "临时",
"extensionGroup": "扩展程序组"
"extensionGroup": "扩展程序组",
"totalSessions": "总会话",
"syncMode": "同步模式",
"proxy": "代理",
"vpn": "VPN",
"cookieCount": "存储的 Cookie",
"localDataTransfer": "本地数据传输"
},
"values": {
"none": "无",
"never": "从未",
"copied": "已复制!",
"yes": "是"
"yes": "是",
"activeNow": "当前活动",
"direct": "直连",
"loading": "加载中…"
},
"network": {
"bypassRules": "代理绕过规则",
@@ -1080,8 +1110,9 @@
"launchHook": {
"title": "启动钩子 URL",
"label": "启动钩子 URL",
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
"placeholder": "https://example.com/hooks/profile-launch"
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送一个 GET 请求。",
"placeholder": "https://example.com/hooks/profile-launch",
"invalidUrlHint": "请输入有效的 http:// 或 https:// URL。"
},
"actions": {
"manageCookies": "管理 Cookie",
@@ -1092,6 +1123,48 @@
"description": "输入克隆配置文件的名称",
"namePlaceholder": "配置文件名称",
"button": "克隆"
},
"duplicate": "复制",
"breadcrumbRoot": "配置文件",
"openDialog": "打开设置",
"sections": {
"overview": "概览",
"fingerprint": "指纹",
"network": "网络",
"cookies": "Cookie",
"extensions": "扩展",
"sync": "同步",
"automation": "自动化",
"security": "安全",
"delete": "删除配置文件",
"activity": "活动",
"launchHook": "启动钩子"
},
"sectionDesc": {
"fingerprint": "配置此配置文件如何对指纹脚本显示。",
"network": "管理此配置文件使用的代理或 VPN。",
"cookies": "导入、复制或清除此配置文件的 Cookie。",
"extensions": "选择启动此配置文件时加载的扩展。",
"sync": "配置此配置文件如何在设备间同步。",
"automation": "在启动此配置文件时运行命令。",
"security": "用密码加密配置文件数据。",
"launchHook": "每次启动配置文件时向此 URL 发送一个 GET 请求。"
},
"badges": {
"locked": "已锁",
"active": "已启用"
},
"cookies": {
"runningNotice": "浏览器运行时无法读取 Cookie。请先关闭此配置文件。",
"domainsHeader": "域 ({{count}})"
},
"security": {
"protected": "此配置文件已用密码加密。",
"unprotected": "此配置文件未加密。设置密码以加密其数据。",
"cannotWhileRunning": "更改密码前请先停止此配置文件。"
},
"fingerprint": {
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。"
}
},
"extensions": {
@@ -1625,12 +1698,13 @@
"password": "密码",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirm": "确认密码"
"confirm": "确认密码",
"confirmPassword": "确认新密码"
},
"errors": {
"oldPasswordRequired": "需要当前密码",
"passwordRequired": "需要密码",
"tooShort": "密码至少需要 {{min}} 个字符",
"tooShort": "密码至少需要 8 个字符",
"mismatch": "密码不匹配"
},
"toasts": {
@@ -1641,6 +1715,11 @@
"warnings": {
"forgetWarningTitle": "重要:此密码无法恢复",
"forgetWarningBody": "Donut Browser 无法重置、恢复或绕过此密码。如果忘记,您将永久无法访问此配置文件的数据。"
},
"modes": {
"set": "设置",
"change": "更改",
"remove": "删除"
}
},
"backendErrors": {
@@ -1659,6 +1738,77 @@
"profileLocked": "配置文件已锁定。请先输入密码。",
"invalidProfileId": "配置文件 ID 无效",
"passwordTooShort": "密码至少需要 {{min}} 个字符",
"internal": "出现问题:{{detail}}"
"internal": "出现问题:{{detail}}",
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。"
},
"rail": {
"profiles": "配置文件",
"proxies": "代理",
"extensions": "扩展",
"groups": "分组",
"settings": "设置",
"more": {
"label": "更多",
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"vpns": "VPN 配置",
"vpnsHint": "WireGuard 隧道",
"integrations": "集成",
"integrationsHint": "Slack、MCP、自动化",
"account": "账户",
"accountHint": "云、订阅、登录"
}
},
"pageTitle": {
"proxies": "代理",
"extensions": "扩展",
"groups": "分组",
"vpns": "VPN",
"settings": "设置",
"integrations": "集成",
"account": "账户",
"import": "导入配置文件"
},
"encryption": {
"required": {
"title": "同步已暂停 — 需要密码",
"description": "已下载加密数据,但此设备未设置 E2E 密码。打开 设置 → 加密 并输入密码以继续同步。",
"openSettings": "打开设置"
},
"rollover": {
"startedTitle": "正在重新加密您的数据",
"startedDescription": "我们正在使用新密码重新上传每个已同步项。先是配置文件,然后是代理、分组、VPN 和扩展。",
"progressTitle": "重新加密 {{stage}}",
"progressDescription": "{{done}} / {{total}}",
"completedTitle": "重新加密完成",
"completedDescription": "所有已同步的数据都已使用新密码封装。",
"stage": {
"profiles": "配置文件",
"proxies": "代理",
"groups": "分组",
"vpns": "VPN",
"extensions": "扩展",
"extension_groups": "扩展组"
}
}
},
"account": {
"refreshed": "账户已刷新",
"loggedOut": "已退出",
"signedOut": "未登录",
"signedOutDescription": "登录以启用云同步、加密配置文件和团队功能。",
"plan": "套餐:{{plan}} · {{period}}",
"refresh": "刷新",
"logout": "退出",
"signIn": "登录",
"fields": {
"plan": "套餐",
"status": "状态",
"teamRole": "团队角色",
"period": "计费周期"
}
}
}
+9
View File
@@ -15,6 +15,9 @@ export type BackendErrorCode =
| "PROFILE_LOCKED"
| "INVALID_PROFILE_ID"
| "PASSWORD_TOO_SHORT"
| "INVALID_LAUNCH_HOOK_URL"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -81,6 +84,12 @@ export function translateBackendError(t: TFunction, err: unknown): string {
const min = Number.parseInt(parsed.params?.min ?? "8", 10);
return t("backendErrors.passwordTooShort", { min });
}
case "INVALID_LAUNCH_HOOK_URL":
return t("backendErrors.invalidLaunchHookUrl");
case "COOKIE_DB_LOCKED":
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
return t("backendErrors.cookieDbUnavailable");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+32
View File
@@ -34,6 +34,38 @@ export interface Theme {
}
export const THEMES: Theme[] = [
{
id: "donut-mono",
name: "Donut Mono",
colors: {
"--background": "#070707",
"--foreground": "#ffffff",
"--card": "#0e0e0e",
"--card-foreground": "#e4e4e4",
"--popover": "#0e0e0e",
"--popover-foreground": "#e4e4e4",
"--primary": "#ffffff",
"--primary-foreground": "#070707",
"--secondary": "#161616",
"--secondary-foreground": "#e4e4e4",
"--muted": "#161616",
"--muted-foreground": "#a0a0a0",
"--accent": "#1f1f1f",
"--accent-foreground": "#ffffff",
"--destructive": "#ec6a5e",
"--destructive-foreground": "#070707",
"--success": "#61c554",
"--success-foreground": "#070707",
"--warning": "#f4be4f",
"--warning-foreground": "#070707",
"--border": "rgba(255,255,255,0.06)",
"--chart-1": "#a0a0a0",
"--chart-2": "#6b6b6b",
"--chart-3": "#444444",
"--chart-4": "#e4e4e4",
"--chart-5": "#ffffff",
},
},
{
id: "tokyo-night",
name: "Tokyo Night",
+54 -23
View File
@@ -97,29 +97,30 @@
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.7 0.2 145);
--success-foreground: oklch(0.141 0.005 285.823);
--warning: oklch(0.8 0.15 75);
--warning-foreground: oklch(0.141 0.005 285.823);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
/* Donut Mono — redesign monochrome palette */
--background: #070707;
--foreground: #ffffff;
--card: #0e0e0e;
--card-foreground: #e4e4e4;
--popover: #0e0e0e;
--popover-foreground: #e4e4e4;
--primary: #ffffff;
--primary-foreground: #070707;
--secondary: #161616;
--secondary-foreground: #e4e4e4;
--muted: #161616;
--muted-foreground: #a0a0a0;
--accent: #1f1f1f;
--accent-foreground: #ffffff;
--destructive: #ec6a5e;
--destructive-foreground: #070707;
--success: #61c554;
--success-foreground: #070707;
--warning: #f4be4f;
--warning-foreground: #070707;
--border: rgba(255, 255, 255, 0.06);
--input: rgba(255, 255, 255, 0.1);
--ring: #6b6b6b;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -156,6 +157,36 @@
}
}
/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to
transparent ONLY when the matching direction is scrollable. The component
sets `data-fade-top` / `data-fade-bottom` attributes on its container as
the user scrolls; each attribute toggles its own end of the mask via a
CSS variable, so the two edges are independent. */
.scroll-fade {
--top-mask: black;
--bottom-mask: black;
-webkit-mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
var(--bottom-mask)
);
mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
var(--bottom-mask)
);
}
.scroll-fade[data-fade-top="true"] {
--top-mask: transparent;
}
.scroll-fade[data-fade-bottom="true"] {
--bottom-mask: transparent;
}
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
.toaster,
[data-sonner-toaster] {