Compare commits

...

14 Commits

Author SHA1 Message Date
zhom 2e891dd9ec chore: version bump 2026-05-16 02:43:17 +04:00
zhom e5361b6905 fix: camoufox proxy pid connection 2026-05-16 02:41:28 +04:00
zhom f6daa642d0 refactor: browser update 2026-05-15 20:42:25 +04:00
zhom c84d547a8c feat: more mcp integrations 2026-05-15 19:59:44 +04:00
zhom c8a43b43f1 refactor: ui cleanup 2026-05-15 15:44:20 +04:00
zhom 56b0da990b refactor: cleanup 2026-05-14 20:04:19 +04:00
zhom 597efb7e58 chore: cleanup 2026-05-14 20:03:22 +04:00
github-actions[bot] ba72e4cb3b chore: update flake.nix for v0.24.1 [skip ci] (#364)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:28 +00:00
github-actions[bot] c2ace4b8d3 docs: update CHANGELOG.md and README.md for v0.24.1 [skip ci] (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:10 +00:00
zhom 35a874ead0 chore: version bump 2026-05-12 20:52:10 +04:00
zhom f02397dba9 refactor: creation button disaster recovery 2026-05-12 20:50:29 +04:00
github-actions[bot] d5752633c8 chore: update flake.nix for v0.24.0 [skip ci] (#357)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:02:08 +00:00
github-actions[bot] 5752260018 docs: update CHANGELOG.md and README.md for v0.24.0 [skip ci] (#356)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 11:01:52 +00:00
zhom 405d7c5716 fix: pass correct parameter for dns list selection 2026-05-12 13:17:29 +04:00
84 changed files with 5864 additions and 2332 deletions
+15 -7
View File
@@ -159,10 +159,14 @@ jobs:
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/`
- Linux: `~/.local/share/com.donutbrowser/logs/`
- Windows: `%APPDATA%\com.donutbrowser\logs\`
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
@@ -352,10 +356,14 @@ jobs:
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Use ONLY the one matching `triage.operating_system`:
- macos: `~/Library/Logs/com.donutbrowser/`
- linux: `~/.local/share/com.donutbrowser/logs/`
- windows: `%APPDATA%\com.donutbrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\com.donutbrowser\logs`)
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
+62
View File
@@ -69,6 +69,58 @@ donutbrowser/
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all seven locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
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",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
## Singletons
@@ -93,6 +145,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
+39
View File
@@ -1,6 +1,45 @@
# Changelog
## v0.24.1 (2026-05-12)
### Refactoring
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
+5 -5
View File
@@ -48,7 +48,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
Or install via Homebrew:
@@ -58,15 +58,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut-0.23.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.23.0";
releaseVersion = "0.24.1";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_amd64.AppImage";
hash = "sha256-bcdZOV1Vj7H9BxlYKUUtGZprrA80283J34xb3NslRjg=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.23.0/Donut_0.23.0_aarch64.AppImage";
hash = "sha256-IbGvqHMxwYHFj6dFP07MhFl00aiHVont+KoZck+HIvk=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.0",
"version": "0.24.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+2 -1
View File
@@ -1784,7 +1784,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.0"
version = "0.24.2"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -1858,6 +1858,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.0"
version = "0.24.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -100,6 +100,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] }
serde_yaml = "0.9"
toml = "0.9"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
+8 -2
View File
@@ -1582,7 +1582,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1924,7 +1927,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
+7 -6
View File
@@ -1215,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
CLOUD_AUTH.logout().await?;
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
// Always clear the stored sync URL and token on cloud logout. While the
// user was signed in, the cloud auth flow populated these with the hosted
// sync server's URL + a server-issued token — leaving them in place would
// pre-fill the Self-Hosted tab with our production URL and a token the
// user never typed. The cloud-URL-only check we used to do here missed
// trailing-slash / scheme variants and any future cloud endpoint moves.
let manager = crate::settings_manager::SettingsManager::instance();
if let Ok(sync_settings) = manager.get_sync_settings() {
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
let _ = manager.save_sync_server_url(None);
}
}
let _ = manager.save_sync_server_url(None);
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
+30 -9
View File
@@ -290,24 +290,45 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
// For each browser where every registered version would be removed (no
// profile uses any), keep the newest one by semver. Without this, the
// version preserved depends on HashMap iteration order, so a freshly
// downloaded version can be deleted in favor of an older orphan — leaving
// the UI stuck on "needs to be downloaded".
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
for (browser, version) in &to_remove {
removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
}
to_remove.retain(|(browser, version)| {
let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
if versions.len() >= total {
if let Some(latest) = versions
.iter()
.max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false;
}
true
+3 -13
View File
@@ -268,7 +268,9 @@ impl GroupManager {
}
}
// Create result including all groups (even those with 0 count)
// Create result including all groups (even those with 0 count).
// The "Default" pseudo-group is intentionally not returned: profiles
// without a group_id are surfaced through the "All" filter instead.
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +283,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+60 -97
View File
@@ -52,6 +52,7 @@ pub mod daemon_client;
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
mod tag_manager;
mod team_lock;
@@ -74,7 +75,7 @@ use profile::manager::{
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
@@ -103,6 +104,7 @@ use sync::{
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,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -503,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
}
}
#[tauri::command]
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
Ok(dir.join("manifest.json").exists())
fn is_mcp_in_claude_desktop_internal() -> bool {
let Some(dir) = claude_desktop_extension_dir() else {
return false;
};
dir.join("manifest.json").exists()
}
#[tauri::command]
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
@@ -605,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(())
}
#[tauri::command]
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -668,91 +669,48 @@ fn update_claude_extensions_registry(
Ok(())
}
fn find_claude_cli() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
// `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
#[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
Ok(mcp_integrations::list_agents_with_status(&[(
"claude-desktop",
claude_desktop_connected,
)]))
}
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
Ok(())
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
}
#[tauri::command]
@@ -1822,6 +1780,19 @@ pub fn run() {
);
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
// Must run BEFORE `mark_profile_stopped` because that
// releases any queued sync run, and a sync that picks up
// the on-disk dir before re-encryption finishes uploads
// the previous snapshot (issue: encrypted profiles not
// syncing fresh data).
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit_and_wait(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -1832,13 +1803,6 @@ pub fn run() {
}
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit(&profile);
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -2106,6 +2070,7 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
@@ -2123,12 +2088,9 @@ pub fn run() {
stop_mcp_server,
get_mcp_server_status,
get_mcp_config,
is_mcp_in_claude_desktop,
add_mcp_to_claude_desktop,
remove_mcp_from_claude_desktop,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
list_mcp_agents,
add_mcp_to_agent,
remove_mcp_from_agent,
// VPN commands
import_vpn_config,
list_vpn_configs,
@@ -2171,6 +2133,7 @@ pub fn run() {
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+102 -6
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -396,7 +410,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -421,8 +435,26 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
@@ -431,7 +463,7 @@ impl ProfileManager {
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
let _ = atomic_write(&metadata_file, json.as_bytes());
}
}
}
@@ -473,6 +505,8 @@ impl ProfileManager {
// Save profile with new name
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -678,6 +712,8 @@ impl ProfileManager {
profile.group_id = group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
@@ -732,6 +768,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -766,6 +804,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -792,6 +832,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -821,6 +863,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -845,6 +889,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -1060,6 +1106,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,6 +1168,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1174,6 +1224,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1263,6 +1315,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
@@ -1300,6 +1354,8 @@ impl ProfileManager {
profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
@@ -1453,13 +1509,18 @@ impl ProfileManager {
};
let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
@@ -1467,6 +1528,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
}
// Emit profile update event to frontend
@@ -1481,7 +1551,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1510,10 +1580,14 @@ impl ProfileManager {
};
if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1555,6 +1629,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1591,6 +1671,12 @@ impl ProfileManager {
);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
// Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1605,7 +1691,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1634,10 +1720,14 @@ impl ProfileManager {
};
if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1679,6 +1769,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
+56 -10
View File
@@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?;
cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
/// Verify a profile password without unlocking. Used by the Settings UI's
/// "Validate" button so users can confirm they remember the password without
/// performing a destructive change. Honors the same lockout schedule as
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
/// hammering this command.
#[tauri::command]
pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
let profile = load_profile(&id)?;
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
let salt = profile
.encryption_salt
.as_deref()
.ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?;
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
let dir = profile_data_dir(&profile);
match verify_key_against_dir(&key, &dir) {
Ok(()) => {
clear_failed_attempts(&id);
Ok(())
}
Err(crate::profile::encryption::PasswordError::WrongPassword) => {
record_failed_attempt(id);
Err(err_code("INCORRECT_PASSWORD"))
}
Err(other) => Err(err_internal(other)),
}
}
#[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id);
cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?;
drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -637,22 +674,31 @@ pub fn complete_after_quit_blocking(
result
}
/// Async re-encrypt of a password-protected profile's ephemeral dir back to
/// disk, called after the browser process exits. Optionally purges the
/// ephemeral dir + cached key based on the global setting.
pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) {
/// Re-encrypt a password-protected profile's ephemeral dir back to the
/// on-disk encrypted dir after the browser process exits. Optionally purges
/// the ephemeral dir + cached key based on the global setting. Returns the
/// number of files re-encrypted (`None` when nothing to do or the profile
/// isn't protected).
///
/// Callers that release a queued sync run after a browser quit MUST await
/// this future — releasing sync while re-encryption is still in-flight
/// uploads the stale on-disk snapshot and leaves the fresh ciphertext
/// orphaned until the next scheduler tick.
pub async fn complete_after_quit_and_wait(
profile: &crate::profile::BrowserProfile,
) -> Option<usize> {
if !profile.password_protected {
return;
return None;
}
let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone();
tauri::async_runtime::spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
complete_after_quit_blocking(&profile, keep_decrypted);
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
.await
.unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
None
})
.await;
});
}
#[cfg(test)]
+11
View File
@@ -991,6 +991,17 @@ pub async fn save_sync_settings(
sync_server_url: Option<String>,
sync_token: Option<String>,
) -> Result<SyncSettings, String> {
// Cloud login and self-hosted sync share the same sync engine and a
// profile can't be sync'd to two backends at once. Block any *write*
// (non-null URL or token) while the user is signed into their cloud
// account — the clearing path (both `None`) is always allowed so logged-
// in users can wipe a stale self-hosted config that pre-dates their
// sign-in.
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
}
let manager = SettingsManager::instance();
manager
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
has_e2e_password()
}
#[tauri::command]
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
+43
View File
@@ -3526,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see
// groups/proxies/vpns syncing while their profiles stay local-only — the
// long-standing source of issue #352. Encrypted mode wins when an E2E
// password is already configured; otherwise we fall back to plain Regular.
{
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let desired_mode = if encryption::has_e2e_password() {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
let desired_mode_str = match desired_mode {
SyncMode::Encrypted => "Encrypted",
SyncMode::Regular => "Regular",
SyncMode::Disabled => "Disabled",
};
for profile in &profiles {
// Skip profiles that are already syncing (any non-Disabled mode),
// ephemeral profiles (data wipes on quit, sync is meaningless), and
// cross-OS profiles (the OS-specific binary isn't installed locally
// so a sync round-trip would be one-sided).
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
continue;
}
if let Err(e) = set_profile_sync_mode(
app_handle.clone(),
profile.id.to_string(),
desired_mode_str.to_string(),
)
.await
{
log::warn!(
"Failed to enable sync for profile {} ({}): {e}",
profile.name,
profile.id
);
}
}
}
// Enable sync for all unsynced proxies
{
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
+21 -1
View File
@@ -7,7 +7,9 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
@@ -22,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.0",
"version": "0.24.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+7 -5
View File
@@ -613,7 +613,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
(selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
@@ -1243,11 +1245,10 @@ export default function Home() {
let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles.
if (selectedGroupId === "__all__") {
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
@@ -1292,6 +1293,7 @@ export default function Home() {
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
+421 -103
View File
@@ -1,12 +1,32 @@
"use client";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu";
import {
LuCloud,
LuEye,
LuEyeOff,
LuLogOut,
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
isOpen: boolean;
@@ -15,6 +35,8 @@ interface AccountPageProps {
onOpenSignIn: () => void;
}
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
export function AccountPage({
isOpen,
onClose,
@@ -22,8 +44,34 @@ export function AccountPage({
onOpenSignIn,
}: AccountPageProps) {
const { t } = useTranslation();
const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth();
const {
user,
isLoggedIn,
isLoading: isCloudLoading,
logout,
refreshProfile,
} = useCloudAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
const [serverUrl, setServerUrl] = useState("");
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("unknown");
const hasConfig = Boolean(serverUrl && token);
// Self-hosted and cloud are mutually exclusive — both share the same sync
// engine and a profile can't be sync'd to two backends. The tab trigger is
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
// with split-brain.
const selfHostedDisabled = isLoggedIn || isCloudLoading;
const handleRefresh = async () => {
setIsRefreshing(true);
@@ -38,119 +86,389 @@ export function AccountPage({
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logout();
// The backend wipes sync URL + token as part of cloud_logout (see
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
// the form so a user who flips to the Self-hosted tab doesn't see the
// pre-logout production URL still sitting there.
await loadSelfHostedSettings();
showSuccessToast(t("account.loggedOut"));
} catch (e) {
showErrorToast(String(e));
} finally {
setIsLoggingOut(false);
}
};
const loadSelfHostedSettings = useCallback(async () => {
try {
const settings = await invoke<SyncSettings>("get_sync_settings");
setServerUrl(settings.sync_server_url ?? "");
setToken(settings.sync_token ?? "");
setConnectionStatus(
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
);
} catch (error) {
console.error("Failed to load sync settings:", error);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadSelfHostedSettings();
}
}, [isOpen, loadSelfHostedSettings]);
const handleTestConnection = useCallback(async () => {
if (!serverUrl) {
showErrorToast(t("sync.config.serverUrlRequired"));
return;
}
setIsTestingConnection(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
setConnectionStatus("connected");
showSuccessToast(t("sync.config.connectionSuccess"));
} else {
setConnectionStatus("error");
showErrorToast(t("sync.config.serverError"));
}
} catch {
setConnectionStatus("error");
showErrorToast(t("sync.config.connectFailed"));
} finally {
setIsTestingConnection(false);
}
}, [serverUrl, t]);
const handleSaveSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: serverUrl || null,
syncToken: token || null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
showSuccessToast(t("sync.config.settingsSaved"));
} catch (error) {
console.error("Failed to save sync settings:", error);
// Use the structured backend-error translator so the cloud-vs-self-
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
// instead of the generic "save failed" toast.
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsSavingSelfHosted(false);
}
}, [serverUrl, token, t]);
const handleDisconnectSelfHosted = useCallback(async () => {
setIsSavingSelfHosted(true);
try {
await invoke<SyncSettings>("save_sync_settings", {
syncServerUrl: null,
syncToken: null,
});
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
setServerUrl("");
setToken("");
setConnectionStatus("unknown");
showSuccessToast(t("sync.config.disconnected"));
} catch (error) {
console.error("Failed to disconnect:", error);
showErrorToast(t("sync.config.disconnectFailed"));
} finally {
setIsSavingSelfHosted(false);
}
}, [t]);
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"
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
selfHostedDisabled
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
>
<LuCloud className="w-3 h-3" />
{t("account.signIn")}
</Button>
)}
</div>
{t("account.tabs.selfHosted")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="size-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="size-3" />
{t("account.refresh")}
</Button>
<LoadingButton
size="sm"
variant="destructive"
isLoading={isLoggingOut}
disabled={isRefreshing}
onClick={() => {
void handleLogout();
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
) : (
<Button
size="sm"
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</AnimatedTabsContent>
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
// but if state flips mid-render (e.g. a cloud login finishes
// while the tab is open), show the explanation instead of
// a silent empty card.
<p className="text-sm text-muted-foreground">
{t("account.selfHosted.disabledWhileLoggedIn")}
</p>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-sm font-medium">
{t("account.selfHosted.title")}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("account.selfHosted.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-server-url" className="text-xs">
{t("sync.serverUrl")}
</Label>
<Input
id="self-hosted-server-url"
type="url"
placeholder={t("sync.serverUrlPlaceholder")}
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="self-hosted-token" className="text-xs">
{t("sync.token")}
</Label>
<div className="relative">
<Input
id="self-hosted-token"
type={showToken ? "text" : "password"}
placeholder={t("sync.tokenPlaceholder")}
value={token}
onChange={(e) => {
setToken(e.target.value);
setConnectionStatus("unknown");
}}
autoComplete="off"
spellCheck={false}
className="pr-9"
/>
<button
type="button"
onClick={() => {
setShowToken((v) => !v);
}}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("account.selfHosted.connectionStatus")}
</span>
{connectionStatus === "connected" && (
<Badge
variant="default"
className="text-success-foreground bg-success"
>
{t("sync.status.connected")}
</Badge>
)}
{connectionStatus === "error" && (
<Badge variant="destructive">
{t("sync.status.error")}
</Badge>
)}
{connectionStatus === "testing" && (
<Badge variant="secondary">
{t("sync.status.syncing")}
</Badge>
)}
{connectionStatus === "unknown" && (
<Badge variant="secondary">
{t("account.selfHosted.statusUnknown")}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-2">
<LoadingButton
size="sm"
variant="outline"
isLoading={isTestingConnection}
disabled={!serverUrl || isSavingSelfHosted}
onClick={() => void handleTestConnection()}
className="h-8 text-xs"
>
{t("account.selfHosted.testConnection")}
</LoadingButton>
<LoadingButton
size="sm"
isLoading={isSavingSelfHosted}
disabled={!serverUrl || !token || isTestingConnection}
onClick={() => void handleSaveSelfHosted()}
className="h-8 text-xs"
>
{t("common.buttons.save")}
</LoadingButton>
{hasConfig && (
<Button
size="sm"
variant="destructive"
disabled={isSavingSelfHosted || isTestingConnection}
onClick={() => void handleDisconnectSelfHosted()}
className="h-8 text-xs"
>
{t("account.selfHosted.disconnect")}
</Button>
)}
</div>
</div>
)}
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+5 -5
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 w-5 h-5" />
<LuCheckCheck className="flex-shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
className="p-0 size-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
<FaTimes className="size-3" />
</Button>
</div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<LuCheckCheck className="w-3 h-3" />
<LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")}
</RippleButton>
) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
<FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")}
</RippleButton>
)
+10 -8
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && profile) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
if (!(isOpen && profile)) {
setIsLoading(false);
return;
}
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen, profile]);
if (!profile) return null;
+6 -6
View File
@@ -335,7 +335,7 @@ export function CookieCopyDialog({
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" />
<LuCookie className="size-5" />
{t("cookies.copy.title")}
</DialogTitle>
<DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning}
>
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />}
{IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div>
<div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="w-4 h-4" />
<LuChevronRight className="size-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -429,7 +429,7 @@ export function CookieManagementDialog({
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")}
<br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")}
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/>
))}
</div>
</ScrollArea>
</FadingScrollArea>
)}
</div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
<LuChevronRight className="size-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
+46 -26
View File
@@ -1,7 +1,14 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
@@ -116,6 +123,8 @@ export function CreateProfileDialog({
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
@@ -422,7 +431,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -450,7 +461,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -478,7 +491,10 @@ export function CreateProfileDialog({
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
@@ -605,11 +621,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -631,11 +647,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -676,9 +692,9 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{IconComponent && (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
)}
</div>
<div className="text-left">
@@ -729,7 +745,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
@@ -749,7 +765,7 @@ export function CreateProfileDialog({
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
@@ -814,7 +830,7 @@ export function CreateProfileDialog({
{/* Wayfern Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -922,7 +938,7 @@ export function CreateProfileDialog({
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -1041,7 +1057,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -1154,7 +1170,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1168,6 +1184,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1190,10 +1207,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1217,7 +1235,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1236,7 +1254,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1261,7 +1279,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
@@ -1412,7 +1430,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
</p>
@@ -1520,7 +1538,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1534,6 +1552,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1556,10 +1575,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1583,7 +1603,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1602,7 +1622,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1627,7 +1647,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
+12 -12
View File
@@ -174,42 +174,42 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
}
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -235,7 +235,7 @@ export function UnifiedToast(props: ToastProps) {
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="w-3 h-3" />
<LuX className="size-3" />
</button>
)}
</div>
@@ -272,7 +272,7 @@ export function UnifiedToast(props: ToastProps) {
<>Looking for updates for {progress.current_browser}</>
)}
</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-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-150"
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}
+2 -2
View File
@@ -155,13 +155,13 @@ export function DeleteGroupDialog({
setDeleteAction(value as "move" | "delete");
}}
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="move" id="move" />
<Label htmlFor="move" className="text-sm">
{t("groups.moveToDefault")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
+1 -1
View File
@@ -105,7 +105,7 @@ export function DeviceCodeVerifyDialog({
disabled={isOpeningLogin}
className="w-full gap-1.5"
>
<LuExternalLink className="w-3.5 h-3.5" />
<LuExternalLink className="size-3.5" />
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
+1 -1
View File
@@ -137,7 +137,7 @@ export function DnsBlocklistDialog({
className="w-full"
>
<LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -77,7 +77,7 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup")
: t("groups.defaultGroup");
: t("groups.noGroup");
toast.success(
t("groups.assignSuccess", {
@@ -165,7 +165,7 @@ export function GroupAssignmentDialog({
setCreateDialogOpen(true);
}}
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("groupManagement.createGroup")}
</RippleButton>
</div>
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId ?? "default"}
value={selectedGroupId ?? "__none__"}
onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value);
setSelectedGroupId(value === "__none__" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("groups.defaultGroupNoGroup")}
<SelectItem value="__none__">
{t("groups.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
}
}}
>
<span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
+405 -134
View File
@@ -1,14 +1,37 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import {
LuChevronDown,
LuChevronUp,
LuFolder,
LuPencil,
LuRefreshCw,
LuTrash2,
} from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "@/components/data-table-action-bar";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import {
Table,
TableBody,
@@ -111,6 +133,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null,
);
@@ -125,6 +149,12 @@ export function GroupManagementDialog({
{},
);
// Table state
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Listen for group sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
@@ -246,9 +276,272 @@ export function GroupManagementDialog({
useEffect(() => {
if (isOpen) {
void loadGroups();
} else {
// Drop any selection when the dialog closes so the floating
// action bar (portaled to body) doesn't linger on the page.
setRowSelection({});
}
}, [isOpen, loadGroups]);
const columns = useMemo<ColumnDef<GroupWithCount>[]>(
() => [
{
id: "select",
size: 36,
enableSorting: false,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => {
table.toggleAllRowsSelected(!!value);
}}
aria-label={t("common.aria.selectAll")}
disabled={table.getRowModel().rows.length === 0}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => {
row.toggleSelected(!!value);
}}
aria-label={t("common.aria.selectRow")}
/>
),
},
{
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
{t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
),
cell: ({ row }) => {
const group = row.original;
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`size-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
<LuFolder className="size-4 text-muted-foreground" />
{group.name}
</div>
);
},
},
{
id: "count",
size: 80,
enableSorting: false,
header: () => t("groupManagement.profilesCol"),
cell: ({ row }) => (
<Badge variant="secondary">{row.original.count}</Badge>
),
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
const group = row.original;
const locked = groupInUse[group.id];
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
<AnimatedSwitch
checked={group.sync_enabled}
onCheckedChange={() => handleToggleSync(group)}
disabled={isTogglingSync[group.id] || locked}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{locked ? (
<p>{t("syncTooltips.lockedInUse")}</p>
) : (
<p>
{group.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
)}
</TooltipContent>
</Tooltip>
);
},
},
{
id: "actions",
size: 96,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
const group = row.original;
return (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.editGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.deleteGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
],
[
t,
groupSyncStatus,
groupSyncErrors,
groupInUse,
isTogglingSync,
handleToggleSync,
handleEditGroup,
handleDeleteGroup,
],
);
const table = useReactTable({
data: groups,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedGroupsForBulk = useMemo(
() => selectedRows.map((row) => row.original),
[selectedRows],
);
const selectedNames = useMemo(
() => selectedGroupsForBulk.map((g) => g.name).join(", "),
[selectedGroupsForBulk],
);
const handleBulkDelete = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
setIsBulkDeleting(true);
try {
const ids = selectedGroupsForBulk.map((g) => g.id);
const results = await Promise.allSettled(
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
showErrorToast(t("groups.deleteFailed"));
} else {
showSuccessToast(t("groups.deleteSuccess"));
}
table.toggleAllRowsSelected(false);
setBulkDeleteOpen(false);
await loadGroups();
onGroupManagementComplete();
} catch (err) {
console.error("Bulk group delete failed:", err);
showErrorToast(
err instanceof Error ? err.message : t("groups.deleteFailed"),
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedGroupsForBulk, table, loadGroups, onGroupManagementComplete, t]);
const handleBulkToggleSync = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
const allOn = selectedGroupsForBulk.every((g) => g.sync_enabled);
const targetEnabled = !allOn;
const targets = selectedGroupsForBulk.filter((g) =>
targetEnabled ? !g.sync_enabled : g.sync_enabled && !groupInUse[g.id],
);
if (targets.length === 0) return;
const results = await Promise.allSettled(
targets.map((group) =>
invoke("set_group_sync_enabled", {
groupId: group.id,
enabled: targetEnabled,
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
} else {
showSuccessToast(
targetEnabled
? t("proxies.management.syncEnabled")
: t("proxies.management.syncDisabled"),
);
}
await loadGroups();
}, [selectedGroupsForBulk, groupInUse, loadGroups, t]);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
@@ -262,18 +555,24 @@ export function GroupManagementDialog({
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>{t("groupManagement.groupsLabel")}</Label>
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
className="flex gap-2 items-center"
className="flex gap-2 items-center shrink-0"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="size-4" />
{t("proxies.management.create")}
</RippleButton>
</div>
@@ -294,131 +593,64 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-20">
{t("groupManagement.profilesCol")}
</TableHead>
<TableHead className="w-24">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="w-24">
{t("common.labels.actions")}
</TableHead>
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
{t("groupManagement.syncCannotDisable")}
</p>
) : (
<p>
{group.sync_enabled
? t("proxies.management.disableSync")
: t("proxies.management.enableSync")}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.editGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.deleteGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
@@ -432,6 +664,45 @@ export function GroupManagementDialog({
</DialogContent>
</Dialog>
{isOpen && (
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
<DataTableActionBarAction
tooltip={t("syncTooltips.bulkToggle")}
onClick={() => {
void handleBulkToggleSync();
}}
size="icon"
>
<LuRefreshCw />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
>
<LuTrash2 />
</DataTableActionBarAction>
</DataTableActionBar>
)}
<DeleteConfirmationDialog
isOpen={bulkDeleteOpen}
onClose={() => {
if (!isBulkDeleting) setBulkDeleteOpen(false);
}}
onConfirm={handleBulkDelete}
title={t("groupManagement.bulkDelete.title")}
description={t("groupManagement.bulkDelete.description", {
count: selectedGroupsForBulk.length,
names: selectedNames,
})}
confirmButtonText={t("groupManagement.bulkDelete.confirmButton")}
isLoading={isBulkDeleting}
/>
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => {
+22 -17
View File
@@ -1,7 +1,7 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
@@ -30,6 +30,7 @@ interface Props {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
@@ -40,6 +41,7 @@ const HomeHeader = ({
searchQuery,
onSearchQueryChange,
groups,
totalProfiles,
selectedGroupId,
onGroupSelect,
pageTitle,
@@ -54,11 +56,6 @@ const HomeHeader = ({
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);
@@ -156,6 +153,8 @@ const HomeHeader = ({
};
}, []);
const isWindows = platform === "windows";
return (
<div
ref={dragRootRef}
@@ -163,7 +162,15 @@ const HomeHeader = ({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
className="flex items-center gap-2 h-11 px-3 border-b border-border bg-card select-none"
className={cn(
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders two 44px native-style controls
// (minimize + close) fixed at top-right with z-50, total 88px wide.
// Reserve 100px on the right edge so the "+ New" button and search
// input clear them with a few pixels of breathing room — issues
// #358, #361, #362 all reported the same overlap before this fix.
isWindows ? "pr-[100px]" : "pr-3",
)}
>
{isMacOS && (
<div
@@ -198,9 +205,9 @@ const HomeHeader = ({
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"
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-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" />
<LuChevronLeft className="size-3" />
</button>
)}
<div
@@ -237,8 +244,6 @@ const HomeHeader = ({
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return (
<button
key={group.id}
@@ -253,7 +258,7 @@ const HomeHeader = ({
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{label}</span>
<span>{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
@@ -273,9 +278,9 @@ const HomeHeader = ({
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"
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-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" />
<LuChevronRight className="size-3" />
</button>
)}
</div>
@@ -294,7 +299,7 @@ const HomeHeader = ({
}}
className="pr-7 pl-8 w-52 h-7 text-xs"
/>
<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" />
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
<button
type="button"
@@ -304,7 +309,7 @@ const HomeHeader = ({
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-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
</button>
) : null}
</div>
@@ -321,7 +326,7 @@ const HomeHeader = ({
}}
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
>
<GoPlus className="w-3.5 h-3.5" />
<GoPlus className="size-3.5" />
{t("header.newProfile")}
</Button>
</span>
+27 -29
View File
@@ -9,6 +9,12 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -304,31 +310,23 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
<AnimatedTabs
value={importMode}
onValueChange={(v) =>
setImportMode(v as "auto-detect" | "manual")
}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</RippleButton>
</div>
</AnimatedTabsTrigger>
</AnimatedTabsList>
{importMode === "auto-detect" && (
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
@@ -383,7 +381,7 @@ export function ImportProfileDialog({
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
@@ -439,9 +437,9 @@ export function ImportProfileDialog({
</div>
)}
</div>
)}
</AnimatedTabsContent>
{importMode === "manual" && (
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
@@ -475,7 +473,7 @@ export function ImportProfileDialog({
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
@@ -507,7 +505,7 @@ export function ImportProfileDialog({
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="w-4 h-4" />
<FaFolder className="size-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
@@ -539,8 +537,8 @@ export function ImportProfileDialog({
</div>
</div>
</div>
)}
</>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && currentMappedBrowser && (
+386 -287
View File
@@ -4,8 +4,23 @@ import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuAppWindow,
LuCheck,
LuCodeXml,
LuPlug,
LuTerminal,
LuTrash2,
LuZap,
} from "react-icons/lu";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -14,8 +29,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -33,12 +48,52 @@ interface McpConfig {
token: string;
}
type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
interface McpAgentInfo {
id: string;
display_name: string;
category: AgentCategory;
connected: boolean;
detected: boolean;
}
interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
}
function AgentIcon({ category }: { category: AgentCategory }) {
const className = "size-4 text-muted-foreground";
switch (category) {
case "desktop-app":
return <LuAppWindow className={className} />;
case "editor":
return <LuCodeXml className={className} />;
case "editor-ext":
return <LuPlug className={className} />;
case "cli":
return <LuTerminal className={className} />;
}
}
function categoryLabel(
t: (k: string) => string,
category: AgentCategory,
): string {
switch (category) {
case "desktop-app":
return t("integrations.mcp.category.desktopApp");
case "editor":
return t("integrations.mcp.category.editor");
case "editor-ext":
return t("integrations.mcp.category.editorExt");
case "cli":
return t("integrations.mcp.category.cli");
}
}
export function IntegrationsDialog({
isOpen,
onClose,
@@ -57,11 +112,11 @@ export function IntegrationsDialog({
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [, setMcpRunning] = useState(false);
const [showApiToken, setShowApiToken] = useState(false);
const [showMcpToken, setShowMcpToken] = useState(false);
const [showMcpUrl, setShowMcpUrl] = useState(false);
const [isApiStarting, setIsApiStarting] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false);
const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const { termsAccepted } = useWayfernTerms();
@@ -101,21 +156,12 @@ export function IntegrationsDialog({
}
}, []);
const loadClaudeDesktopStatus = useCallback(async () => {
const loadAgents = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_desktop");
setMcpInClaudeDesktop(exists);
} catch {
// Not critical
}
}, []);
const loadClaudeCodeStatus = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_code");
setMcpInClaudeCode(exists);
} catch {
// Claude CLI may not be installed
const list = await invoke<McpAgentInfo[]>("list_mcp_agents");
setAgents(list);
} catch (e) {
console.error("Failed to list MCP agents:", e);
}
}, []);
@@ -125,8 +171,7 @@ export function IntegrationsDialog({
void loadApiServerStatus();
void loadMcpConfig();
void loadMcpServerStatus();
void loadClaudeDesktopStatus();
void loadClaudeCodeStatus();
void loadAgents();
}
}, [
isOpen,
@@ -134,8 +179,7 @@ export function IntegrationsDialog({
loadApiServerStatus,
loadMcpConfig,
loadMcpServerStatus,
loadClaudeDesktopStatus,
loadClaudeCodeStatus,
loadAgents,
]);
const handleApiToggle = async (enabled: boolean) => {
@@ -181,6 +225,7 @@ export function IntegrationsDialog({
});
setSettings(next);
void loadMcpConfig();
void loadAgents();
showSuccessToast(t("integrations.mcpStarted", { port }));
} else {
await invoke("stop_mcp_server");
@@ -202,6 +247,53 @@ export function IntegrationsDialog({
}
};
const markAgentBusy = (id: string, busy: boolean) => {
setBusyAgentIds((prev) => {
const next = new Set(prev);
if (busy) next.add(id);
else next.delete(id);
return next;
});
};
const handleAddAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("add_mcp_to_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.addedToClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const handleRemoveAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("remove_mcp_from_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.removedFromClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const mcpUrl = mcpConfig
? `http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`
: "";
return (
<Dialog
open={isOpen}
@@ -210,7 +302,7 @@ export function IntegrationsDialog({
}}
subPage={subPage}
>
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
@@ -218,200 +310,235 @@ export function IntegrationsDialog({
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
</TabsList>
<AnimatedTabs defaultValue="api">
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
<AnimatedTabsContent
value="api"
className="mt-4 flex flex-col gap-4"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
</div>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</div>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div>
{settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center space-x-2">
<Button
size="sm"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
<Input
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
{apiServerPort && (
<span className="text-xs text-muted-foreground">
{t("common.status.running")}
</span>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiTokenLabel")}
</Label>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
variant="outline"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{showApiToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiTokenLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
/>
</div>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiExampleRequest")}
</Label>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.apiTokenHint", {
tokenSlot: "<token>",
})}
</p>
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</div>
</>
)}
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
<AnimatedTabsContent
value="mcp"
className="mt-4 flex flex-col gap-5"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuZap className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
</div>
</div>
<AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(checked)}
/>
</div>
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
<>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input
type={showMcpToken ? "text" : "password"}
value={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
type={showMcpUrl ? "text" : "password"}
value={mcpUrl}
readOnly
className="font-mono text-xs pr-10"
/>
@@ -421,116 +548,88 @@ export function IntegrationsDialog({
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowMcpToken(!showMcpToken);
setShowMcpUrl(!showMcpUrl);
}}
>
{showMcpToken ? (
<EyeOff className="h-4 w-4" />
{showMcpUrl ? (
<EyeOff className="size-4" />
) : (
<Eye className="h-4 w-4" />
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")}
/>
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")}
</p>
{mcpInClaudeDesktop ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_desktop");
setMcpInClaudeDesktop(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeDesktop")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_desktop");
setMcpInClaudeDesktop(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeDesktop")}
</Button>
)}
<div className="flex flex-col gap-3">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
<div
key={agent.id}
className="rounded-md border bg-card px-3 py-2.5 flex items-center gap-3"
>
<div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
<AgentIcon category={agent.category} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{agent.display_name}
</p>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{categoryLabel(t, agent.category)}
</p>
</div>
{agent.connected ? (
<div className="flex items-center gap-1">
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-foreground">
<LuCheck className="size-3" />
{t("integrations.mcp.connected")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void handleRemoveAgent(agent)}
aria-label={t(
"integrations.mcp.removeAriaLabel",
{
name: agent.display_name,
},
)}
>
<LuTrash2 className="size-4" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void handleAddAgent(agent)}
>
{t("integrations.mcp.add")}
</Button>
)}
</div>
);
})}
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeCodeTitle")}
</p>
{mcpInClaudeCode ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeCode")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_code");
setMcpInClaudeCode(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeCode")}
</Button>
)}
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+1 -1
View File
@@ -17,7 +17,7 @@ export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
disabled={props.disabled || isLoading}
>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
<LuLoaderCircle className="size-4 animate-spin" />
) : (
props.children
)}
+4 -4
View File
@@ -26,6 +26,10 @@ interface LocationProxyDialogProps {
onClose: () => void;
}
function LoadingSpinner() {
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
}
export function LocationProxyDialog({
isOpen,
onClose,
@@ -219,10 +223,6 @@ export function LocationProxyDialog({
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
const LoadingSpinner = () => (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
+1 -1
View File
@@ -434,7 +434,7 @@ const MultipleSelector = React.forwardRef<
handleUnselect(option);
}}
>
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
<LuX className="size-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
+3 -3
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-8 h-8" />;
return <BsMic className="size-8" />;
case "camera":
return <BsCamera className="w-8 h-8" />;
return <BsCamera className="size-8" />;
}
};
@@ -195,7 +195,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
+56 -44
View File
@@ -350,11 +350,11 @@ function ExtCell({
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" />
<LuPuzzle className="size-3 shrink-0" />
<span className="truncate flex-1" title={label}>
{label}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
@@ -369,7 +369,7 @@ function ExtCell({
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{groupId === null && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.extDefault")}
</span>
@@ -382,7 +382,7 @@ function ExtCell({
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{groupId === g.id && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
@@ -416,13 +416,17 @@ function DnsCell({
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
];
const currentLabel =
level === null
? null
: (LEVELS.find((l) => l.value === level)?.labelKey ?? null);
const onPick = async (nextLevel: string | null) => {
setIsSaving(true);
try {
await invoke("update_profile_dns_blocklist", {
profileId: profile.id,
level: nextLevel,
dnsBlocklist: nextLevel,
});
} catch (err) {
console.error("Failed to update DNS blocklist:", err);
@@ -445,11 +449,11 @@ function DnsCell({
: 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 ?? "—"}
<FiWifi className="size-3 shrink-0" />
<span className="flex-1 truncate text-[11px] tracking-wide">
{currentLabel ? meta.t(currentLabel) : "—"}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
@@ -462,7 +466,7 @@ function DnsCell({
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{level === null && <LuCheck className="mr-2 size-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
@@ -475,9 +479,7 @@ function DnsCell({
void onPick(l.value);
}}
>
{level === l.value && (
<LuCheck className="mr-2 w-3.5 h-3.5" />
)}
{level === l.value && <LuCheck className="mr-2 size-3.5" />}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
@@ -1960,7 +1962,7 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -1969,9 +1971,9 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<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-150" />
<span className="size-4 group">
<OsIcon className="size-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 size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1999,14 +2001,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -2025,9 +2027,9 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4 cursor-not-allowed">
<span className="flex justify-center items-center size-4 cursor-not-allowed">
{IconComponent && (
<IconComponent className="w-4 h-4 opacity-50" />
<IconComponent className="size-4 opacity-50" />
)}
</span>
</TooltipTrigger>
@@ -2047,14 +2049,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -2067,7 +2069,7 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex relative justify-center items-center w-4 h-4">
<span className="flex relative justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -2076,11 +2078,11 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<span className="w-4 h-4 group">
<span className="size-4 group">
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
<IconComponent className="size-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-150" />
<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 size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -2194,7 +2196,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
<LuTriangleAlert className="size-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2217,7 +2219,7 @@ export function ProfilesDataTable({
: meta.t("profiles.actions.launch")
}
className={cn(
"h-7 w-7 p-0 grid place-items-center",
"size-7 p-0 grid place-items-center",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
@@ -2231,11 +2233,11 @@ export function ProfilesDataTable({
}
>
{isLaunching || isStopping ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : isRunning ? (
<LuSquare className="w-3.5 h-3.5 fill-current" />
<LuSquare className="size-3.5 fill-current" />
) : (
<LuPlay className="w-3.5 h-3.5 fill-current" />
<LuPlay className="size-3.5 fill-current" />
)}
</RippleButton>
</span>
@@ -2265,9 +2267,9 @@ export function ProfilesDataTable({
>
{meta.t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 w-4 h-4" />
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
);
@@ -2382,7 +2384,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuLock className="w-3 h-3 text-muted-foreground" />
<LuLock className="size-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2606,7 +2608,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedId === null
? "opacity-100"
: "opacity-0",
@@ -2633,7 +2635,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
@@ -2659,7 +2661,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveVpnId === vpn.id
? "opacity-100"
: "opacity-0",
@@ -2701,7 +2703,7 @@ export function ProfilesDataTable({
)
}
>
<span className="mr-2 h-4 w-4" />+{" "}
<span className="mr-2 size-4" />+{" "}
{country.name}
</CommandItem>
))}
@@ -2793,11 +2795,11 @@ export function ProfilesDataTable({
<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" : ""}`}
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
/>
) : (
<span
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
className={`size-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
/>
)}
</span>
@@ -2818,7 +2820,7 @@ export function ProfilesDataTable({
<div className="flex justify-end items-center h-9 w-full">
<Button
variant="ghost"
className="p-0 w-7 h-7"
className="p-0 size-7"
disabled={!meta.isClient}
onClick={() => {
setProfileForInfoDialog(profile);
@@ -2827,7 +2829,7 @@ export function ProfilesDataTable({
<span className="sr-only">
{t("profiles.aria.profileInfo")}
</span>
<LuInfo className="w-4 h-4" />
<LuInfo className="size-4" />
</Button>
</div>
);
@@ -2889,6 +2891,14 @@ export function ProfilesDataTable({
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
style={
{
// Sticky table header is 32px tall (h-8); shift the top
// fade band below it so the header stays fully opaque and
// only body rows fade as they scroll past.
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
@@ -3005,7 +3015,9 @@ export function ProfilesDataTable({
/>
{profileForInfoDialog &&
(() => {
const infoProfile = profileForInfoDialog;
const infoProfile =
profiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
+153 -127
View File
@@ -16,7 +16,6 @@ import {
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -33,6 +32,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -48,13 +48,9 @@ import {
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { getProfileIcon } from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
@@ -94,14 +90,14 @@ interface ProfileInfoDialogProps {
syncStatuses: Record<string, { status: string; error?: string }>;
}
function OSIcon({ os }: { os: string }) {
function _OSIcon({ os }: { os: string }) {
switch (os) {
case "macos":
return <FaApple className="w-3.5 h-3.5" />;
return <FaApple className="size-3.5" />;
case "windows":
return <FaWindows className="w-3.5 h-3.5" />;
return <FaWindows className="size-3.5" />;
case "linux":
return <FaLinux className="w-3.5 h-3.5" />;
return <FaLinux className="size-3.5" />;
default:
return null;
}
@@ -290,12 +286,8 @@ export function ProfileInfoDialog({
action();
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
@@ -317,7 +309,7 @@ export function ProfileInfoDialog({
const actions: ActionItem[] = [
{
icon: <LuGlobe className="w-4 h-4" />,
icon: <LuGlobe className="size-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
handleAction(() => onOpenTrafficDialog?.(profile.id));
@@ -325,7 +317,7 @@ export function ProfileInfoDialog({
disabled: isCrossOs,
},
{
icon: <LuRefreshCw className="w-4 h-4" />,
icon: <LuRefreshCw className="size-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
handleAction(() => onOpenProfileSyncDialog?.(profile));
@@ -334,7 +326,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuGroup className="w-4 h-4" />,
icon: <LuGroup className="size-4" />,
label: t("profiles.actions.assignToGroup"),
onClick: () => {
handleAction(() => onAssignProfilesToGroup?.([profile.id]));
@@ -343,7 +335,7 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
icon: <LuFingerprint className="size-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
@@ -353,7 +345,7 @@ export function ProfileInfoDialog({
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuUsers className="w-4 h-4" />,
icon: <LuUsers className="size-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => {
handleAction(() => onLaunchWithSync?.(profile));
@@ -363,7 +355,7 @@ export function ProfileInfoDialog({
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
icon: <LuCopy className="w-4 h-4" />,
icon: <LuCopy className="size-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
handleAction(() => onCopyCookiesToProfile?.(profile));
@@ -376,7 +368,7 @@ export function ProfileInfoDialog({
!onCopyCookiesToProfile,
},
{
icon: <LuCookie className="w-4 h-4" />,
icon: <LuCookie className="size-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
handleAction(() => onOpenCookieManagement?.(profile));
@@ -389,7 +381,7 @@ export function ProfileInfoDialog({
!onOpenCookieManagement,
},
{
icon: <LuSettings className="w-4 h-4" />,
icon: <LuSettings className="size-4" />,
label: t("profiles.actions.clone"),
onClick: () => {
handleAction(() => onCloneProfile?.(profile));
@@ -399,7 +391,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuPuzzle className="w-4 h-4" />,
icon: <LuPuzzle className="size-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
handleAction(() => onAssignExtensionGroup?.([profile.id]));
@@ -409,21 +401,21 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
icon: <LuShieldCheck className="size-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => {
handleAction(() => onOpenBypassRules?.(profile));
},
},
{
icon: <LuShield className="w-4 h-4" />,
icon: <LuShield className="size-4" />,
label: t("dnsBlocklist.title"),
onClick: () => {
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
icon: <LuLink className="size-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
@@ -431,7 +423,7 @@ export function ProfileInfoDialog({
hidden: !onOpenLaunchHook,
},
{
icon: <LuKey className="w-4 h-4" />,
icon: <LuKey className="size-4" />,
label: t("profiles.actions.setPassword"),
onClick: () => {
handleAction(() => onSetPassword?.(profile));
@@ -444,7 +436,7 @@ export function ProfileInfoDialog({
!onSetPassword,
},
{
icon: <LuKey className="w-4 h-4" />,
icon: <LuKey className="size-4" />,
label: t("profiles.actions.changePassword"),
onClick: () => {
handleAction(() => onChangePassword?.(profile));
@@ -454,7 +446,7 @@ export function ProfileInfoDialog({
hidden: profile.password_protected !== true || !onChangePassword,
},
{
icon: <LuLockOpen className="w-4 h-4" />,
icon: <LuLockOpen className="size-4" />,
label: t("profiles.actions.removePassword"),
onClick: () => {
handleAction(() => onRemovePassword?.(profile));
@@ -465,7 +457,7 @@ export function ProfileInfoDialog({
destructive: true,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
icon: <LuTrash2 className="size-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
handleAction(() => onDeleteProfile?.(profile));
@@ -491,10 +483,8 @@ export function ProfileInfoDialog({
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
releaseLabel={releaseLabel}
isRunning={isRunning}
isDisabled={isDisabled}
showCrossOs={showCrossOs}
networkLabel={networkLabel}
groupName={groupName}
extensionGroupName={extensionGroupName}
@@ -520,10 +510,8 @@ export function ProfileInfoDialog({
interface ProfileInfoLayoutProps {
profile: BrowserProfile;
ProfileIcon: React.ComponentType<{ className?: string }>;
releaseLabel: string;
isRunning: boolean;
isDisabled: boolean;
showCrossOs: boolean;
networkLabel: string;
groupName: string | null;
extensionGroupName: string | null;
@@ -564,10 +552,8 @@ type ProfileSection =
function ProfileInfoLayout({
profile,
ProfileIcon,
releaseLabel,
isRunning,
isDisabled,
showCrossOs,
networkLabel,
groupName,
extensionGroupName,
@@ -646,12 +632,12 @@ function ProfileInfoLayout({
}[] = [
{
id: "overview",
icon: <LuClipboard className="w-3.5 h-3.5" />,
icon: <LuClipboard className="size-3.5" />,
label: t("profileInfo.sections.overview"),
},
{
id: "fingerprint",
icon: <LuFingerprint className="w-3.5 h-3.5" />,
icon: <LuFingerprint className="size-3.5" />,
label: t("profileInfo.sections.fingerprint"),
badge: profile.password_protected
? t("profileInfo.badges.locked")
@@ -660,13 +646,13 @@ function ProfileInfoLayout({
},
{
id: "network",
icon: <LuGlobe className="w-3.5 h-3.5" />,
icon: <LuGlobe className="size-3.5" />,
label: t("profileInfo.sections.network"),
badge: profile.proxy_id || profile.vpn_id ? networkLabel : undefined,
},
{
id: "cookies",
icon: <LuCookie className="w-3.5 h-3.5" />,
icon: <LuCookie className="size-3.5" />,
label: t("profileInfo.sections.cookies"),
badge:
cookieCount !== null && cookieCount > 0
@@ -676,26 +662,26 @@ function ProfileInfoLayout({
},
{
id: "extensions",
icon: <LuPuzzle className="w-3.5 h-3.5" />,
icon: <LuPuzzle className="size-3.5" />,
label: t("profileInfo.sections.extensions"),
badge: extensionGroupName ?? undefined,
hidden: !extensionAction,
},
{
id: "sync",
icon: <LuRefreshCw className="w-3.5 h-3.5" />,
icon: <LuRefreshCw className="size-3.5" />,
label: t("profileInfo.sections.sync"),
hidden: !syncAction,
},
{
id: "automation",
icon: <LuLink className="w-3.5 h-3.5" />,
icon: <LuLink className="size-3.5" />,
label: t("profileInfo.sections.launchHook"),
badge: profile.launch_hook ? t("profileInfo.badges.active") : undefined,
},
{
id: "security",
icon: <LuKey className="w-3.5 h-3.5" />,
icon: <LuKey className="size-3.5" />,
label: t("profileInfo.sections.security"),
},
];
@@ -704,7 +690,7 @@ function ProfileInfoLayout({
<>
{/* Top bar */}
<div className="flex items-center gap-2 h-11 px-3 border-b border-border shrink-0">
<LuUsers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<LuUsers className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex items-center gap-1.5 text-xs min-w-0 flex-1">
<span className="font-semibold">
{t("profileInfo.breadcrumbRoot")}
@@ -720,7 +706,7 @@ function ProfileInfoLayout({
disabled={isDisabled}
onClick={() => onCloneProfile(profile)}
>
<LuCopy className="w-3 h-3" />
<LuCopy className="size-3" />
{t("profileInfo.duplicate")}
</Button>
)}
@@ -728,9 +714,9 @@ function ProfileInfoLayout({
type="button"
aria-label={t("common.buttons.close")}
onClick={onClose}
className="grid place-items-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
className="grid place-items-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
>
<LuX className="w-3.5 h-3.5" />
<LuX className="size-3.5" />
</button>
</div>
@@ -773,7 +759,7 @@ function ProfileInfoLayout({
disabled={deleteAction.disabled}
className="flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-destructive hover:bg-destructive/10 disabled:opacity-50 disabled:pointer-events-none"
>
<LuTrash2 className="w-3.5 h-3.5 shrink-0" />
<LuTrash2 className="size-3.5 shrink-0" />
<span className="flex-1 text-left">
{t("profileInfo.sections.delete")}
</span>
@@ -789,7 +775,7 @@ function ProfileInfoLayout({
{/* Hero */}
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2.5 shrink-0">
<ProfileIcon className="w-7 h-7 text-foreground" />
<ProfileIcon className="size-7 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@@ -798,63 +784,8 @@ function ProfileInfoLayout({
</h3>
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
<span className="font-mono uppercase text-muted-foreground">
{getBrowserDisplayName(profile.browser)}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{groupName ?? t("profileInfo.values.none")}
</span>
{isRunning && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-success">
<span className="w-1.5 h-1.5 rounded-full bg-success" />
{t("common.status.running")}
</span>
</>
)}
{profile.ephemeral && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground uppercase">
{t("profiles.ephemeralBadge")}
</span>
</>
)}
{profile.password_protected && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<LuLock className="w-3 h-3" />
{t("profiles.passwordProtectedBadge")}
</span>
</>
)}
{showCrossOs && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</span>
</>
)}
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{releaseLabel}
<span className="font-mono text-muted-foreground">
{profile.version}
</span>
</div>
</div>
@@ -875,9 +806,9 @@ function ProfileInfoLayout({
aria-label={t("common.buttons.copy")}
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
<LuClipboardCheck className="size-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
<LuClipboard className="size-3.5" />
)}
</button>
</div>
@@ -1082,7 +1013,7 @@ function _SectionAction({
>
{icon}
<span className="flex-1">{label}</span>
<LuChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
<LuChevronRight className="size-3.5 text-muted-foreground" />
</button>
);
}
@@ -1132,7 +1063,7 @@ function LaunchHookEditor({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuLink className="w-4 h-4" />
<LuLink className="size-4" />
{t("profileInfo.sections.launchHook")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1216,7 +1147,7 @@ function SyncSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuRefreshCw className="w-4 h-4" />
<LuRefreshCw className="size-4" />
{t("profileInfo.sections.sync")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1331,7 +1262,7 @@ function NetworkSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuGlobe className="w-4 h-4" />
<LuGlobe className="size-4" />
{t("profileInfo.sections.network")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1464,7 +1395,7 @@ function ExtensionsSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuPuzzle className="w-4 h-4" />
<LuPuzzle className="size-4" />
{t("profileInfo.sections.extensions")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1553,7 +1484,7 @@ function CookiesSectionInline({
return (
<div className="flex flex-col gap-3 min-h-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuCookie className="w-4 h-4" />
<LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1651,7 +1582,7 @@ function FingerprintSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuFingerprint className="w-4 h-4" />
<LuFingerprint className="size-4" />
{t("profileInfo.sections.fingerprint")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1705,7 +1636,7 @@ function FingerprintSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuFingerprint className="w-4 h-4" />
<LuFingerprint className="size-4" />
{t("profileInfo.sections.fingerprint")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1716,7 +1647,7 @@ function FingerprintSectionInline({
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={onCamoufoxChange}
forceAdvanced={false}
forceAdvanced={true}
readOnly={isDisabled}
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
@@ -1729,6 +1660,7 @@ function FingerprintSectionInline({
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={onWayfernChange}
forceAdvanced={true}
readOnly={isDisabled}
crossOsUnlocked={crossOsUnlocked}
profileVersion={profile.version}
@@ -1739,7 +1671,7 @@ function FingerprintSectionInline({
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
<div className="flex items-center gap-2 sticky bottom-0 bg-background pt-2 -mx-3 px-3 -mb-3 pb-3 border-t border-border">
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<Button
size="sm"
className="h-7 text-xs"
@@ -1790,6 +1722,30 @@ function SecuritySectionInline({
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [success, setSuccess] = React.useState<string | null>(null);
const [isVerifyOpen, setIsVerifyOpen] = React.useState(false);
const [verifyPassword, setVerifyPassword] = React.useState("");
const [isVerifying, setIsVerifying] = React.useState(false);
const onVerify = async () => {
setIsVerifying(true);
try {
await invoke("verify_profile_password", {
profileId: profile.id,
password: verifyPassword,
});
showSuccessToast(t("profilePassword.verifyDialog.matchToast"));
setIsVerifyOpen(false);
setVerifyPassword("");
} catch (e) {
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
showErrorToast(message);
} finally {
setIsVerifying(false);
}
};
// Reset the form whenever the underlying profile state changes (e.g. the
// user just set a password — flip to "change" mode and clear fields).
@@ -1837,24 +1793,29 @@ function SecuritySectionInline({
profileId: profile.id,
password,
});
setSuccess(t("profilePassword.toasts.set"));
showSuccessToast(t("profilePassword.toasts.set"));
} else if (mode === "change") {
await invoke("change_profile_password", {
profileId: profile.id,
oldPassword,
newPassword: password,
});
setSuccess(t("profilePassword.toasts.changed"));
showSuccessToast(t("profilePassword.toasts.changed"));
} else {
await invoke("remove_profile_password", {
profileId: profile.id,
password: oldPassword,
});
setSuccess(t("profilePassword.toasts.removed"));
showSuccessToast(t("profilePassword.toasts.removed"));
}
reset();
} catch (e) {
setError(String(e));
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
setError(message);
showErrorToast(message);
} finally {
setIsSubmitting(false);
}
@@ -1863,7 +1824,7 @@ function SecuritySectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuKey className="w-4 h-4" />
<LuKey className="size-4" />
{t("profileInfo.sections.security")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1874,6 +1835,19 @@ function SecuritySectionInline({
{profile.password_protected && (
<div className="flex gap-1.5">
<button
type="button"
onClick={() => {
setVerifyPassword("");
setIsVerifyOpen(true);
}}
className={cn(
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
"border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
{t("profilePassword.modes.validate")}
</button>
<button
type="button"
onClick={() => {
@@ -1973,6 +1947,58 @@ function SecuritySectionInline({
? t("profilePassword.modes.change")
: t("profilePassword.modes.remove")}
</Button>
<Dialog
open={isVerifyOpen}
onOpenChange={(open) => {
if (!isVerifying) {
setIsVerifyOpen(open);
if (!open) setVerifyPassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("profilePassword.verifyDialog.title")}</DialogTitle>
<DialogDescription>
{t("profilePassword.verifyDialog.description")}
</DialogDescription>
</DialogHeader>
<Input
type="password"
placeholder={t("profilePassword.fields.currentPassword")}
value={verifyPassword}
autoFocus
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyPassword.length > 0) {
e.preventDefault();
void onVerify();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifying}
onClick={() => {
setIsVerifyOpen(false);
setVerifyPassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<Button
disabled={isVerifying || verifyPassword.length === 0}
onClick={() => void onVerify()}
>
{isVerifying
? t("common.buttons.loading")
: t("profilePassword.verifyDialog.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -2226,7 +2252,7 @@ export function ProfileBypassRulesDialog({
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
<LuPlus className="size-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
@@ -2249,7 +2275,7 @@ export function ProfileBypassRulesDialog({
}}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
<LuX className="size-3.5" />
</button>
</div>
))}
+10 -8
View File
@@ -53,14 +53,16 @@ export function ProfilePasswordDialog({
const firstInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen) {
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
setTimeout(() => firstInputRef.current?.focus(), 0);
}
if (!isOpen) return;
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
const handle = window.setTimeout(() => firstInputRef.current?.focus(), 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen]);
// Tick down the lockout timer
+1 -1
View File
@@ -237,7 +237,7 @@ export function ProfileSelectorDialog({
profile.browser,
);
return IconComponent ? (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
) : null;
})()}
</div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function ProfileSyncDialog({
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -216,7 +216,7 @@ export function ProfileSyncDialog({
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
@@ -228,7 +228,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
@@ -240,7 +240,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
+12 -6
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner";
@@ -55,6 +55,7 @@ export function ProxyAssignmentDialog({
vpnConfigs = [],
}: ProxyAssignmentDialogProps) {
const { t } = useTranslation();
const proxyListboxId = useId();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none",
@@ -183,6 +184,7 @@ export function ProxyAssignmentDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxId}
className="w-full justify-between font-normal"
>
{(() => {
@@ -199,10 +201,14 @@ export function ProxyAssignmentDialog({
);
return proxy ? proxy.name : t("proxyAssignment.noneOption");
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
<PopoverContent
id={proxyListboxId}
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={t("proxyAssignment.searchPlaceholder")}
@@ -219,7 +225,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "none"
? "opacity-100"
: "opacity-0",
@@ -243,7 +249,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
@@ -269,7 +275,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "vpn" && selectedId === vpn.id
? "opacity-100"
: "opacity-0",
+3 -3
View File
@@ -118,12 +118,12 @@ export function ProxyCheckButton({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
className="size-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
@@ -132,7 +132,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
<FiCheck className="size-3" />
)}
</Button>
</TooltipTrigger>
+5 -5
View File
@@ -108,13 +108,13 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
}}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer">
{t("proxies.exportDialog.json")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer">
{t("proxies.exportDialog.txt")}
@@ -154,9 +154,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
className="flex gap-2 items-center"
>
{copied ? (
<LuCheck className="w-4 h-4" />
<LuCheck className="size-4" />
) : (
<LuCopy className="w-4 h-4" />
<LuCopy className="size-4" />
)}
{copied
? t("proxies.exportDialog.copied")
@@ -167,7 +167,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
<LuDownload className="w-4 h-4" />
<LuDownload className="size-4" />
{t("common.buttons.download")}
</RippleButton>
</DialogFooter>
+1 -1
View File
@@ -315,7 +315,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("proxies.importDialog.dropzonePrompt")}
<br />
File diff suppressed because it is too large Load Diff
+14 -24
View File
@@ -236,9 +236,11 @@ interface RailItem {
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
interface MoreMenuItem {
@@ -255,18 +257,6 @@ const MORE_ITEMS: MoreMenuItem[] = [
labelKey: "rail.more.importProfile",
hintKey: "rail.more.importProfileHint",
},
{
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) {
@@ -290,7 +280,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
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"
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
@@ -322,12 +312,12 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
"animate-[wiggle_0.3s_ease-in-out]",
)}
>
<Logo className="w-5 h-5 will-change-transform" />
<Logo className="size-5 will-change-transform" />
</span>
</span>
</button>
) : (
<div className="w-7 h-7" />
<div className="size-7" />
)}
<div className="w-5 h-px bg-border my-1" />
@@ -345,7 +335,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
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",
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
@@ -357,7 +347,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="w-3.5 h-3.5" />
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
@@ -377,13 +367,13 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
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",
"grid place-items-center size-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" />
<GoKebabHorizontal className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
@@ -399,7 +389,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
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",
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
@@ -411,7 +401,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<GoGear className="w-3.5 h-3.5" />
<GoGear className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
@@ -438,8 +428,8 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
}}
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 className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="size-3" />
</span>
<span className="flex flex-col min-w-0">
<span className="text-xs font-medium text-foreground truncate">
+7 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
@@ -44,6 +44,7 @@ export function ReleaseTypeSelector({
}: ReleaseTypeSelectorProps) {
const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false);
const listboxId = useId();
const effectivePlaceholder =
placeholder ?? t("releaseTypeSelector.placeholder");
@@ -91,13 +92,14 @@ export function ReleaseTypeSelector({
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
aria-controls={listboxId}
className="justify-between w-full"
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
<LuChevronsUpDown className="ml-2 size-4 opacity-50 shrink-0" />
</RippleButton>
</PopoverTrigger>
<PopoverContent className="p-0">
<PopoverContent id={listboxId} className="p-0">
<Command>
<CommandEmpty>
{t("releaseTypeSelector.noReleaseTypes")}
@@ -126,7 +128,7 @@ export function ReleaseTypeSelector({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedReleaseType === option.type
? "opacity-100"
: "opacity-0",
@@ -187,7 +189,7 @@ export function ReleaseTypeSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
<LuDownload className="mr-2 size-4" />
{isDownloading
? t("releaseTypeSelector.downloading")
: t("releaseTypeSelector.downloadBrowser")}
+175 -13
View File
@@ -24,6 +24,7 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -131,6 +132,10 @@ export function SettingsDialog({
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
const [systemInfo, setSystemInfo] = useState<{
app_version: string;
os: string;
@@ -164,9 +169,9 @@ export function SettingsDialog({
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-4 h-4" />;
return <BsMic className="size-4" />;
case "camera":
return <BsCamera className="w-4 h-4" />;
return <BsCamera className="size-4" />;
}
}, []);
@@ -737,7 +742,7 @@ export function SettingsDialog({
<button
type="button"
aria-label={label}
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
className="size-8 rounded-md border shadow-sm cursor-pointer"
style={{ backgroundColor: colorValue }}
/>
</PopoverTrigger>
@@ -886,7 +891,7 @@ export function SettingsDialog({
key={permission.permission_type}
className="flex justify-between items-center p-3 rounded-lg border"
>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-x-3">
{getPermissionIcon(permission.permission_type)}
<div>
<div className="text-sm font-medium">
@@ -899,7 +904,7 @@ export function SettingsDialog({
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
{getStatusBadge(permission.isGranted)}
{!permission.isGranted && (
<LoadingButton
@@ -990,10 +995,22 @@ export function SettingsDialog({
{t("settings.encryption.passwordSetDescription")}
</span>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setVerifyE2ePassword("");
setIsVerifyE2eOpen(true);
}}
>
{t("settings.encryption.validatePassword")}
</Button>
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setHasE2ePassword(false);
setE2ePassword("");
@@ -1003,22 +1020,41 @@ export function SettingsDialog({
>
{t("settings.encryption.changePassword")}
</Button>
<Button
<LoadingButton
variant="destructive"
size="sm"
isLoading={isRemovingE2e}
onClick={async () => {
setIsRemovingE2e(true);
try {
// Await the rollover so the user sees an error if
// re-syncing fails. Previously the rollover was
// fire-and-forget (`void invoke(...)`) which left
// half-removed state on screen with no feedback —
// the source of issue #360 "completely bugged".
await invoke("delete_e2e_password");
setHasE2ePassword(false);
try {
await invoke(
"rollover_encryption_for_all_entities",
);
} catch (rolloverErr) {
console.error(
"Rollover after password removal failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(t("settings.encryption.removed"));
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
setIsRemovingE2e(false);
}
}}
>
{t("settings.encryption.removePassword")}
</Button>
</LoadingButton>
</div>
</div>
) : (
@@ -1065,10 +1101,22 @@ export function SettingsDialog({
setHasE2ePassword(true);
setE2ePassword("");
setE2ePasswordConfirm("");
try {
// Await rollover so any failure surfaces to the
// user instead of being lost via fire-and-forget.
// Without this, "change password" leaves entities
// half-re-encrypted with no visible error.
await invoke("rollover_encryption_for_all_entities");
} catch (rolloverErr) {
console.error(
"Rollover after password set failed:",
rolloverErr,
);
showErrorToast(String(rolloverErr));
}
showSuccessToast(
t("settings.encryption.passwordSaved"),
);
void invoke("rollover_encryption_for_all_entities");
} catch (error) {
showErrorToast(String(error));
} finally {
@@ -1089,7 +1137,23 @@ export function SettingsDialog({
</Label>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
{trialStatus?.type === "Active" ? (
{cloudUser != null && cloudUser.plan !== "free" ? (
// Paid Donut plan supersedes the local commercial trial —
// the trial only exists to gate commercial use until the
// user subscribes. Showing "Trial expired" to a paying
// customer reads like a billing error, so swap in a
// subscription-active badge instead.
<div className="space-y-1">
<p className="text-sm font-medium text-success">
{t("settings.commercial.subscriptionActive", {
plan: cloudUser.plan,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("settings.commercial.subscriptionActiveDescription")}
</p>
</div>
) : trialStatus?.type === "Active" ? (
<div className="space-y-1">
<p className="text-sm font-medium">
{t("settings.commercial.trialActive", {
@@ -1121,7 +1185,7 @@ export function SettingsDialog({
</Label>
{!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="disable-auto-updates"
checked={settings.disable_auto_updates ?? false}
@@ -1143,7 +1207,7 @@ export function SettingsDialog({
</div>
)}
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="keep-decrypted-profiles-in-ram"
checked={settings.keep_decrypted_profiles_in_ram ?? false}
@@ -1268,6 +1332,104 @@ export function SettingsDialog({
isOpen={dnsBlocklistDialogOpen}
onClose={() => setDnsBlocklistDialogOpen(false)}
/>
<Dialog
open={isVerifyE2eOpen}
onOpenChange={(open) => {
if (!isVerifyingE2e) {
setIsVerifyE2eOpen(open);
if (!open) setVerifyE2ePassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.encryption.validateDialog.title")}
</DialogTitle>
<DialogDescription>
{t("settings.encryption.validateDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
type="password"
placeholder={t("settings.encryption.passwordPlaceholder")}
value={verifyE2ePassword}
autoFocus
onChange={(e) => setVerifyE2ePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
e.preventDefault();
void (async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
})();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifyingE2e}
onClick={() => {
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<LoadingButton
isLoading={isVerifyingE2e}
disabled={verifyE2ePassword.length === 0}
onClick={async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
}}
>
{t("settings.encryption.validateDialog.submit")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -303,7 +303,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -323,7 +323,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location-advanced"
checked={isAutoLocationEnabled}
@@ -367,7 +367,7 @@ export function SharedCamoufoxConfigForm({
<div className="space-y-3">
<Label>{t("fingerprint.blockingOptions")}</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-images"
checked={config.block_images ?? false}
@@ -379,7 +379,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockImages")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc ?? false}
@@ -391,7 +391,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockWebRTC")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl ?? false}
@@ -1025,7 +1025,7 @@ export function SharedCamoufoxConfigForm({
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="battery-charging"
checked={fingerprintConfig["battery:charging"] ?? false}
@@ -1176,7 +1176,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint-auto"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -1199,7 +1199,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location"
checked={isAutoLocationEnabled}
+1 -1
View File
@@ -96,7 +96,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="py-4">
+8 -8
View File
@@ -248,7 +248,7 @@ export function SyncConfigDialog({
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-success" />
<div className="size-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
@@ -353,7 +353,7 @@ export function SyncConfigDialog({
<TabsContent value="cloud">
{isCloudLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -373,7 +373,7 @@ export function SyncConfigDialog({
<TabsContent value="self-hosted">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -419,9 +419,9 @@ export function SyncConfigDialog({
}
>
{showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuEyeOff className="size-4 text-muted-foreground hover:text-foreground" />
) : (
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuEye className="size-4 text-muted-foreground hover:text-foreground" />
)}
</button>
</TooltipTrigger>
@@ -434,19 +434,19 @@ export function SyncConfigDialog({
{connectionStatus === "testing" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.status.syncing")}
</div>
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-success" />
<div className="size-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-destructive" />
<div className="size-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
+20 -16
View File
@@ -108,24 +108,28 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Re-apply custom theme after mount
useEffect(() => {
if (!isLoading && theme === "custom") {
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
setTimeout(() => {
void reapplyCustomTheme();
}, 100);
} else if (!isLoading) {
if (isLoading) return;
if (theme !== "custom") {
clearThemeColors();
return;
}
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
const handle = window.setTimeout(() => {
void reapplyCustomTheme();
}, 100);
return () => {
window.clearTimeout(handle);
};
}, [isLoading, theme]);
// Listen for system theme changes when in "system" mode
+5 -4
View File
@@ -23,6 +23,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -398,7 +399,7 @@ export function TrafficDetailsDialog({
<div className="flex items-center justify-center gap-6 mt-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
@@ -407,7 +408,7 @@ export function TrafficDetailsDialog({
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
@@ -590,7 +591,7 @@ export function TrafficDetailsDialog({
<h3 className="text-sm font-medium mb-2">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
<FadingScrollArea className="p-3 max-h-[120px]">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
@@ -601,7 +602,7 @@ export function TrafficDetailsDialog({
</span>
))}
</div>
</div>
</FadingScrollArea>
</div>
)}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { motion } from "motion/react";
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
const MotionThumb = motion.create(SwitchPrimitive.Thumb);
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
/**
* Toggle switch with a thumb that slides between the off (left) and on
* (right) positions and squashes wider while pressed. Animated via Framer
* Motion no layout shift when the parent's width changes, and the
* pressed state is purely visual so external onCheckedChange semantics
* stay identical to a Radix Switch.
*/
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
return (
<SwitchPrimitive.Root
data-slot="animated-switch"
className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
"bg-input data-[state=checked]:bg-primary",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<MotionThumb
data-slot="animated-switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-sm ring-0",
"bg-background data-[state=checked]:bg-primary-foreground",
)}
layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }}
style={{ marginLeft: 2, marginRight: 2 }}
/>
</SwitchPrimitive.Root>
);
}
export type { AnimatedSwitchProps };
export { AnimatedSwitch };
+156
View File
@@ -0,0 +1,156 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { motion } from "motion/react";
import * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/lib/utils";
interface AnimatedTabsContextValue {
activeValue: string | undefined;
hoveredValue: string | null;
setHoveredValue: (value: string | null) => void;
indicatorId: string;
}
const AnimatedTabsContext =
React.createContext<AnimatedTabsContextValue | null>(null);
function useAnimatedTabs() {
const ctx = React.useContext(AnimatedTabsContext);
if (!ctx) {
throw new Error(
"AnimatedTabsTrigger must be rendered inside <AnimatedTabs>",
);
}
return ctx;
}
type AnimatedTabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function AnimatedTabs({
value: valueProp,
defaultValue,
onValueChange,
children,
...props
}: AnimatedTabsProps) {
const [activeValue, setActiveValue] = useControlledState({
value: valueProp,
defaultValue,
onChange: onValueChange,
});
const [hoveredValue, setHoveredValue] = React.useState<string | null>(null);
const indicatorId = React.useId();
return (
<AnimatedTabsContext.Provider
value={{
activeValue,
hoveredValue,
setHoveredValue,
indicatorId,
}}
>
<TabsPrimitive.Root
data-slot="animated-tabs"
value={activeValue}
defaultValue={defaultValue}
onValueChange={setActiveValue}
{...props}
>
{children}
</TabsPrimitive.Root>
</AnimatedTabsContext.Provider>
);
}
type AnimatedTabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
function AnimatedTabsList({
className,
onMouseLeave,
...props
}: AnimatedTabsListProps) {
const { setHoveredValue } = useAnimatedTabs();
return (
<TabsPrimitive.List
data-slot="animated-tabs-list"
className={cn(
"relative inline-flex items-center gap-1 rounded-md p-0",
className,
)}
onMouseLeave={(event) => {
setHoveredValue(null);
onMouseLeave?.(event);
}}
{...props}
/>
);
}
type AnimatedTabsTriggerProps = React.ComponentProps<
typeof TabsPrimitive.Trigger
>;
function AnimatedTabsTrigger({
value,
className,
children,
onMouseEnter,
...props
}: AnimatedTabsTriggerProps) {
const { activeValue, hoveredValue, setHoveredValue, indicatorId } =
useAnimatedTabs();
// The visible pill follows hover when present, otherwise sits on the
// active tab. Framer's `layoutId` handles the slide animation between
// mounted instances; only the trigger whose `value` matches `shownValue`
// renders the indicator, so the transition is a single-element move.
const shownValue = hoveredValue ?? activeValue;
const showIndicator = shownValue === value;
const isActive = activeValue === value;
return (
<TabsPrimitive.Trigger
data-slot="animated-tabs-trigger"
value={value}
onMouseEnter={(event) => {
setHoveredValue(value);
onMouseEnter?.(event);
}}
className={cn(
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors duration-150",
"text-muted-foreground hover:text-foreground",
isActive && "text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{showIndicator && (
<motion.span
layoutId={`animated-tabs-indicator-${indicatorId}`}
className="absolute inset-0 -z-10 rounded-md bg-accent"
transition={{ type: "spring", stiffness: 360, damping: 32 }}
/>
)}
{children}
</TabsPrimitive.Trigger>
);
}
const AnimatedTabsContent = TabsPrimitive.Content;
export type {
AnimatedTabsListProps,
AnimatedTabsProps,
AnimatedTabsTriggerProps,
};
export {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
};
+2 -2
View File
@@ -229,7 +229,7 @@ const ChartTooltipContent = React.forwardRef<
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"size-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
@@ -321,7 +321,7 @@ const ChartLegendContent = React.forwardRef<
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
className="size-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
+3 -3
View File
@@ -245,7 +245,7 @@ export const ColorPickerSelection = memo(
{...props}
>
<div
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
className="absolute size-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${positionX * 100}%`,
top: `${positionY * 100}%`,
@@ -281,7 +281,7 @@ export const ColorPickerHue = ({
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
<Slider.Range className="absolute h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
@@ -315,7 +315,7 @@ export const ColorPickerAlpha = ({
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
+5 -3
View File
@@ -47,6 +47,7 @@ export function Combobox({
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const listboxId = React.useId();
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
const resolvedSearchPlaceholder =
@@ -59,16 +60,17 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
className={cn("w-full justify-between", className)}
>
{value
? options.find((option) => option.value === value)?.label
: resolvedPlaceholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<PopoverContent id={listboxId} className="w-full p-0">
<Command>
<CommandInput placeholder={resolvedSearchPlaceholder} />
<CommandList>
@@ -85,7 +87,7 @@ export function Combobox({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
+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-150 ${
className={`size-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-150 ${
className={`absolute inset-0 m-auto size-4 text-foreground transition-all duration-150 ${
copied ? "scale-100" : "scale-0"
}`}
/>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { type HTMLAttributes, useRef } from "react";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { cn } from "@/lib/utils";
export type FadingScrollAreaProps = HTMLAttributes<HTMLDivElement>;
/**
* Scrollable container with top/bottom fade overlays. The fades only become
* visible when the matching direction is actually scrollable. Use in place
* of `<div className="border rounded-md max-h-[...] overflow-auto">` for
* lists that should match the borderless aesthetic of the profile table.
*/
export function FadingScrollArea({
className,
children,
...props
}: FadingScrollAreaProps) {
const ref = useRef<HTMLDivElement>(null);
useScrollFade(ref);
return (
<div
ref={ref}
className={cn("overflow-y-auto scroll-fade", className)}
{...props}
>
{children}
</div>
);
}
+2 -2
View File
@@ -28,13 +28,13 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<LuCircle className="h-2.5 w-2.5 fill-current text-current" />
<LuCircle className="size-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
+4 -4
View File
@@ -70,18 +70,18 @@ export function VpnCheckButton({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
className="size-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="w-3 h-3 text-success" />
<FiCheck className="size-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
<FiCheck className="size-3" />
)}
</Button>
</TooltipTrigger>
+3 -3
View File
@@ -219,7 +219,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("vpns.import.dropzonePrompt")}
</p>
@@ -244,7 +244,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<LuShield className="w-8 h-8 text-primary" />
<LuShield className="size-8 text-primary" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
@@ -292,7 +292,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-success" />
<LuShield className="size-8 text-success" />
<div>
<div className="font-medium text-success">
{t("vpns.import.importedSuccess")}
+5 -5
View File
@@ -228,7 +228,7 @@ export function WayfernConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -248,7 +248,7 @@ export function WayfernConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location-advanced"
checked={isAutoLocationEnabled}
@@ -954,7 +954,7 @@ export function WayfernConfigForm({
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="battery-charging"
checked={fingerprintConfig.batteryCharging ?? false}
@@ -1133,7 +1133,7 @@ export function WayfernConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint-auto"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -1156,7 +1156,7 @@ export function WayfernConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location"
checked={isAutoLocationEnabled}
@@ -79,7 +79,7 @@ export function WindowResizeWarningDialog({
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="dont-show-again"
checked={dontShowAgain}
+11 -8
View File
@@ -38,6 +38,7 @@ export function useGroupEvents() {
// Initial load and event listeners setup
useEffect(() => {
let groupsUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
@@ -51,19 +52,13 @@ export function useGroupEvents() {
});
// Also listen for profile changes since groups show profile counts
const profilesUnlisten = await listen("profiles-changed", () => {
profilesUnlisten = await listen("profiles-changed", () => {
console.log(
"Received profiles-changed event, reloading groups for updated counts",
);
void loadGroups();
});
// Store both listeners for cleanup
groupsUnlisten = () => {
groupsUnlisten?.();
profilesUnlisten();
};
console.log("Group event listeners set up successfully");
} catch (err) {
console.error("Failed to setup group event listeners:", err);
@@ -79,9 +74,17 @@ export function useGroupEvents() {
void setupListeners();
// Cleanup listeners on unmount
// Cleanup listeners on unmount.
// NOTE: the previous version stored both unlisten fns by reassigning
// `groupsUnlisten` to a wrapper that called itself, which produced a
// `Maximum call stack size exceeded` crash whenever this effect tore
// down. React's reconciler then bailed out mid-commit and left stale
// overlay nodes in the DOM, blocking every subsequent click in the
// window. Holding the two unlisten fns in separate locals avoids both
// problems.
return () => {
if (groupsUnlisten) groupsUnlisten();
if (profilesUnlisten) profilesUnlisten();
};
}, [loadGroups]);
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "Downloading...",
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved"
"saved": "Saved",
"copied": "Copied"
},
"status": {
"active": "Active",
@@ -158,14 +159,24 @@
"passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners."
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners.",
"validatePassword": "Validate",
"validateDialog": {
"title": "Validate Encryption Password",
"description": "Enter your encryption password to verify it matches the one stored on this device.",
"submit": "Validate",
"matchToast": "Password is correct",
"mismatchToast": "Password does not match"
}
},
"commercial": {
"title": "Commercial License",
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
"trialActiveDescription": "Commercial use is free during the trial. When it ends, all features keep working — personal use stays free, only commercial use will require a license.",
"trialExpired": "Trial expired",
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license.",
"subscriptionActive": "Subscribed — {{plan}} plan",
"subscriptionActiveDescription": "Your Donut Browser subscription is active. Commercial use is licensed for the duration of your plan."
},
"advanced": {
"title": "Advanced",
@@ -374,6 +385,9 @@
"deleteFailed": "Failed to delete proxy",
"deleteTitle": "Delete Proxy",
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
"newProxy": "New proxy",
"newVpn": "New VPN",
"protocolCol": "Protocol",
"title": "Proxies & VPNs"
},
"add": "Add Proxy",
@@ -478,6 +492,13 @@
"continueButton": "Continue",
"doneButton": "Done",
"failed": "Failed to import proxies"
},
"bulkDelete": {
"proxiesTitle": "Delete Selected Proxies",
"proxiesDescription": "This action cannot be undone. This will permanently delete {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Delete Selected VPNs",
"vpnsDescription": "This action cannot be undone. This will permanently delete {{count}} VPN(s): {{names}}.",
"confirmButton": "Delete {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "Add Group",
"edit": "Edit Group",
"delete": "Delete Group",
"defaultGroup": "Default",
"defaultGroupNoGroup": "Default (No Group)",
"moveToDefault": "Move profiles to Default group",
"noGroupDescription": "Profiles without a group will appear in the \"Default\" group.",
"moveToDefault": "Remove profiles from group",
"noGroupDescription": "Profiles without a group appear in the \"All\" filter.",
"assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}",
"noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.",
@@ -519,7 +538,6 @@
"loadingProfiles": "Loading associated profiles...",
"associatedProfiles": "Associated Profiles ({{count}})",
"whatToDoWithProfiles": "What should happen to these profiles?",
"moveToDefaultOption": "Move profiles to Default group",
"deleteAlongWithGroup": "Delete profiles along with the group",
"noAssociatedProfiles": "This group has no associated profiles.",
"deleteGroup": "Delete Group",
@@ -528,7 +546,10 @@
"unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups",
"loading": "Loading groups...",
"all": "All"
"all": "All",
"noGroup": "No group",
"pageTitle": "Profile groups",
"pageDescription": "Profile groups let you organize browsers by client, environment, or use case. Sync groups across devices to share them."
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "Token copied",
"url": "MCP Server URL",
"urlCopied": "URL copied",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configures claude_desktop_config.json automatically",
"addToClaudeDesktop": "Add to Claude Desktop",
"removeFromClaudeDesktop": "Remove from Claude Desktop",
"addedToClaudeDesktop": "Added to Claude Desktop. Restart Claude Desktop and enable the extension in Settings.",
"removedFromClaudeDesktop": "Removed from Claude Desktop config. Please restart Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Add to Claude Code",
"removeFromClaudeCode": "Remove from Claude Code",
"addedToClaudeCode": "Added to Claude Code",
"removedFromClaudeCode": "Removed from Claude Code",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
"copyConfig": "Copy Configuration",
"clientsLabel": "Clients",
"connected": "Connected",
"add": "Add",
"addedToClient": "Added to {{name}}",
"removedFromClient": "Removed from {{name}}",
"removeAriaLabel": "Remove from {{name}}",
"category": {
"desktopApp": "Desktop app",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Editor ext"
}
},
"tabApi": "Local API",
"tabMcp": "MCP (AI Assistants)",
@@ -673,7 +695,9 @@
"mcpStarted": "MCP server started on port {{port}}",
"mcpStopped": "MCP server stopped",
"mcpToggleFailed": "Failed to toggle MCP server",
"openSettings": "Open Integrations Settings"
"openSettings": "Open Integrations Settings",
"apiRunningOn": "Running on",
"apiExampleRequest": "Example request"
},
"import": {
"title": "Import Profile",
@@ -1179,6 +1203,7 @@
"empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.",
"createGroup": "Create Group",
"newGroup": "New group",
"addToGroup": "Add extension...",
"removeFromGroup": "Remove from group",
"deleteGroup": "Delete group",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync",
"loadGroupsFailed": "Failed to load extension groups",
"assignGroupFailed": "Failed to assign extension group"
"assignGroupFailed": "Failed to assign extension group",
"bulkDelete": {
"extensionsTitle": "Delete extensions",
"extensionsDescription": "Delete {{count}} extensions? {{names}}",
"groupsTitle": "Delete extension groups",
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
"confirmButton": "Delete"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "Waiting to sync",
"errorWith": "Sync error: {{error}}",
"error": "Sync error",
"notSynced": "Not synced"
"notSynced": "Not synced",
"enable": "Enable sync",
"disable": "Disable sync",
"lockedInUse": "Sync is locked while in use by a synced profile",
"bulkToggle": "Toggle sync"
},
"groupManagement": {
"description": "Manage your profile groups",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
"editGroupTooltip": "Edit group",
"deleteGroupTooltip": "Delete group",
"loadFailed": "Failed to load groups"
"loadFailed": "Failed to load groups",
"bulkDelete": {
"title": "Delete groups",
"description": "Are you sure you want to delete {{count}} groups? {{names}}. Profiles will be moved to Default.",
"description_one": "Are you sure you want to delete {{count}} group? {{names}}. Profiles will be moved to Default.",
"confirmButton": "Delete groups"
}
},
"proxyAssignment": {
"title": "Assign Proxy / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "Set",
"change": "Change",
"remove": "Remove"
"remove": "Remove",
"validate": "Validate"
},
"verifyDialog": {
"title": "Validate Profile Password",
"description": "Enter the profile password to confirm it matches the one stored on disk.",
"submit": "Validate",
"matchToast": "Password is correct"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"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."
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
},
"rail": {
"profiles": "Profiles",
"proxies": "Proxies",
"extensions": "Extensions",
"groups": "Groups",
"settings": "Settings",
@@ -1754,18 +1803,17 @@
"label": "More",
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"integrations": "Integrations",
"integrationsHint": "Slack, MCP, automations",
"account": "Account",
"accountHint": "Cloud, billing, sign-in"
}
"importProfileHint": "Bring profiles from another tool"
},
"network": "Network",
"integrations": "Integrations",
"account": "Account"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Network",
"extensions": "Extensions",
"groups": "Groups",
"vpns": "VPNs",
"vpns": "Network",
"settings": "Settings",
"integrations": "Integrations",
"account": "Account",
@@ -1808,6 +1856,19 @@
"status": "Status",
"teamRole": "Team role",
"period": "Billing period"
},
"tabs": {
"account": "Account",
"selfHosted": "Self-hosted"
},
"selfHosted": {
"title": "Self-hosted sync server",
"description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.",
"disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.",
"connectionStatus": "Connection:",
"statusUnknown": "Untested",
"testConnection": "Test connection",
"disconnect": "Disconnect"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "Descargando...",
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado"
"saved": "Guardado",
"copied": "Copiado"
},
"status": {
"active": "Activo",
@@ -158,14 +159,24 @@
"passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos."
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar contraseña de cifrado",
"description": "Introduce tu contraseña de cifrado para verificar que coincide con la almacenada en este dispositivo.",
"submit": "Validar",
"matchToast": "La contraseña es correcta",
"mismatchToast": "La contraseña no coincide"
}
},
"commercial": {
"title": "Licencia Comercial",
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
"trialActiveDescription": "El uso comercial es gratuito durante la prueba. Al finalizar, todas las funciones siguen funcionando — el uso personal sigue siendo gratuito, solo el uso comercial requerirá una licencia.",
"trialExpired": "Prueba expirada",
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia.",
"subscriptionActive": "Suscrito — plan {{plan}}",
"subscriptionActiveDescription": "Tu suscripción a Donut Browser está activa. El uso comercial está autorizado mientras tu plan esté vigente."
},
"advanced": {
"title": "Avanzado",
@@ -374,6 +385,9 @@
"deleteFailed": "Error al eliminar el proxy",
"deleteTitle": "Eliminar proxy",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".",
"newProxy": "Nuevo proxy",
"newVpn": "Nueva VPN",
"protocolCol": "Protocolo",
"title": "Proxies y VPN"
},
"add": "Agregar Proxy",
@@ -478,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Hecho",
"failed": "Error al importar los proxies"
},
"bulkDelete": {
"proxiesTitle": "Eliminar proxies seleccionados",
"proxiesDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Eliminar VPN seleccionadas",
"vpnsDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Eliminar {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "Agregar Grupo",
"edit": "Editar Grupo",
"delete": "Eliminar Grupo",
"defaultGroup": "Predeterminado",
"defaultGroupNoGroup": "Predeterminado (Sin Grupo)",
"moveToDefault": "Mover perfiles al grupo Predeterminado",
"noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".",
"moveToDefault": "Quitar perfiles del grupo",
"noGroupDescription": "Los perfiles sin grupo aparecen en el filtro «Todos».",
"assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente",
"noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
@@ -519,7 +538,6 @@
"loadingProfiles": "Cargando perfiles asociados...",
"associatedProfiles": "Perfiles Asociados ({{count}})",
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
"deleteGroup": "Eliminar Grupo",
@@ -528,7 +546,10 @@
"unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sin grupo",
"pageTitle": "Grupos de perfiles",
"pageDescription": "Los grupos de perfiles te permiten organizar los navegadores por cliente, entorno o caso de uso. Sincroniza los grupos entre dispositivos para compartirlos."
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "Token copiado",
"url": "URL del servidor MCP",
"urlCopied": "URL copiada",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configura claude_desktop_config.json automáticamente",
"addToClaudeDesktop": "Agregar a Claude Desktop",
"removeFromClaudeDesktop": "Eliminar de Claude Desktop",
"addedToClaudeDesktop": "Agregado a Claude Desktop. Reinicia Claude Desktop y activa la extensión en Configuración.",
"removedFromClaudeDesktop": "Eliminado de la configuración de Claude Desktop. Reinicia Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Agregar a Claude Code",
"removeFromClaudeCode": "Eliminar de Claude Code",
"addedToClaudeCode": "Agregado a Claude Code",
"removedFromClaudeCode": "Eliminado de Claude Code",
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
"copyConfig": "Copiar Configuración",
"clientsLabel": "Clientes",
"connected": "Conectado",
"add": "Agregar",
"addedToClient": "Agregado a {{name}}",
"removedFromClient": "Eliminado de {{name}}",
"removeAriaLabel": "Eliminar de {{name}}",
"category": {
"desktopApp": "Aplicación de escritorio",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Extensión de editor"
}
},
"tabApi": "API local",
"tabMcp": "MCP (asistentes IA)",
@@ -673,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
"mcpStopped": "Servidor MCP detenido",
"mcpToggleFailed": "Error al alternar el servidor MCP",
"openSettings": "Abrir configuración de integraciones"
"openSettings": "Abrir configuración de integraciones",
"apiRunningOn": "Ejecutándose en",
"apiExampleRequest": "Solicitud de ejemplo"
},
"import": {
"title": "Importar Perfil",
@@ -1179,6 +1203,7 @@
"empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo",
"newGroup": "Nuevo grupo",
"addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización",
"loadGroupsFailed": "Error al cargar grupos de extensiones",
"assignGroupFailed": "Error al asignar grupo de extensiones"
"assignGroupFailed": "Error al asignar grupo de extensiones",
"bulkDelete": {
"extensionsTitle": "Eliminar extensiones",
"extensionsDescription": "¿Eliminar {{count}} extensiones? {{names}}",
"groupsTitle": "Eliminar grupos de extensiones",
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
"confirmButton": "Eliminar"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "En espera de sincronización",
"errorWith": "Error de sincronización: {{error}}",
"error": "Error de sincronización",
"notSynced": "Sin sincronizar"
"notSynced": "Sin sincronizar",
"enable": "Activar sincronización",
"disable": "Desactivar sincronización",
"lockedInUse": "La sincronización está bloqueada mientras un perfil sincronizado lo use",
"bulkToggle": "Alternar sincronización"
},
"groupManagement": {
"description": "Administra tus grupos de perfiles",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Eliminar grupo",
"loadFailed": "Error al cargar los grupos"
"loadFailed": "Error al cargar los grupos",
"bulkDelete": {
"title": "Eliminar grupos",
"description": "¿Estás seguro de que quieres eliminar {{count}} grupos? {{names}}. Los perfiles se moverán a Predeterminado.",
"description_one": "¿Estás seguro de que quieres eliminar {{count}} grupo? {{names}}. Los perfiles se moverán a Predeterminado.",
"confirmButton": "Eliminar grupos"
}
},
"proxyAssignment": {
"title": "Asignar proxy / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "Establecer",
"change": "Cambiar",
"remove": "Quitar"
"remove": "Quitar",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar contraseña del perfil",
"description": "Introduce la contraseña del perfil para confirmar que coincide con la almacenada en disco.",
"submit": "Validar",
"matchToast": "La contraseña es correcta"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"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."
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
},
"rail": {
"profiles": "Perfiles",
"proxies": "Proxies",
"extensions": "Extensiones",
"groups": "Grupos",
"settings": "Ajustes",
@@ -1754,18 +1803,17 @@
"label": "Más",
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"integrations": "Integraciones",
"integrationsHint": "Slack, MCP, automatizaciones",
"account": "Cuenta",
"accountHint": "Nube, facturación, sesión"
}
"importProfileHint": "Trae perfiles de otra herramienta"
},
"network": "Red",
"integrations": "Integraciones",
"account": "Cuenta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Red",
"extensions": "Extensiones",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Red",
"settings": "Ajustes",
"integrations": "Integraciones",
"account": "Cuenta",
@@ -1808,6 +1856,19 @@
"status": "Estado",
"teamRole": "Rol en el equipo",
"period": "Período"
},
"tabs": {
"account": "Cuenta",
"selfHosted": "Autoalojado"
},
"selfHosted": {
"title": "Servidor de sincronización autoalojado",
"description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.",
"disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.",
"connectionStatus": "Conexión:",
"statusUnknown": "Sin probar",
"testConnection": "Probar conexión",
"disconnect": "Desconectar"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "Téléchargement...",
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré"
"saved": "Enregistré",
"copied": "Copié"
},
"status": {
"active": "Actif",
@@ -158,14 +159,24 @@
"passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe."
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe.",
"validatePassword": "Valider",
"validateDialog": {
"title": "Valider le mot de passe de chiffrement",
"description": "Saisissez votre mot de passe de chiffrement pour vérifier qu'il correspond à celui enregistré sur cet appareil.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct",
"mismatchToast": "Le mot de passe ne correspond pas"
}
},
"commercial": {
"title": "Licence commerciale",
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant l'essai. À l'expiration, toutes les fonctionnalités continuent de fonctionner — l'utilisation personnelle reste gratuite, seule l'utilisation commerciale nécessitera une licence.",
"trialExpired": "Essai expiré",
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence.",
"subscriptionActive": "Abonné — formule {{plan}}",
"subscriptionActiveDescription": "Votre abonnement Donut Browser est actif. L'usage commercial est licencié pendant toute la durée de votre formule."
},
"advanced": {
"title": "Avancé",
@@ -374,6 +385,9 @@
"deleteFailed": "Échec de la suppression du proxy",
"deleteTitle": "Supprimer le proxy",
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.",
"newProxy": "Nouveau proxy",
"newVpn": "Nouveau VPN",
"protocolCol": "Protocole",
"title": "Proxys et VPN"
},
"add": "Ajouter un proxy",
@@ -478,6 +492,13 @@
"continueButton": "Continuer",
"doneButton": "Terminé",
"failed": "Échec de l'import des proxys"
},
"bulkDelete": {
"proxiesTitle": "Supprimer les proxys sélectionnés",
"proxiesDescription": "Cette action est irréversible. {{count}} proxy(s) seront définitivement supprimés : {{names}}.",
"vpnsTitle": "Supprimer les VPN sélectionnés",
"vpnsDescription": "Cette action est irréversible. {{count}} VPN(s) seront définitivement supprimés : {{names}}.",
"confirmButton": "Supprimer {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "Ajouter un groupe",
"edit": "Modifier le groupe",
"delete": "Supprimer le groupe",
"defaultGroup": "Par défaut",
"defaultGroupNoGroup": "Par défaut (Aucun groupe)",
"moveToDefault": "Déplacer les profils vers le groupe Par défaut",
"noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».",
"moveToDefault": "Retirer les profils du groupe",
"noGroupDescription": "Les profils sans groupe apparaissent dans le filtre « Tous ».",
"assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès",
"noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.",
@@ -519,7 +538,6 @@
"loadingProfiles": "Chargement des profils associés...",
"associatedProfiles": "Profils Associés ({{count}})",
"whatToDoWithProfiles": "Que faire de ces profils ?",
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
"deleteAlongWithGroup": "Supprimer les profils avec le groupe",
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
"deleteGroup": "Supprimer le Groupe",
@@ -528,7 +546,10 @@
"unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes...",
"all": "Tous"
"all": "Tous",
"noGroup": "Aucun groupe",
"pageTitle": "Groupes de profils",
"pageDescription": "Les groupes de profils vous permettent d'organiser les navigateurs par client, environnement ou cas d'usage. Synchronisez les groupes entre appareils pour les partager."
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "Jeton copié",
"url": "URL du serveur MCP",
"urlCopied": "URL copiée",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configure claude_desktop_config.json automatiquement",
"addToClaudeDesktop": "Ajouter à Claude Desktop",
"removeFromClaudeDesktop": "Supprimer de Claude Desktop",
"addedToClaudeDesktop": "Ajouté à Claude Desktop. Redémarrez Claude Desktop et activez l'extension dans les Paramètres.",
"removedFromClaudeDesktop": "Supprimé de la configuration de Claude Desktop. Veuillez redémarrer Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Ajouter à Claude Code",
"removeFromClaudeCode": "Supprimer de Claude Code",
"addedToClaudeCode": "Ajouté à Claude Code",
"removedFromClaudeCode": "Supprimé de Claude Code",
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
"copyConfig": "Copier la configuration",
"clientsLabel": "Clients",
"connected": "Connecté",
"add": "Ajouter",
"addedToClient": "Ajouté à {{name}}",
"removedFromClient": "Supprimé de {{name}}",
"removeAriaLabel": "Supprimer de {{name}}",
"category": {
"desktopApp": "Application bureau",
"cli": "CLI",
"editor": "Éditeur",
"editorExt": "Ext. d'éditeur"
}
},
"tabApi": "API locale",
"tabMcp": "MCP (Assistants IA)",
@@ -673,7 +695,9 @@
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
"mcpStopped": "Serveur MCP arrêté",
"mcpToggleFailed": "Échec du basculement du serveur MCP",
"openSettings": "Ouvrir les paramètres d'intégrations"
"openSettings": "Ouvrir les paramètres d'intégrations",
"apiRunningOn": "En cours sur",
"apiExampleRequest": "Exemple de requête"
},
"import": {
"title": "Importer un profil",
@@ -1179,6 +1203,7 @@
"empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe",
"newGroup": "Nouveau groupe",
"addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation",
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions",
"bulkDelete": {
"extensionsTitle": "Supprimer les extensions",
"extensionsDescription": "Supprimer {{count}} extensions ? {{names}}",
"groupsTitle": "Supprimer les groupes d'extensions",
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
"confirmButton": "Supprimer"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "En attente de synchronisation",
"errorWith": "Erreur de synchronisation : {{error}}",
"error": "Erreur de synchronisation",
"notSynced": "Non synchronisé"
"notSynced": "Non synchronisé",
"enable": "Activer la synchronisation",
"disable": "Désactiver la synchronisation",
"lockedInUse": "La synchronisation est verrouillée tant qu'un profil synchronisé l'utilise",
"bulkToggle": "Basculer la synchronisation"
},
"groupManagement": {
"description": "Gérez vos groupes de profils",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
"editGroupTooltip": "Modifier le groupe",
"deleteGroupTooltip": "Supprimer le groupe",
"loadFailed": "Échec du chargement des groupes"
"loadFailed": "Échec du chargement des groupes",
"bulkDelete": {
"title": "Supprimer les groupes",
"description": "Êtes-vous sûr de vouloir supprimer {{count}} groupes ? {{names}}. Les profils seront déplacés vers Par défaut.",
"description_one": "Êtes-vous sûr de vouloir supprimer {{count}} groupe ? {{names}}. Les profils seront déplacés vers Par défaut.",
"confirmButton": "Supprimer les groupes"
}
},
"proxyAssignment": {
"title": "Assigner un proxy / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "Définir",
"change": "Modifier",
"remove": "Supprimer"
"remove": "Supprimer",
"validate": "Valider"
},
"verifyDialog": {
"title": "Valider le mot de passe du profil",
"description": "Saisissez le mot de passe du profil pour vérifier qu'il correspond à celui enregistré sur le disque.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"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."
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
},
"rail": {
"profiles": "Profils",
"proxies": "Proxys",
"extensions": "Extensions",
"groups": "Groupes",
"settings": "Paramètres",
@@ -1754,18 +1803,17 @@
"label": "Plus",
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"integrations": "Intégrations",
"integrationsHint": "Slack, MCP, automatisations",
"account": "Compte",
"accountHint": "Cloud, facturation, connexion"
}
"importProfileHint": "Importer depuis un autre outil"
},
"network": "Réseau",
"integrations": "Intégrations",
"account": "Compte"
},
"pageTitle": {
"proxies": "Proxys",
"proxies": "Réseau",
"extensions": "Extensions",
"groups": "Groupes",
"vpns": "VPN",
"vpns": "Réseau",
"settings": "Paramètres",
"integrations": "Intégrations",
"account": "Compte",
@@ -1808,6 +1856,19 @@
"status": "Statut",
"teamRole": "Rôle d’équipe",
"period": "Période"
},
"tabs": {
"account": "Compte",
"selfHosted": "Auto-hébergé"
},
"selfHosted": {
"title": "Serveur de synchronisation auto-hébergé",
"description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.",
"disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.",
"connectionStatus": "Connexion :",
"statusUnknown": "Non testé",
"testConnection": "Tester la connexion",
"disconnect": "Déconnecter"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "ダウンロード中...",
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました"
"saved": "保存しました",
"copied": "コピーしました"
},
"status": {
"active": "アクティブ",
@@ -158,14 +159,24 @@
"passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります",
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。"
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。",
"validatePassword": "確認",
"validateDialog": {
"title": "暗号化パスワードを確認",
"description": "このデバイスに保存されているパスワードと一致するか、暗号化パスワードを入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました",
"mismatchToast": "パスワードが一致しません"
}
},
"commercial": {
"title": "商用ライセンス",
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
"trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。",
"trialExpired": "トライアル期限切れ",
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。",
"subscriptionActive": "サブスクリプション中 — {{plan}} プラン",
"subscriptionActiveDescription": "Donut Browser のサブスクリプションが有効です。プランの期間中、商用利用がライセンスされます。"
},
"advanced": {
"title": "詳細設定",
@@ -374,6 +385,9 @@
"deleteFailed": "プロキシの削除に失敗しました",
"deleteTitle": "プロキシを削除",
"deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。",
"newProxy": "新しいプロキシ",
"newVpn": "新しいVPN",
"protocolCol": "プロトコル",
"title": "プロキシと VPN"
},
"add": "プロキシを追加",
@@ -478,6 +492,13 @@
"continueButton": "続ける",
"doneButton": "完了",
"failed": "プロキシのインポートに失敗しました"
},
"bulkDelete": {
"proxiesTitle": "選択したプロキシを削除",
"proxiesDescription": "この操作は取り消せません。{{count}} 件のプロキシを完全に削除します: {{names}}",
"vpnsTitle": "選択したVPNを削除",
"vpnsDescription": "この操作は取り消せません。{{count}} 件のVPNを完全に削除します: {{names}}",
"confirmButton": "{{count}} 件を削除"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "グループを追加",
"edit": "グループを編集",
"delete": "グループを削除",
"defaultGroup": "デフォルト",
"defaultGroupNoGroup": "デフォルト(グループなし)",
"moveToDefault": "プロファイルをデフォルトグループに移動",
"noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。",
"moveToDefault": "プロファイルをグループから外す",
"noGroupDescription": "グループに属さないプロファイルは「すべて」フィルターに表示されます。",
"assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました",
"noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
@@ -519,7 +538,6 @@
"loadingProfiles": "関連するプロファイルを読み込んでいます...",
"associatedProfiles": "関連プロファイル ({{count}})",
"whatToDoWithProfiles": "これらのプロファイルをどうしますか?",
"moveToDefaultOption": "プロファイルをデフォルトグループに移動",
"deleteAlongWithGroup": "プロファイルもグループと一緒に削除",
"noAssociatedProfiles": "このグループには関連するプロファイルがありません。",
"deleteGroup": "グループを削除",
@@ -528,7 +546,10 @@
"unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中...",
"all": "すべて"
"all": "すべて",
"noGroup": "グループなし",
"pageTitle": "プロファイルグループ",
"pageDescription": "プロファイルグループを使うと、クライアント、環境、用途ごとにブラウザを整理できます。デバイス間でグループを同期して共有しましょう。"
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "トークンをコピーしました",
"url": "MCPサーバーURL",
"urlCopied": "URLをコピーしました",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "claude_desktop_config.json を自動的に設定します",
"addToClaudeDesktop": "Claude Desktop に追加",
"removeFromClaudeDesktop": "Claude Desktop から削除",
"addedToClaudeDesktop": "Claude Desktop に追加しました。Claude Desktop を再起動し、設定で拡張機能を有効にしてください。",
"removedFromClaudeDesktop": "Claude Desktop の設定から削除しました。Claude Desktop を再起動してください。",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Claude Code に追加",
"removeFromClaudeCode": "Claude Code から削除",
"addedToClaudeCode": "Claude Code に追加しました",
"removedFromClaudeCode": "Claude Code から削除しました",
"config": "MCP設定",
"copyConfig": "設定をコピー"
"copyConfig": "設定をコピー",
"clientsLabel": "クライアント",
"connected": "接続済み",
"add": "追加",
"addedToClient": "{{name}} に追加しました",
"removedFromClient": "{{name}} から削除しました",
"removeAriaLabel": "{{name}} から削除",
"category": {
"desktopApp": "デスクトップアプリ",
"cli": "CLI",
"editor": "エディタ",
"editorExt": "エディタ拡張"
}
},
"tabApi": "ローカル API",
"tabMcp": "MCP (AI アシスタント)",
@@ -673,7 +695,9 @@
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
"mcpStopped": "MCP サーバーを停止しました",
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
"openSettings": "統合設定を開く"
"openSettings": "統合設定を開く",
"apiRunningOn": "実行中",
"apiExampleRequest": "リクエスト例"
},
"import": {
"title": "プロファイルをインポート",
@@ -1179,6 +1203,7 @@
"empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成",
"newGroup": "新しいグループ",
"addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする",
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました",
"bulkDelete": {
"extensionsTitle": "拡張機能を削除",
"extensionsDescription": "{{count}}件の拡張機能を削除しますか? {{names}}",
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "同期待ち",
"errorWith": "同期エラー: {{error}}",
"error": "同期エラー",
"notSynced": "未同期"
"notSynced": "未同期",
"enable": "同期を有効化",
"disable": "同期を無効化",
"lockedInUse": "同期されたプロファイルが使用中のため、同期は無効化できません",
"bulkToggle": "同期を切り替え"
},
"groupManagement": {
"description": "プロファイルのグループを管理します",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません",
"editGroupTooltip": "グループを編集",
"deleteGroupTooltip": "グループを削除",
"loadFailed": "グループの読み込みに失敗しました"
"loadFailed": "グループの読み込みに失敗しました",
"bulkDelete": {
"title": "グループを削除",
"description": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"description_one": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"confirmButton": "グループを削除"
}
},
"proxyAssignment": {
"title": "プロキシ / VPN を割り当てる",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "設定",
"change": "変更",
"remove": "削除"
"remove": "削除",
"validate": "確認"
},
"verifyDialog": {
"title": "プロファイルパスワードを確認",
"description": "ディスクに保存されているプロファイルパスワードと一致するか入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"internal": "問題が発生しました: {{detail}}",
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。"
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
},
"rail": {
"profiles": "プロファイル",
"proxies": "プロキシ",
"extensions": "拡張機能",
"groups": "グループ",
"settings": "設定",
@@ -1754,18 +1803,17 @@
"label": "その他",
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"integrations": "連携",
"integrationsHint": "Slack、MCP、自動化",
"account": "アカウント",
"accountHint": "クラウド、請求、サインイン"
}
"importProfileHint": "別のツールから取り込む"
},
"network": "ネットワーク",
"integrations": "連携",
"account": "アカウント"
},
"pageTitle": {
"proxies": "プロキシ",
"proxies": "ネットワーク",
"extensions": "拡張機能",
"groups": "グループ",
"vpns": "VPN",
"vpns": "ネットワーク",
"settings": "設定",
"integrations": "連携",
"account": "アカウント",
@@ -1808,6 +1856,19 @@
"status": "ステータス",
"teamRole": "チームロール",
"period": "請求周期"
},
"tabs": {
"account": "アカウント",
"selfHosted": "セルフホスト"
},
"selfHosted": {
"title": "セルフホスト同期サーバー",
"description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。",
"disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。",
"connectionStatus": "接続:",
"statusUnknown": "未テスト",
"testConnection": "接続をテスト",
"disconnect": "切断"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "Baixando...",
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo"
"saved": "Salvo",
"copied": "Copiado"
},
"status": {
"active": "Ativo",
@@ -158,14 +159,24 @@
"passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres",
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe."
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar senha de criptografia",
"description": "Digite sua senha de criptografia para verificar se corresponde à armazenada neste dispositivo.",
"submit": "Validar",
"matchToast": "A senha está correta",
"mismatchToast": "A senha não corresponde"
}
},
"commercial": {
"title": "Licença Comercial",
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
"trialActiveDescription": "O uso comercial é gratuito durante o teste. Após o término, todos os recursos continuam funcionando — o uso pessoal permanece gratuito, apenas o uso comercial exigirá uma licença.",
"trialExpired": "Teste expirado",
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença.",
"subscriptionActive": "Assinado — plano {{plan}}",
"subscriptionActiveDescription": "Sua assinatura do Donut Browser está ativa. O uso comercial está licenciado enquanto seu plano estiver vigente."
},
"advanced": {
"title": "Avançado",
@@ -374,6 +385,9 @@
"deleteFailed": "Falha ao excluir proxy",
"deleteTitle": "Excluir proxy",
"deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.",
"newProxy": "Novo proxy",
"newVpn": "Nova VPN",
"protocolCol": "Protocolo",
"title": "Proxies e VPNs"
},
"add": "Adicionar Proxy",
@@ -478,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Concluído",
"failed": "Falha ao importar proxies"
},
"bulkDelete": {
"proxiesTitle": "Excluir proxies selecionados",
"proxiesDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Excluir VPNs selecionadas",
"vpnsDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Excluir {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "Adicionar Grupo",
"edit": "Editar Grupo",
"delete": "Excluir Grupo",
"defaultGroup": "Padrão",
"defaultGroupNoGroup": "Padrão (Sem Grupo)",
"moveToDefault": "Mover perfis para o grupo Padrão",
"noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".",
"moveToDefault": "Remover perfis do grupo",
"noGroupDescription": "Perfis sem grupo aparecem no filtro \"Todos\".",
"assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso",
"noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.",
@@ -519,7 +538,6 @@
"loadingProfiles": "Carregando perfis associados...",
"associatedProfiles": "Perfis Associados ({{count}})",
"whatToDoWithProfiles": "O que fazer com esses perfis?",
"moveToDefaultOption": "Mover perfis para o grupo Padrão",
"deleteAlongWithGroup": "Excluir perfis junto com o grupo",
"noAssociatedProfiles": "Este grupo não tem perfis associados.",
"deleteGroup": "Excluir Grupo",
@@ -528,7 +546,10 @@
"unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sem grupo",
"pageTitle": "Grupos de perfis",
"pageDescription": "Os grupos de perfis permitem organizar os navegadores por cliente, ambiente ou caso de uso. Sincronize grupos entre dispositivos para compartilhá-los."
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "Token copiado",
"url": "URL do servidor MCP",
"urlCopied": "URL copiada",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configura claude_desktop_config.json automaticamente",
"addToClaudeDesktop": "Adicionar ao Claude Desktop",
"removeFromClaudeDesktop": "Remover do Claude Desktop",
"addedToClaudeDesktop": "Adicionado ao Claude Desktop. Reinicie o Claude Desktop e ative a extensão em Configurações.",
"removedFromClaudeDesktop": "Removido da configuração do Claude Desktop. Reinicie o Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Adicionar ao Claude Code",
"removeFromClaudeCode": "Remover do Claude Code",
"addedToClaudeCode": "Adicionado ao Claude Code",
"removedFromClaudeCode": "Removido do Claude Code",
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
"copyConfig": "Copiar Configuração",
"clientsLabel": "Clientes",
"connected": "Conectado",
"add": "Adicionar",
"addedToClient": "Adicionado a {{name}}",
"removedFromClient": "Removido de {{name}}",
"removeAriaLabel": "Remover de {{name}}",
"category": {
"desktopApp": "Aplicativo de desktop",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Extensão de editor"
}
},
"tabApi": "API local",
"tabMcp": "MCP (Assistentes de IA)",
@@ -673,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
"mcpStopped": "Servidor MCP parado",
"mcpToggleFailed": "Falha ao alternar o servidor MCP",
"openSettings": "Abrir configurações de integrações"
"openSettings": "Abrir configurações de integrações",
"apiRunningOn": "Em execução em",
"apiExampleRequest": "Exemplo de solicitação"
},
"import": {
"title": "Importar Perfil",
@@ -1179,6 +1203,7 @@
"empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo",
"newGroup": "Novo grupo",
"addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização",
"loadGroupsFailed": "Falha ao carregar grupos de extensões",
"assignGroupFailed": "Falha ao atribuir grupo de extensões"
"assignGroupFailed": "Falha ao atribuir grupo de extensões",
"bulkDelete": {
"extensionsTitle": "Excluir extensões",
"extensionsDescription": "Excluir {{count}} extensões? {{names}}",
"groupsTitle": "Excluir grupos de extensões",
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
"confirmButton": "Excluir"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "Aguardando sincronização",
"errorWith": "Erro de sincronização: {{error}}",
"error": "Erro de sincronização",
"notSynced": "Não sincronizado"
"notSynced": "Não sincronizado",
"enable": "Ativar sincronização",
"disable": "Desativar sincronização",
"lockedInUse": "A sincronização está bloqueada enquanto um perfil sincronizado a usar",
"bulkToggle": "Alternar sincronização"
},
"groupManagement": {
"description": "Gerencie seus grupos de perfis",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Excluir grupo",
"loadFailed": "Falha ao carregar grupos"
"loadFailed": "Falha ao carregar grupos",
"bulkDelete": {
"title": "Excluir grupos",
"description": "Tem certeza que deseja excluir {{count}} grupos? {{names}}. Os perfis serão movidos para Padrão.",
"description_one": "Tem certeza que deseja excluir {{count}} grupo? {{names}}. Os perfis serão movidos para Padrão.",
"confirmButton": "Excluir grupos"
}
},
"proxyAssignment": {
"title": "Atribuir proxy / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "Definir",
"change": "Alterar",
"remove": "Remover"
"remove": "Remover",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar senha do perfil",
"description": "Digite a senha do perfil para confirmar se corresponde à armazenada em disco.",
"submit": "Validar",
"matchToast": "A senha está correta"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"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."
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
},
"rail": {
"profiles": "Perfis",
"proxies": "Proxies",
"extensions": "Extensões",
"groups": "Grupos",
"settings": "Configurações",
@@ -1754,18 +1803,17 @@
"label": "Mais",
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"integrations": "Integrações",
"integrationsHint": "Slack, MCP, automações",
"account": "Conta",
"accountHint": "Nuvem, cobrança, login"
}
"importProfileHint": "Trazer perfis de outra ferramenta"
},
"network": "Rede",
"integrations": "Integrações",
"account": "Conta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Rede",
"extensions": "Extensões",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Rede",
"settings": "Configurações",
"integrations": "Integrações",
"account": "Conta",
@@ -1808,6 +1856,19 @@
"status": "Status",
"teamRole": "Função na equipe",
"period": "Período"
},
"tabs": {
"account": "Conta",
"selfHosted": "Auto-hospedado"
},
"selfHosted": {
"title": "Servidor de sincronização auto-hospedado",
"description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.",
"disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.",
"connectionStatus": "Conexão:",
"statusUnknown": "Não testado",
"testConnection": "Testar conexão",
"disconnect": "Desconectar"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "Загрузка...",
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено"
"saved": "Сохранено",
"copied": "Скопировано"
},
"status": {
"active": "Активен",
@@ -158,14 +159,24 @@
"passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд."
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд.",
"validatePassword": "Проверить",
"validateDialog": {
"title": "Проверка пароля шифрования",
"description": "Введите пароль шифрования, чтобы убедиться, что он совпадает с сохранённым на этом устройстве.",
"submit": "Проверить",
"matchToast": "Пароль верен",
"mismatchToast": "Пароль не совпадает"
}
},
"commercial": {
"title": "Коммерческая лицензия",
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.",
"trialExpired": "Пробный период истёк",
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия.",
"subscriptionActive": "Подписка активна — план {{plan}}",
"subscriptionActiveDescription": "Ваша подписка на Donut Browser активна. Коммерческое использование лицензировано на срок действия плана."
},
"advanced": {
"title": "Дополнительно",
@@ -374,6 +385,9 @@
"deleteFailed": "Не удалось удалить прокси",
"deleteTitle": "Удалить прокси",
"deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.",
"newProxy": "Новый прокси",
"newVpn": "Новый VPN",
"protocolCol": "Протокол",
"title": "Прокси и VPN"
},
"add": "Добавить прокси",
@@ -478,6 +492,13 @@
"continueButton": "Продолжить",
"doneButton": "Готово",
"failed": "Не удалось импортировать прокси"
},
"bulkDelete": {
"proxiesTitle": "Удалить выбранные прокси",
"proxiesDescription": "Это действие нельзя отменить. Будет безвозвратно удалено прокси: {{count}} — {{names}}.",
"vpnsTitle": "Удалить выбранные VPN",
"vpnsDescription": "Это действие нельзя отменить. Будет безвозвратно удалено VPN: {{count}} — {{names}}.",
"confirmButton": "Удалить {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "Добавить группу",
"edit": "Редактировать группу",
"delete": "Удалить группу",
"defaultGroup": "По умолчанию",
"defaultGroupNoGroup": "По умолчанию (Без группы)",
"moveToDefault": "Переместить профили в группу по умолчанию",
"noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».",
"moveToDefault": "Убрать профили из группы",
"noGroupDescription": "Профили без группы отображаются в фильтре «Все».",
"assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}",
"noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.",
@@ -519,7 +538,6 @@
"loadingProfiles": "Загрузка связанных профилей...",
"associatedProfiles": "Связанные профили ({{count}})",
"whatToDoWithProfiles": "Что сделать с этими профилями?",
"moveToDefaultOption": "Переместить профили в группу По умолчанию",
"deleteAlongWithGroup": "Удалить профили вместе с группой",
"noAssociatedProfiles": "У этой группы нет связанных профилей.",
"deleteGroup": "Удалить группу",
@@ -528,7 +546,10 @@
"unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп...",
"all": "Все"
"all": "Все",
"noGroup": "Без группы",
"pageTitle": "Группы профилей",
"pageDescription": "Группы профилей позволяют организовать браузеры по клиенту, окружению или сценарию использования. Синхронизируйте группы между устройствами, чтобы делиться ими."
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "Токен скопирован",
"url": "URL MCP сервера",
"urlCopied": "URL скопирован",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Автоматически настраивает claude_desktop_config.json",
"addToClaudeDesktop": "Добавить в Claude Desktop",
"removeFromClaudeDesktop": "Удалить из Claude Desktop",
"addedToClaudeDesktop": "Добавлено в Claude Desktop. Перезапустите Claude Desktop и включите расширение в Настройках.",
"removedFromClaudeDesktop": "Удалено из конфигурации Claude Desktop. Перезапустите Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Добавить в Claude Code",
"removeFromClaudeCode": "Удалить из Claude Code",
"addedToClaudeCode": "Добавлено в Claude Code",
"removedFromClaudeCode": "Удалено из Claude Code",
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
"copyConfig": "Копировать конфигурацию",
"clientsLabel": "Клиенты",
"connected": "Подключено",
"add": "Добавить",
"addedToClient": "Добавлено в {{name}}",
"removedFromClient": "Удалено из {{name}}",
"removeAriaLabel": "Удалить из {{name}}",
"category": {
"desktopApp": "Десктоп-приложение",
"cli": "CLI",
"editor": "Редактор",
"editorExt": "Расширение редактора"
}
},
"tabApi": "Локальный API",
"tabMcp": "MCP (ИИ-ассистенты)",
@@ -673,7 +695,9 @@
"mcpStarted": "MCP сервер запущен на порту {{port}}",
"mcpStopped": "MCP сервер остановлен",
"mcpToggleFailed": "Не удалось переключить MCP сервер",
"openSettings": "Открыть настройки интеграций"
"openSettings": "Открыть настройки интеграций",
"apiRunningOn": "Запущен на",
"apiExampleRequest": "Пример запроса"
},
"import": {
"title": "Импорт профиля",
@@ -1179,6 +1203,7 @@
"empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу",
"newGroup": "Новая группа",
"addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию",
"loadGroupsFailed": "Не удалось загрузить группы расширений",
"assignGroupFailed": "Не удалось назначить группу расширений"
"assignGroupFailed": "Не удалось назначить группу расширений",
"bulkDelete": {
"extensionsTitle": "Удалить расширения",
"extensionsDescription": "Удалить {{count}} расширений? {{names}}",
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "Ожидание синхронизации",
"errorWith": "Ошибка синхронизации: {{error}}",
"error": "Ошибка синхронизации",
"notSynced": "Не синхронизировано"
"notSynced": "Не синхронизировано",
"enable": "Включить синхронизацию",
"disable": "Отключить синхронизацию",
"lockedInUse": "Синхронизация заблокирована, пока используется синхронизируемым профилем",
"bulkToggle": "Переключить синхронизацию"
},
"groupManagement": {
"description": "Управляйте группами профилей",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
"editGroupTooltip": "Редактировать группу",
"deleteGroupTooltip": "Удалить группу",
"loadFailed": "Не удалось загрузить группы"
"loadFailed": "Не удалось загрузить группы",
"bulkDelete": {
"title": "Удалить группы",
"description": "Вы уверены, что хотите удалить {{count}} групп? {{names}}. Профили будут перемещены в группу по умолчанию.",
"description_one": "Вы уверены, что хотите удалить {{count}} группу? {{names}}. Профили будут перемещены в группу по умолчанию.",
"confirmButton": "Удалить группы"
}
},
"proxyAssignment": {
"title": "Назначить прокси / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "Задать",
"change": "Изменить",
"remove": "Удалить"
"remove": "Удалить",
"validate": "Проверить"
},
"verifyDialog": {
"title": "Проверка пароля профиля",
"description": "Введите пароль профиля, чтобы убедиться, что он совпадает с сохранённым на диске.",
"submit": "Проверить",
"matchToast": "Пароль верен"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"internal": "Что-то пошло не так: {{detail}}",
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно."
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
},
"rail": {
"profiles": "Профили",
"proxies": "Прокси",
"extensions": "Расширения",
"groups": "Группы",
"settings": "Настройки",
@@ -1754,18 +1803,17 @@
"label": "Ещё",
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"integrations": "Интеграции",
"integrationsHint": "Slack, MCP, автоматизации",
"account": "Аккаунт",
"accountHint": "Облако, оплата, вход"
}
"importProfileHint": "Перенести профили из другого инструмента"
},
"network": "Сеть",
"integrations": "Интеграции",
"account": "Аккаунт"
},
"pageTitle": {
"proxies": "Прокси",
"proxies": "Сеть",
"extensions": "Расширения",
"groups": "Группы",
"vpns": "VPN",
"vpns": "Сеть",
"settings": "Настройки",
"integrations": "Интеграции",
"account": "Аккаунт",
@@ -1808,6 +1856,19 @@
"status": "Статус",
"teamRole": "Роль в команде",
"period": "Период"
},
"tabs": {
"account": "Аккаунт",
"selfHosted": "Свой сервер"
},
"selfHosted": {
"title": "Свой сервер синхронизации",
"description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.",
"disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.",
"connectionStatus": "Соединение:",
"statusUnknown": "Не проверено",
"testConnection": "Проверить соединение",
"disconnect": "Отключить"
}
}
}
+97 -36
View File
@@ -32,7 +32,8 @@
"downloading": "下载中...",
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存"
"saved": "已保存",
"copied": "已复制"
},
"status": {
"active": "活跃",
@@ -158,14 +159,24 @@
"passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符",
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。"
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。",
"validatePassword": "验证",
"validateDialog": {
"title": "验证加密密码",
"description": "输入加密密码以验证它是否与此设备上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确",
"mismatchToast": "密码不匹配"
}
},
"commercial": {
"title": "商业许可",
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
"trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。",
"trialExpired": "试用期已过期",
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。",
"subscriptionActive": "已订阅 — {{plan}} 方案",
"subscriptionActiveDescription": "您的 Donut Browser 订阅已激活。在订阅有效期内允许商业使用。"
},
"advanced": {
"title": "高级",
@@ -374,6 +385,9 @@
"deleteFailed": "删除代理失败",
"deleteTitle": "删除代理",
"deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。",
"newProxy": "新建代理",
"newVpn": "新建 VPN",
"protocolCol": "协议",
"title": "代理和 VPN"
},
"add": "添加代理",
@@ -478,6 +492,13 @@
"continueButton": "继续",
"doneButton": "完成",
"failed": "导入代理失败"
},
"bulkDelete": {
"proxiesTitle": "删除所选代理",
"proxiesDescription": "此操作无法撤销。将永久删除 {{count}} 个代理:{{names}}。",
"vpnsTitle": "删除所选 VPN",
"vpnsDescription": "此操作无法撤销。将永久删除 {{count}} 个 VPN{{names}}。",
"confirmButton": "删除 {{count}}"
}
},
"groups": {
@@ -486,10 +507,8 @@
"add": "添加分组",
"edit": "编辑分组",
"delete": "删除分组",
"defaultGroup": "默认",
"defaultGroupNoGroup": "默认(无分组)",
"moveToDefault": "将配置文件移至默认分组",
"noGroupDescription": "未分组的配置文件将显示在「默认」分组中。",
"moveToDefault": "将配置文件移出分组",
"noGroupDescription": "未分组的配置文件显示在「全部」筛选中。",
"assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}",
"noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。",
@@ -519,7 +538,6 @@
"loadingProfiles": "正在加载关联的配置文件...",
"associatedProfiles": "关联的配置文件 ({{count}})",
"whatToDoWithProfiles": "这些配置文件应该怎么办?",
"moveToDefaultOption": "将配置文件移至默认组",
"deleteAlongWithGroup": "将配置文件与组一起删除",
"noAssociatedProfiles": "此组没有关联的配置文件。",
"deleteGroup": "删除组",
@@ -528,7 +546,10 @@
"unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组...",
"all": "全部"
"all": "全部",
"noGroup": "无分组",
"pageTitle": "配置文件分组",
"pageDescription": "配置文件分组可让您按客户端、环境或使用场景整理浏览器。在多台设备之间同步分组以便共享。"
},
"sync": {
"mode": {
@@ -635,19 +656,20 @@
"tokenCopied": "令牌已复制",
"url": "MCP 服务器 URL",
"urlCopied": "URL 已复制",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "自动配置 claude_desktop_config.json",
"addToClaudeDesktop": "添加到 Claude Desktop",
"removeFromClaudeDesktop": "从 Claude Desktop 移除",
"addedToClaudeDesktop": "已添加到 Claude Desktop。请重启 Claude Desktop 并在设置中启用扩展。",
"removedFromClaudeDesktop": "已从 Claude Desktop 配置移除。请重启 Claude Desktop。",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "添加到 Claude Code",
"removeFromClaudeCode": "从 Claude Code 移除",
"addedToClaudeCode": "已添加到 Claude Code",
"removedFromClaudeCode": "已从 Claude Code 移除",
"config": "MCP 配置",
"copyConfig": "复制配置"
"copyConfig": "复制配置",
"clientsLabel": "客户端",
"connected": "已连接",
"add": "添加",
"addedToClient": "已添加到 {{name}}",
"removedFromClient": "已从 {{name}} 移除",
"removeAriaLabel": "从 {{name}} 移除",
"category": {
"desktopApp": "桌面应用",
"cli": "CLI",
"editor": "编辑器",
"editorExt": "编辑器扩展"
}
},
"tabApi": "本地 API",
"tabMcp": "MCP (AI 助手)",
@@ -673,7 +695,9 @@
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
"mcpStopped": "MCP 服务器已停止",
"mcpToggleFailed": "切换 MCP 服务器失败",
"openSettings": "打开集成设置"
"openSettings": "打开集成设置",
"apiRunningOn": "运行于",
"apiExampleRequest": "示例请求"
},
"import": {
"title": "导入配置文件",
@@ -1179,6 +1203,7 @@
"empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组",
"newGroup": "新建分组",
"addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组",
@@ -1226,7 +1251,14 @@
"syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步",
"loadGroupsFailed": "加载扩展组失败",
"assignGroupFailed": "分配扩展组失败"
"assignGroupFailed": "分配扩展组失败",
"bulkDelete": {
"extensionsTitle": "删除扩展",
"extensionsDescription": "删除 {{count}} 个扩展?{{names}}",
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
}
},
"pro": {
"badge": "PRO",
@@ -1371,7 +1403,11 @@
"waiting": "等待同步",
"errorWith": "同步错误: {{error}}",
"error": "同步错误",
"notSynced": "未同步"
"notSynced": "未同步",
"enable": "启用同步",
"disable": "禁用同步",
"lockedInUse": "同步配置文件正在使用,无法禁用同步",
"bulkToggle": "切换同步"
},
"groupManagement": {
"description": "管理你的配置文件分组",
@@ -1385,7 +1421,13 @@
"syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步",
"editGroupTooltip": "编辑分组",
"deleteGroupTooltip": "删除分组",
"loadFailed": "加载分组失败"
"loadFailed": "加载分组失败",
"bulkDelete": {
"title": "删除分组",
"description": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"description_one": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"confirmButton": "删除分组"
}
},
"proxyAssignment": {
"title": "分配代理 / VPN",
@@ -1719,7 +1761,14 @@
"modes": {
"set": "设置",
"change": "更改",
"remove": "删除"
"remove": "删除",
"validate": "验证"
},
"verifyDialog": {
"title": "验证配置文件密码",
"description": "输入配置文件密码以确认它与磁盘上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确"
}
},
"backendErrors": {
@@ -1742,11 +1791,11 @@
"internal": "出现问题:{{detail}}",
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。"
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
},
"rail": {
"profiles": "配置文件",
"proxies": "代理",
"extensions": "扩展",
"groups": "分组",
"settings": "设置",
@@ -1754,18 +1803,17 @@
"label": "更多",
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"integrations": "集成",
"integrationsHint": "Slack、MCP、自动化",
"account": "账户",
"accountHint": "云、订阅、登录"
}
"importProfileHint": "从其他工具导入"
},
"network": "网络",
"integrations": "集成",
"account": "账号"
},
"pageTitle": {
"proxies": "代理",
"proxies": "网络",
"extensions": "扩展",
"groups": "分组",
"vpns": "VPN",
"vpns": "网络",
"settings": "设置",
"integrations": "集成",
"account": "账户",
@@ -1808,6 +1856,19 @@
"status": "状态",
"teamRole": "团队角色",
"period": "计费周期"
},
"tabs": {
"account": "账户",
"selfHosted": "自托管"
},
"selfHosted": {
"title": "自托管同步服务器",
"description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。",
"disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。",
"connectionStatus": "连接:",
"statusUnknown": "未测试",
"testConnection": "测试连接",
"disconnect": "断开连接"
}
}
}
+3
View File
@@ -19,6 +19,7 @@ export type BackendErrorCode =
| "INVALID_LAUNCH_HOOK_URL"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "SELF_HOSTED_REQUIRES_LOGOUT"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -93,6 +94,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
return t("backendErrors.cookieDbUnavailable");
case "SELF_HOSTED_REQUIRES_LOGOUT":
return t("backendErrors.selfHostedRequiresLogout");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+37 -11
View File
@@ -157,26 +157,41 @@
}
}
/* 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 utility: a vertical mask that thins the alpha of the top and
bottom 24px of the scroll container ONLY when that direction is actually
scrollable. useScrollFade() writes `data-fade-top` / `data-fade-bottom`
on the container as the user scrolls; each attribute toggles its own
end of the mask via a CSS variable.
Mask is preferred over a colored gradient overlay: an overlay paints
bg-color over content, which leaves a visible band wherever content
passes through it. Mask just fades alpha content gracefully fades to
nothing at the edges.
`--scroll-fade-top-offset` pushes the top-edge fade band down by N
pixels so a sticky table header stays fully opaque and only the body
rows scrolling past it fade. Set it inline on a scroll container whose
first N px are occupied by sticky chrome. */
.scroll-fade {
--top-mask: black;
--bottom-mask: black;
--scroll-fade-top-offset: 0px;
-webkit-mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
}
@@ -206,3 +221,14 @@
[data-sonner-toast] select {
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
scroll-behavior: auto;
}
}