feat: add onboarding

This commit is contained in:
zhom
2026-06-01 01:05:35 +04:00
parent 3a3f201065
commit 98f1c7452a
67 changed files with 3157 additions and 369 deletions
+8
View File
@@ -47,3 +47,11 @@ jobs:
- name: Run flake info app
run: nix run .#info
# `nix flake show` above only evaluates the flake. This step actually
# compiles the app inside the Nix environment, which is what catches a
# missing build-time dependency — in particular libayatana-appindicator
# (required by libappindicator-sys for the Linux system tray). The build
# fails here if that dependency is dropped from the flake.
- name: Build the app via the flake
run: nix run .#build
+51
View File
@@ -216,6 +216,57 @@ The `.github/workflows/publish-repos.yml` workflow runs automatically after stab
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Sync (cloud / self-hosted)
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
- **Profile browser files** (the Chromium/Firefox profile directory): a
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
per-file hash+size diff, only changed files transfer. `sync_profile` in
`engine.rs`.
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
extension groups, and profile *metadata*): one small JSON blob each, synced
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
Every config entity carries `updated_at: Option<u64>` (unix seconds;
`extension_manager` uses a non-Optional `u64`). It is the **single source of
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
every upload/download and must NOT decide sync direction. (The
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
so any real edit wins.
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
small JSON body and read its embedded `updated_at`. Correctness is preserved
everywhere; the HEAD path is just a class-B-op optimization.
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
into BOTH the JSON body and the S3 object metadata, so after a download both
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
### Server (`donut-sync/`) metadata passthrough
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
echoes back what it signed (the Rust client must send exactly those headers on
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+8
View File
@@ -6,17 +6,25 @@ export class StatResponseDto {
exists: boolean;
lastModified?: string;
size?: number;
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
metadata?: Record<string, string>;
}
export class PresignUploadRequestDto {
key: string;
contentType?: string;
expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
}
export class PresignUploadResponseDto {
url: string;
expiresAt: string;
// Metadata the server actually signed; the client must echo it as
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
metadata?: Record<string, string>;
}
export class PresignDownloadRequestDto {
+7
View File
@@ -256,6 +256,10 @@ export class SyncService implements OnModuleInit {
exists: true,
lastModified: response.LastModified?.toISOString(),
size: response.ContentLength,
// S3 returns user metadata with lowercased keys and no `x-amz-meta-`
// prefix. Clients read `updated-at` from here to resolve sync conflicts
// without downloading the object body.
metadata: response.Metadata,
};
} catch (error: unknown) {
if (
@@ -289,6 +293,9 @@ export class SyncService implements OnModuleInit {
Bucket: this.bucket,
Key: key,
ContentType: dto.contentType || "application/octet-stream",
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
// exactly these headers on the PUT, so we echo them in the response.
Metadata: dto.metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
+2
View File
@@ -34,6 +34,7 @@
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
+5
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -54,16 +55,19 @@
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -78,6 +82,7 @@
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
+65
View File
@@ -33,6 +33,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -84,6 +87,9 @@ importers:
ahooks:
specifier: ^3.9.7
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -99,6 +105,9 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
i18next:
specifier: ^26.1.0
version: 26.1.0(typescript@6.0.3)
@@ -114,6 +123,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
onborda:
specifier: ^1.2.5
version: 1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -151,6 +163,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11.1
version: 2.11.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/color':
specifier: ^4.2.1
version: 4.2.1
@@ -1673,6 +1688,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.10':
resolution: {integrity: sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -2483,6 +2511,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
@@ -3012,6 +3043,9 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -4285,6 +4319,15 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
onborda@1.2.5:
resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==}
peerDependencies:
'@radix-ui/react-portal': '>=1.1.1'
framer-motion: '>=11'
next: '>=13'
react: '>=18'
react-dom: '>=18'
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -7002,6 +7045,16 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -7822,6 +7875,8 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.7.0
'@types/canvas-confetti@1.9.0': {}
'@types/color-convert@2.0.4':
dependencies:
'@types/color-name': 1.1.5
@@ -8372,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -9726,6 +9783,14 @@ snapshots:
dependencies:
ee-first: 1.1.1
onborda@1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-portal': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
once@1.4.0:
dependencies:
wrappy: 1.0.2
+42 -12
View File
@@ -586,6 +586,24 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
Ok(server_guard.get_port())
}
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
/// dropping the `fingerprint` field unless the user has an active paid plan.
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
/// handler via `has_active_paid_subscription()`.
fn config_to_api_value<T: serde::Serialize>(
config: Option<&T>,
is_paid: bool,
) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(config?).ok()?;
if !is_paid {
if let Some(obj) = value.as_object_mut() {
obj.remove("fingerprint");
}
}
Some(value)
}
// API Handlers - Profiles
#[utoipa::path(
get,
@@ -602,6 +620,9 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles
@@ -616,10 +637,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -659,6 +677,9 @@ async fn get_profile(
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
@@ -673,10 +694,7 @@ async fn get_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -712,6 +730,9 @@ async fn create_profile(
Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
@@ -727,6 +748,18 @@ async fn create_profile(
None
};
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) =
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
{
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
StatusCode::PAYMENT_REQUIRED
} else {
StatusCode::BAD_REQUEST
});
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
@@ -776,10 +809,7 @@ async fn create_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
+28
View File
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
portable_dir().is_some()
}
/// Optional single-root override for all on-disk state. Set
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
fn data_root() -> Option<PathBuf> {
std::env::var_os("DONUTBROWSER_DATA_ROOT")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
/// otherwise, in which case the platform default app log dir is used.
pub fn log_dir_override() -> Option<PathBuf> {
data_root().map(|root| root.join("logs"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("cache");
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
if let Some(dir) = log_dir_override() {
return dir;
}
use tauri::Manager;
handle
.path()
+1
View File
@@ -703,6 +703,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
+1
View File
@@ -1220,6 +1220,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+18
View File
@@ -656,6 +656,24 @@ impl BrowserRunner {
let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}");
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
// next launch starts from the upgraded value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
log::info!(
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
updated_profile.wayfern_config = Some(cfg);
}
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
+22 -1
View File
@@ -46,6 +46,16 @@ pub struct CloudUser {
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
// This desktop session's position among the user's active devices, oldest
// first. Ordinal 1 is the primary device — the only one that can run browser
// automation. `default` keeps older login/state payloads (which lack these
// fields) deserializing cleanly.
#[serde(rename = "deviceOrdinal", default)]
pub device_ordinal: Option<i64>,
#[serde(rename = "deviceCount", default)]
pub device_count: Option<i64>,
#[serde(rename = "isPrimaryDevice", default)]
pub is_primary_device: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -413,7 +423,18 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Login failed ({status}): {body}"));
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
// limit or a temporary security block). Surface the human-readable
// message rather than the raw JSON so the sign-in screen is clear.
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| format!("Login failed ({status})"));
return Err(message);
}
let result: DeviceCodeExchangeResponse = response
+64 -12
View File
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
)
.await
{
Ok(_) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
// the core of the bug fix: Wayfern going first must never starve Camoufox.
const MAX_ATTEMPTS: u32 = 3;
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let mut succeeded = false;
for attempt in 1..=MAX_ATTEMPTS {
let result = tokio::time::timeout(
ATTEMPT_TIMEOUT,
crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
),
)
.await;
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
}
Err(_) => {
// The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by
// browser prefix) and emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
let progress = crate::downloader::DownloadProgress {
browser: (*browser).to_string(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = crate::events::emit("download-progress", &progress);
}
}
Err(e) => {
log::warn!("Failed to auto-download {browser} {version}: {e}");
if attempt < MAX_ATTEMPTS {
// Short backoff before retrying a transient failure.
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
if !succeeded {
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
}
}
Ok(downloaded)
+125 -15
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
// the UI can surface it and the caller can move on / retry.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self {
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
// for this long, the read fails instead of hanging forever. This is the
// transport-level guard; the streaming loop also wraps each read in an
// explicit tokio timeout as defense-in-depth.
.read_timeout(STREAM_IDLE_TIMEOUT)
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
loop {
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
// surfaces as a terminal error instead of awaiting forever.
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(item) => item,
Err(_) => {
drop(file);
// Keep any partial bytes on disk so a later attempt can resume via Range.
return Err(
format!(
"Download stalled: no data received for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
)
.into(),
);
}
};
let Some(chunk) = next else {
break;
};
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
@@ -694,20 +723,25 @@ impl Downloader {
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit cancelled stage if the download was cancelled by user
if cancel_token.is_cancelled() {
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "cancelled".to_string(),
};
let _ = events::emit("download-progress", &progress);
}
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status)
// maps to "error" so the frontend can show a concrete error toast.
let stage = if cancel_token.is_cancelled() {
"cancelled"
} else {
"error"
};
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: stage.to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
}
@@ -844,6 +878,20 @@ impl Downloader {
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Emit a terminal error stage so the UI shows an error instead of spinning.
let progress = DownloadProgress {
browser: browser_str.clone(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because
/// `download_browser_full` may re-resolve to a different version than requested, this
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.retain(|key| !key.starts_with(&prefix));
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.retain(|key, _| !key.starts_with(&prefix));
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,
// leaving stuck bookkeeping for a version that differs from the requested one.
let key = "wayfern-1.2.3-resolved".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(key.clone());
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(key.clone(), CancellationToken::new());
}
// A different browser's in-progress state must be left untouched.
let other = "camoufox-9.9.9".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(other.clone());
}
clear_download_state_for_browser("wayfern");
assert!(
!is_downloading("wayfern", "1.2.3-resolved"),
"stuck wayfern key should be cleared even when version differs from request"
);
{
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
assert!(
!tokens.contains_key(&key),
"stuck wayfern cancellation token should be cleared"
);
}
assert!(
is_downloading("camoufox", "9.9.9"),
"unrelated browser's download state must be preserved"
);
// Cleanup so we don't leak global state into other tests.
clear_download_state_for_browser("camoufox");
}
}
// Global singleton instance
+1
View File
@@ -281,6 +281,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
+8
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?;
}
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else {
groups_data.groups.push(group.clone());
}
+75 -9
View File
@@ -93,10 +93,10 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
dismiss_window_resize_warning, get_app_settings, get_sync_settings, get_system_info,
get_system_language, get_table_sorting_settings, get_window_resize_warning_dismissed,
open_log_directory, read_log_files, save_app_settings, save_sync_settings,
save_table_sorting_settings,
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings,
};
use sync::{
@@ -929,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
#[tauri::command]
async fn check_vpn_validity(
vpn_id: String,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
check_vpn_validity_core(&vpn_id).await
}
pub async fn check_vpn_validity_core(
vpn_id: &str,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
@@ -1014,6 +1020,53 @@ async fn check_vpn_validity(
Ok(result)
}
/// Validate that a profile's selected proxy or VPN actually works before the
/// profile is created. Shared by the Tauri command, REST API, and MCP create
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
/// subscription) fails creation identically everywhere. Returns structured
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
pub async fn validate_profile_network(
proxy_id: Option<&str>,
vpn_id: Option<&str>,
) -> Result<(), String> {
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
let result = check_vpn_validity_core(vpn_id).await?;
if !result.is_valid {
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
}
return Ok(());
}
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
// The cloud-included proxy is managed infrastructure; its only failure mode
// is the user hitting their usage limit, which surfaces as a 402 at request
// time. There's nothing to pre-validate here.
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
return Ok(());
}
let settings = crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(proxy_id, &settings)
.await
{
Ok(result) if result.is_valid => {}
Ok(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
Err(err) if err.contains("402") => {
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
}
Err(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
}
}
Ok(())
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown)
@@ -1122,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1274,15 +1328,25 @@ pub fn run() {
let log_file_name = app_dirs::app_name();
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
// the platform default app log dir, so all on-disk state lives under one root.
let file_log_target = match app_dirs::log_dir_override() {
Some(path) => Target::new(TargetKind::Folder {
path,
file_name: Some(log_file_name.to_string()),
}),
None => Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
@@ -2127,6 +2191,8 @@ pub fn run() {
get_system_info,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
+9 -3
View File
@@ -1671,9 +1671,15 @@ impl McpServer {
"connect_vpn" => self.handle_connect_vpn(arguments).await,
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
// Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(arguments)
+20 -2
View File
@@ -200,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -303,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -365,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -510,6 +513,7 @@ impl ProfileManager {
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name
self.save_profile(&profile)?;
@@ -719,6 +723,7 @@ impl ProfileManager {
}
profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -773,6 +778,7 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -809,6 +815,7 @@ impl ProfileManager {
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -838,6 +845,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -869,6 +877,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -895,6 +904,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -1058,6 +1068,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_profile(&new_profile)?;
@@ -1225,6 +1236,7 @@ impl ProfileManager {
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save the updated profile
self
@@ -1324,6 +1336,7 @@ impl ProfileManager {
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self
.save_profile(&profile)
@@ -1368,6 +1381,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -2455,6 +2469,10 @@ pub async fn create_browser_profile_new(
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
// subscription) cancels creation with a translatable error.
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -2486,7 +2504,7 @@ pub async fn update_camoufox_config(
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
@@ -2514,7 +2532,7 @@ pub async fn update_wayfern_config(
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
+6
View File
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
/// Source of truth for metadata sync conflict resolution (last-write-wins);
/// NOT bumped by browser-file changes, which sync via the file manifest.
#[serde(default)]
pub updated_at: Option<u64>,
}
pub fn default_release_type() -> String {
+3
View File
@@ -586,6 +586,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -668,6 +669,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -726,6 +728,7 @@ impl ProfileImporter {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.profile_manager.save_profile(&profile)?;
+20
View File
@@ -103,6 +103,11 @@ pub struct StoredProxy {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins) — bumped on config edits only, never
/// by sync bookkeeping. `None` on legacy files is treated as 0.
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub is_cloud_managed: bool,
#[serde(default)]
@@ -124,6 +129,14 @@ pub struct StoredProxy {
pub dynamic_proxy_format: Option<String>,
}
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -133,6 +146,7 @@ impl StoredProxy {
proxy_settings,
sync_enabled,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: None,
@@ -159,10 +173,12 @@ impl StoredProxy {
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
self.updated_at = Some(now_secs());
}
pub fn update_name(&mut self, name: String) {
self.name = name;
self.updated_at = Some(now_secs());
}
}
@@ -455,6 +471,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: true,
is_cloud_derived: false,
geo_country: None,
@@ -646,6 +663,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: true,
geo_country: Some(country),
@@ -710,6 +728,7 @@ impl ProxyManager {
&proxy.geo_isp,
);
proxy.updated_at = Some(now_secs());
proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
@@ -3154,6 +3173,7 @@ mod tests {
},
sync_enabled: false,
last_sync: None,
updated_at: None,
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: Some("US".to_string()),
+25
View File
@@ -54,6 +54,8 @@ pub struct AppSettings {
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
@@ -93,6 +95,7 @@ impl Default for AppSettings {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
@@ -1010,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub async fn get_onboarding_completed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.onboarding_completed)
}
#[tauri::command]
pub async fn complete_onboarding() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.onboarding_completed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -1147,6 +1171,7 @@ mod tests {
mcp_token: None,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
+37
View File
@@ -49,6 +49,21 @@ impl SyncClient {
&self,
key: &str,
content_type: Option<&str>,
) -> SyncResult<PresignUploadResponse> {
self
.presign_upload_with_metadata(key, content_type, None)
.await
}
/// Presign an upload, asking the server to sign `metadata` into the object as
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
/// (empty/None on older servers); the caller must send exactly that back on
/// the PUT via `upload_bytes_with_metadata`.
pub async fn presign_upload_with_metadata(
&self,
key: &str,
content_type: Option<&str>,
metadata: Option<std::collections::HashMap<String, String>>,
) -> SyncResult<PresignUploadResponse> {
let response = self
.client
@@ -58,6 +73,7 @@ impl SyncClient {
key: key.to_string(),
content_type: content_type.map(|s| s.to_string()),
expires_in: Some(3600),
metadata,
})
.send()
.await
@@ -186,6 +202,21 @@ impl SyncClient {
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
) -> SyncResult<()> {
self
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
.await
}
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
/// MUST be exactly the metadata the presign signed (from
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
pub async fn upload_bytes_with_metadata(
&self,
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
metadata: Option<&std::collections::HashMap<String, String>>,
) -> SyncResult<()> {
let mut req = self
.client
@@ -197,6 +228,12 @@ impl SyncClient {
req = req.header("Content-Type", ct);
}
if let Some(meta) = metadata {
for (k, v) in meta {
req = req.header(format!("x-amz-meta-{k}"), v);
}
}
let response = req
.send()
.await
+96 -101
View File
@@ -15,6 +15,11 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
@@ -358,6 +363,67 @@ impl SyncEngine {
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
}
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
/// conflict resolution. Prefers the value from S3 object metadata returned by
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
/// decrypting the small JSON body and reading its embedded `updated_at` (for
/// older self-hosted servers that don't surface metadata). Legacy objects with
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
if let Some(meta) = &stat.metadata {
if let Some(v) = meta
.get(UPDATED_AT_META_KEY)
.and_then(|s| s.parse::<u64>().ok())
{
return v;
}
}
// Fallback: read updated_at from the (small) JSON body.
if let Ok(presign) = self.client.presign_download(remote_key).await {
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
return u;
}
}
}
}
}
0
}
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
/// profile metadata), signing its `updated_at` into S3 object metadata so
/// future reconciles can compare via HEAD without downloading the body. The
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
/// lives in the object metadata.
async fn upload_config_json(
&self,
remote_key: &str,
json: &str,
updated_at: u64,
) -> SyncResult<()> {
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
let mut meta = HashMap::new();
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
let presign = self
.client
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
.await?;
self
.client
.upload_bytes_with_metadata(
&presign.url,
&payload,
Some(content_type),
presign.metadata.as_ref(),
)
.await?;
Ok(())
}
pub async fn sync_profile(
&self,
app_handle: &tauri::AppHandle,
@@ -1431,21 +1497,13 @@ impl SyncEngine {
match (local_proxy, stat.exists) {
(Some(proxy), true) => {
// Both exist - compare timestamps
let local_updated = proxy.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_proxy(&proxy).await?;
}
}
@@ -1478,17 +1536,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
.await?;
// Update local proxy with new last_sync (always write plaintext locally)
@@ -1579,21 +1629,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - compare timestamps
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_group(&group).await?;
}
}
@@ -1626,17 +1668,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
.await?;
// Update local group with new last_sync
@@ -1795,18 +1829,13 @@ impl SyncEngine {
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = vpn.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_vpn(&vpn).await?;
}
}
@@ -1836,17 +1865,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
.await?;
// Update local VPN with new last_sync
@@ -1946,18 +1967,13 @@ impl SyncEngine {
match (local_ext, stat.exists) {
(Some(ext), true) => {
let local_updated = ext.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = ext.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension(&ext).await?;
}
}
@@ -1987,17 +2003,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
.await?;
// Also upload the extension file data — encrypted as a sealed envelope
@@ -2151,18 +2159,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension_group(&group).await?;
}
}
@@ -2196,17 +2199,9 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at)
.await?;
// Update local group with new last_sync
+14
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")]
pub last_modified: Option<String>,
pub size: Option<u64>,
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
/// the prefix. `None` from older servers that don't return it. Used to read
/// `updated-at` for sync conflict resolution without downloading the body.
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>,
#[serde(rename = "expiresIn")]
pub expires_in: Option<u64>,
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
/// The metadata the server actually signed into the URL. The client must send
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
/// from older servers → client sends no metadata headers (body-GET fallback).
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on config edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
/// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
}
/// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
updated_at: config.updated_at,
};
// Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
}
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
.collect(),
)
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?;
Ok(config)
}
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
+34 -4
View File
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The fingerprint Wayfern actually applied, echoed back by
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
/// (e.g. when the stored one targets an older browser version). Internal
/// only — the caller persists it to the profile; never sent to the frontend.
#[serde(default, skip_serializing)]
pub used_fingerprint: Option<String>,
}
struct WayfernInstance {
@@ -703,6 +709,7 @@ impl WayfernManager {
log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
@@ -781,10 +788,30 @@ impl WayfernManager {
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
),
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
// it once, from the first target that succeeds, so the caller can
// persist the upgraded value to the profile.
if used_fingerprint.is_none() {
// getFingerprint/setFingerprint wrap the object as
// { fingerprint: {...} }; tolerate a bare object too.
let fp = result.get("fingerprint").cloned().unwrap_or(result);
if fp.is_object() {
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
Ok(s) => used_fingerprint = Some(s),
Err(e) => {
log::warn!("Failed to serialize used fingerprint: {e}")
}
}
}
}
}
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
}
}
@@ -849,6 +876,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
})
}
@@ -990,6 +1018,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
used_fingerprint: None,
});
} else {
log::info!(
@@ -1032,6 +1061,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
used_fingerprint: None,
});
}
+4
View File
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let save_result = storage.save_config(&config);
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
}
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -489,6 +492,7 @@ fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> Vp
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
}
}
+116 -4
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useOnborda } from "onborda";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
@@ -23,6 +24,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
@@ -39,7 +41,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WelcomeDialog } from "@/components/welcome-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
@@ -55,6 +59,10 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
} from "@/lib/onboarding-signal";
import {
matchesGroupDigit,
matchesShortcut,
@@ -95,6 +103,95 @@ export default function Home() {
error: profilesError,
} = useProfileEvents();
// First-run onboarding tour (Onborda).
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
useOnborda();
const onboardingHandledRef = useRef(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [thankYouOpen, setThankYouOpen] = useState(false);
// null = onboarding decision pending; false = not a first-run onboarding (run
// the normal permission checks); true = first-run onboarding, so the welcome
// flow drives permissions and the standalone permission dialog is suppressed.
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
null,
);
// Welcome flow finished. Existing-profile users are done after the welcome +
// commercial-use steps; users with no profile yet continue into the in-app
// product tour that walks them through creating their first profile.
const handleWelcomeComplete = useCallback(() => {
setWelcomeOpen(false);
setFirstRunOnboarding(false);
if (profiles.length === 0) {
startOnborda(ONBOARDING_TOUR);
}
}, [startOnborda, profiles.length]);
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
useEffect(() => {
const handler = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
return () =>
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
}, []);
// Suppress the global browser-download toasts while onboarding (welcome or
// tour) is active — the welcome dialog shows setup progress itself.
useEffect(() => {
setOnboardingActive(welcomeOpen || isOnbordaVisible);
}, [welcomeOpen, isOnbordaVisible]);
// While the tour is visible, keep the body pinned to the left. Onborda calls
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
// body is overflow-hidden it can still be scrolled programmatically, which
// would shove the whole app (rail and all) sideways with no way to scroll
// back. The profile table keeps its own scroll container, untouched here.
useEffect(() => {
if (!isOnbordaVisible) return;
const pin = () => {
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
if (document.documentElement.scrollLeft !== 0)
document.documentElement.scrollLeft = 0;
};
pin();
window.addEventListener("scroll", pin, true);
return () => window.removeEventListener("scroll", pin, true);
}, [isOnbordaVisible]);
// On the very first launch, always show the welcome + commercial-use steps
// (one-shot: the backend flag is set immediately so it can't trigger again).
// The welcome dialog itself decides whether to continue into the browser
// download + profile-creation flow — only when the user has no profile yet.
useEffect(() => {
if (profilesLoading || onboardingHandledRef.current) return;
onboardingHandledRef.current = true;
void (async () => {
try {
const completed = await invoke<boolean>("get_onboarding_completed");
if (completed) {
setFirstRunOnboarding(false);
return;
}
await invoke("complete_onboarding");
setFirstRunOnboarding(true);
setWelcomeOpen(true);
} catch (err) {
console.error("Onboarding init failed:", err);
setFirstRunOnboarding(false);
}
})();
}, [profilesLoading]);
// Advance from the "create a profile" step to the "DNS blocking" step as soon
// as the user's first profile exists (its DNS dropdown is now in the DOM).
useEffect(() => {
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
// Small delay so the new profile row (and its DNS dropdown target) has
// mounted before Onborda re-points at it.
setCurrentStep(1, 300);
}
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
const {
groups: groupsData,
isLoading: groupsLoading,
@@ -775,9 +872,12 @@ export default function Home() {
} catch (error) {
showErrorToast(
t("errors.createProfileFailed", {
error: error instanceof Error ? error.message : String(error),
error: translateBackendError(t, error),
}),
);
// Rethrow so the create dialog keeps itself open (its own handler
// skips closing on error), letting the user fix the proxy/VPN and retry.
throw error;
}
},
[selectedGroupId, t],
@@ -1349,12 +1449,14 @@ export default function Home() {
};
}, [checkTerms]);
// Check permissions when they are initialized
// Check permissions when they are initialized. During first-run onboarding
// the welcome flow requests permissions, so the standalone dialog is deferred
// until we know this isn't a first-run onboarding.
useEffect(() => {
if (isInitialized) {
if (isInitialized && firstRunOnboarding === false) {
checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes
useEffect(() => {
@@ -1624,6 +1726,16 @@ export default function Home() {
onPermissionGranted={checkNextPermission}
/>
<WelcomeDialog
isOpen={welcomeOpen}
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => {
+31
View File
@@ -280,9 +280,40 @@ export function AccountPage({
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<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.device")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
</p>
</div>
)}
</div>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
+1 -1
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 size-5" />
<LuCheckCheck className="shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
+4 -1
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
+137 -41
View File
@@ -11,7 +11,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
@@ -307,6 +307,10 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded versions for both anti-detect browsers up front so the
// selection-screen availability gate is accurate before either is picked.
void loadDownloadedVersions("wayfern");
void loadDownloadedVersions("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
@@ -320,6 +324,7 @@ export function CreateProfileDialog({
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
@@ -405,6 +410,7 @@ export function CreateProfileDialog({
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
@@ -585,7 +591,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? t("createProfile.title")
@@ -618,23 +624,30 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("wayfern");
}}
disabled={!getCreatableVersion("wayfern")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("wayfern") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
{isBrowserCurrentlyDownloading("wayfern")
? t("createProfile.downloadingSubtitle")
: t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
@@ -644,26 +657,41 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("camoufox");
}}
disabled={!getCreatableVersion("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("camoufox") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
{isBrowserCurrentlyDownloading("camoufox")
? t("createProfile.downloadingSubtitle")
: t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{!getCreatableVersion("wayfern") &&
!getCreatableVersion("camoufox") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -867,7 +895,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
!getCreatableVersion("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -899,17 +927,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && (
getCreatableVersion("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
getCreatableVersion("wayfern")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
getCreatableVersion("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"wayfern",
)}
>
{isBrowserCurrentlyDownloading("wayfern")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -927,7 +991,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
getCreatableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
@@ -975,7 +1039,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
!getCreatableVersion("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
getCreatableVersion("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
getCreatableVersion("camoufox")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -1045,7 +1145,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
getCreatableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
@@ -1077,7 +1177,7 @@ export function CreateProfileDialog({
size="sm"
variant="outline"
>
Retry
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
@@ -1086,7 +1186,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1122,18 +1222,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1432,7 +1529,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center">
<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...
{t("createProfile.version.fetching")}
</p>
</div>
)}
@@ -1458,7 +1555,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1494,16 +1591,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1701,7 +1797,7 @@ export function CreateProfileDialog({
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
+11 -15
View File
@@ -174,42 +174,38 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
}
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
return <LuDownload className="shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -232,7 +228,7 @@ export function UnifiedToast(props: ToastProps) {
<button
type="button"
onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
@@ -1129,10 +1129,10 @@ export function ExtensionManagementDialog({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+3 -3
View File
@@ -148,10 +148,10 @@ export function GroupBadges({
return (
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
@@ -165,7 +165,7 @@ export function GroupBadges({
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
+1
View File
@@ -321,6 +321,7 @@ const HomeHeader = ({
<span className="shrink-0">
<Button
size="sm"
data-onborda="create-profile"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
+2 -2
View File
@@ -303,7 +303,7 @@ export function ImportProfileDialog({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
@@ -604,7 +604,7 @@ export function ImportProfileDialog({
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
"shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
+100
View File
@@ -0,0 +1,100 @@
"use client";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
// thank-you dialog (skipping early does not emit it).
export function OnboardingCard({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
arrow,
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// This step is completed by clicking the highlighted element (the "New"
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
return (
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
<div className="flex gap-2 items-start justify-between">
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
{step.content}
</div>
<div className="flex gap-2 items-center justify-between mt-4">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => {
closeOnborda();
}}
>
{t("onboarding.buttons.skip")}
</Button>
)}
<div className="flex gap-2 items-center">
{!isFirst && !isLast && (
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => {
prevStep();
}}
>
{t("onboarding.buttons.back")}
</Button>
)}
{isLast ? (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
{t("onboarding.buttons.finish")}
</Button>
) : requiresAction ? null : (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
nextStep();
}}
>
{t("onboarding.buttons.next")}
</Button>
)}
</div>
</div>
<span className="text-popover">{arrow}</span>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
import { useTranslation } from "react-i18next";
import { OnboardingCard } from "@/components/onboarding-card";
// Name of the first-run product tour. Referenced by the trigger logic in
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
export const ONBOARDING_TOUR = "donut-onboarding";
export function OnboardingProvider({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const tours: OnbordaProps["steps"] = [
{
tour: ONBOARDING_TOUR,
steps: [
{
icon: null,
title: t("onboarding.steps.createProfile.title"),
content: t("onboarding.steps.createProfile.content"),
selector: '[data-onborda="create-profile"]',
// The "New" button sits in the top-right corner; "bottom-right"
// anchors the card's right edge to it so the card extends left/down
// and stays on-screen instead of overflowing the right viewport edge.
side: "bottom-right",
showControls: true,
pointerPadding: 8,
pointerRadius: 10,
},
{
icon: null,
title: t("onboarding.steps.dnsBlocking.title"),
content: t("onboarding.steps.dnsBlocking.content"),
selector: '[data-onborda="dns-blocklist"]',
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
// card runs off the right edge; "bottom-right" anchors the card's right
// edge to the dropdown and extends it left/down, keeping it fully
// on-screen with its arrow pointing up at the option.
side: "bottom-right",
showControls: true,
pointerPadding: 6,
pointerRadius: 8,
},
],
},
];
return (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.6"
>
{children}
</Onborda>
</OnbordaProvider>
);
}
+4 -6
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="size-8" />;
return <BsMic className="size-5 shrink-0" />;
case "camera":
return <BsCamera className="size-8" />;
return <BsCamera className="size-5 shrink-0" />;
}
};
@@ -195,13 +195,11 @@ 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 size-16 bg-primary/15 rounded-full">
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
{getPermissionTitle(permissionType)}
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-base text-pretty">
{getPermissionDescription(permissionType)}
</DialogDescription>
</DialogHeader>
+1
View File
@@ -441,6 +441,7 @@ function DnsCell({
<PopoverTrigger asChild>
<button
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
+29 -1
View File
@@ -16,6 +16,7 @@ import {
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -341,7 +342,9 @@ export function ProfileInfoDialog({
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
},
disabled: isDisabled,
// Viewing and editing fingerprints both require an active paid plan.
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
@@ -481,6 +484,9 @@ export function ProfileInfoDialog({
hideClose
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
>
{/* The dialog renders its own custom header, so the accessible title is
visually hidden but present for screen readers (Radix requires it). */}
<DialogTitle className="sr-only">{t("profileInfo.title")}</DialogTitle>
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
@@ -888,6 +894,7 @@ function ProfileInfoLayout({
// proBadge state. Default to false if action missing.
fingerprintAction && !fingerprintAction.proBadge,
)}
onSaved={onClose}
t={t}
/>
)}
@@ -1586,11 +1593,13 @@ function FingerprintSectionInline({
profile,
isDisabled,
crossOsUnlocked,
onSaved,
t,
}: {
profile: BrowserProfile;
isDisabled: boolean;
crossOsUnlocked: boolean;
onSaved: () => void;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
@@ -1629,6 +1638,23 @@ function FingerprintSectionInline({
);
}
// Viewing and editing fingerprints both require an active paid plan
// (`crossOsUnlocked` is that paid flag here). Render a locked state instead of
// the editor so free users can neither see nor change the fingerprint.
if (!crossOsUnlocked) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
<LuLock className="size-4 shrink-0 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{t("profileInfo.fingerprint.lockedTitle")}
</h3>
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
{t("profileInfo.fingerprint.lockedDescription")}
</p>
</div>
);
}
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
setSuccess(null);
@@ -1655,6 +1681,8 @@ function FingerprintSectionInline({
});
}
setSuccess(t("common.buttons.saved"));
// Close the dialog once the fingerprint is saved.
onSaved();
} catch (e) {
setError(String(e));
} finally {
@@ -1139,10 +1139,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1355,10 +1355,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+83
View File
@@ -0,0 +1,83 @@
"use client";
import confetti from "canvas-confetti";
import { motion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
// Celebratory close-out of the first-run onboarding: thanks the user and fires
// confetti. Shown once the product tour is finished.
export function ThankYouDialog({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) return;
const fire = (options: confetti.Options) => {
void confetti({ origin: { y: 0.7 }, ...options });
};
fire({ particleCount: 110, spread: 70, startVelocity: 48 });
const t1 = setTimeout(
() => fire({ particleCount: 70, spread: 100, decay: 0.92 }),
200,
);
const t2 = setTimeout(
() => fire({ particleCount: 50, spread: 120, scalar: 0.9 }),
420,
);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [isOpen]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<div className="flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ ...spring, delay: 0.05 }}
className="text-foreground"
>
<Logo className="size-14" />
</motion.div>
<div className="flex flex-col gap-2">
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
{t("onboarding.thankYou.title")}
</DialogTitle>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...spring, delay: 0.15 }}
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
>
{t("onboarding.thankYou.body")}
</motion.p>
</div>
<Button size="sm" onClick={onClose}>
{t("onboarding.thankYou.cta")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -312,7 +312,7 @@ export const ColorPickerAlpha = ({
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
<div className="absolute inset-0 bg-linear-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 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" />
+42 -28
View File
@@ -111,26 +111,39 @@ function DialogOverlay({
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
{...props}
>
{/* Keep the OS title-bar zone draggable while a modal is open the
overlay otherwise covers the native drag region. `data-window-drag-area`
stops Radix from treating a drag here as an outside-click dismiss. */}
<div
data-tauri-drag-region
data-window-drag-area="true"
aria-hidden="true"
className="absolute inset-x-0 top-0 h-11"
/>
<WindowDragArea />
</motion.div>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
/**
* Suppress the built-in top-right close X. Use when the dialog renders
* its own header bar with a custom close control to avoid two X buttons
* stacking near the corner.
*/
hideClose?: boolean;
/**
* When false, the user cannot dismiss the dialog Escape and outside
* clicks are ignored and the close X is hidden. Use for steps the user
* must complete to progress (e.g. required onboarding, a blocking
* download). The dialog can still be closed programmatically via `open`.
*/
dismissible?: boolean;
};
function SubPageContent({
@@ -176,7 +189,6 @@ function SubPageContent({
function DialogContent({
className,
children,
from = "top",
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
@@ -184,19 +196,11 @@ function DialogContent({
onInteractOutside,
transition,
hideClose,
dismissible = true,
...props
}: DialogContentProps) {
const { t } = useTranslation();
const { subPage } = useDialog();
const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
const finalTransition = transition ?? {
type: "spring",
stiffness: 220,
damping: 26,
};
if (subPage) {
return <SubPageContent>{children}</SubPageContent>;
@@ -210,9 +214,16 @@ function DialogContent({
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onEscapeKeyDown={(event) => {
if (!dismissible) event.preventDefault();
onEscapeKeyDown?.(event);
}}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={(event) => {
if (!dismissible) {
event.preventDefault();
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-window-drag-area="true"]')) {
event.preventDefault();
@@ -223,22 +234,25 @@ function DialogContent({
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
// Open/close motion modeled on transitions.dev's modal: a subtle
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
// translate stays in `style` so `scale` animates around the center
// without fighting the transform-based positioning.
style={{ transformOrigin: "center" }}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
scale: 0.96,
transition: transition ?? {
duration: 0.15,
ease: [0.22, 1, 0.36, 1],
},
}}
transition={finalTransition}
transition={
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className,
@@ -246,7 +260,7 @@ function DialogContent({
{...props}
>
{children}
{!hideClose && (
{!hideClose && dismissible && (
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
+2 -2
View File
@@ -15,12 +15,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-bg": "var(--card)",
"--normal-text": "var(--card-foreground)",
"--normal-border": "var(--border)",
zIndex: 99999,
zIndex: 10001,
} as React.CSSProperties
}
toastOptions={{
style: {
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
backdropFilter: "saturate(1.2)",
},
+8 -8
View File
@@ -1095,10 +1095,10 @@ export function WayfernConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1318,10 +1318,10 @@ export function WayfernConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+484
View File
@@ -0,0 +1,484 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuArrowRight,
LuBriefcase,
LuCookie,
LuFolders,
LuGithub,
LuGlobe,
LuHeart,
LuLoaderCircle,
LuMic,
LuNetwork,
LuShieldCheck,
LuTerminal,
LuTriangleAlert,
LuUsers,
} from "react-icons/lu";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useBrowserSetup } from "@/hooks/use-browser-setup";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
const panelTransition = {
type: "spring",
stiffness: 260,
damping: 28,
} as const;
const panelVariants = {
enter: { opacity: 0, y: 12 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12 },
};
// Concrete feature list shown on the intro step, rendered as an icon grid.
const FEATURES = [
{ key: "welcome.features.items.setDefault", Icon: LuGlobe },
{ key: "welcome.features.items.proxy", Icon: LuNetwork },
{ key: "welcome.features.items.vpn", Icon: LuShieldCheck },
{ key: "welcome.features.items.profiles", Icon: LuUsers },
{ key: "welcome.features.items.api", Icon: LuTerminal },
{ key: "welcome.features.items.openSource", Icon: LuGithub },
{ key: "welcome.features.items.groups", Icon: LuFolders },
{ key: "welcome.features.items.cookies", Icon: LuCookie },
] as const;
function formatBytes(bytes: number): string {
if (!(bytes > 0)) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const exponent = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(1024)),
);
const value = bytes / 1024 ** exponent;
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
return `${rounded} ${units[exponent]}`;
}
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
if (total < 60) return `${total}s`;
const minutes = Math.floor(total / 60);
const remainder = total % 60;
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
}
export function WelcomeDialog({
isOpen,
needsSetup,
onComplete,
}: {
isOpen: boolean;
/**
* Whether this user still needs the browser-download + profile-creation flow.
* False when they already have a profile then the welcome and commercial-use
* steps still show, but "continue" finishes onboarding instead of proceeding
* to permissions/download.
*/
needsSetup: boolean;
onComplete: () => void;
}) {
const { t } = useTranslation();
const { requestPermission } = usePermissions();
const [step, setStep] = useState<WelcomeStep>("intro");
// Where the "skip" / "continue" affordances go: into the setup flow when a
// browser/profile is still needed, otherwise straight to completion.
const advanceToSetup = () => {
if (needsSetup) setStep("setup");
else onComplete();
};
const [requesting, setRequesting] = useState(false);
// Track the required browser's download + extraction the whole time the
// dialog is open, so progress is live by the time the user reaches setup.
const setup = useBrowserSetup("wayfern", isOpen);
const browserName = getBrowserDisplayName("wayfern");
const requestPermissions = useCallback(async () => {
setRequesting(true);
try {
await requestPermission("microphone");
await requestPermission("camera");
} catch (err) {
console.error("Permission request failed:", err);
} finally {
setRequesting(false);
setStep("setup");
}
}, [requestPermission]);
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent
dismissible={false}
className="overflow-hidden sm:max-w-xl"
>
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
<AnimatePresence mode="wait">
{step === "intro" && (
<motion.div
key="intro"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col items-center gap-4 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...panelTransition, delay: 0.05 }}
className="text-foreground"
>
<Logo className="size-12" />
</motion.div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm text-pretty text-muted-foreground">
{t("welcome.tagline")}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">
{t("welcome.features.title")}
</p>
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map(({ key, Icon }, i) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...panelTransition,
delay: 0.12 + i * 0.04,
}}
className="flex items-center gap-2.5"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<dt className="text-sm font-medium text-foreground">
{t(key)}
</dt>
</motion.div>
))}
</dl>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => setStep("license")}
>
{t("welcome.next")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "license" && (
<motion.div
key="license"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.license.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{t("welcome.license.body")}
</p>
</div>
<dl className="flex flex-col gap-3">
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-sm font-medium text-foreground">
{t("welcome.license.personalTitle")}
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
{t("welcome.license.personalDesc")}
</dd>
</div>
</div>
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex items-center gap-2 text-sm font-medium text-foreground">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("welcome.license.trialBadge")}
</span>
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
{t("welcome.license.commercialDesc")}
</dd>
</div>
</div>
</dl>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => {
if (needsSetup) setStep("permissions");
else onComplete();
}}
>
{t("welcome.license.agree")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "permissions" && (
<motion.div
key="permissions"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance">
<LuMic className="size-5 shrink-0" />
{t("welcome.permissions.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{t("welcome.permissions.desc")}
</p>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={requesting}
onClick={advanceToSetup}
>
{t("welcome.permissions.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
disabled={requesting}
onClick={() => {
void requestPermissions();
}}
>
{requesting && (
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
)}
{requesting
? t("welcome.permissions.requesting")
: t("welcome.permissions.grant")}
</Button>
</div>
</motion.div>
)}
{step === "setup" && (
<motion.div
key="setup"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col items-center gap-6 text-center"
>
{setup.phase === "error" ? (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{setup.error?.stage === "downloading"
? t("welcome.ready.errorDownload", {
browser: browserName,
})
: setup.error?.stage === "extracting" ||
setup.error?.stage === "verifying"
? t("welcome.ready.errorExtraction", {
browser: browserName,
})
: t("welcome.ready.errorGeneric", {
browser: browserName,
})}
</p>
</div>
{/* No escape hatch here: a browser must finish downloading
before onboarding can complete, so the only action on
failure is to retry. */}
<Button
size="sm"
onClick={() => {
setup.retry();
}}
>
{t("welcome.ready.retry")}
</Button>
</>
) : (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.ready.title")}
</h2>
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{setup.phase === "ready"
? t("welcome.ready.descReady")
: setup.phase === "extracting"
? t("welcome.ready.descExtracting")
: t("welcome.ready.descDownloading")}
</p>
</div>
{setup.phase === "downloading" && (
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.downloadPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.downloading")}
</span>
<span>{setup.downloadPercent}%</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
<span>
{setup.totalBytes != null
? t("welcome.ready.stats", {
downloaded: formatBytes(setup.downloadedBytes),
total: formatBytes(setup.totalBytes),
})
: formatBytes(setup.downloadedBytes)}
</span>
{setup.speedBytesPerSec > 0 && (
<span>
{t("welcome.ready.speed", {
speed: formatBytes(setup.speedBytesPerSec),
})}
</span>
)}
{setup.etaSeconds != null &&
Number.isFinite(setup.etaSeconds) &&
setup.etaSeconds > 0 && (
<span>
{t("welcome.ready.timeLeft", {
time: formatDuration(setup.etaSeconds),
})}
</span>
)}
</div>
</div>
)}
{setup.phase === "extracting" && (
<div className="flex w-full max-w-xs flex-col gap-2">
{setup.extractionOvertime ? (
<div className="flex items-center justify-center gap-1.5 text-sm tabular-nums text-muted-foreground">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.almostFinished")}
</div>
) : (
<>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.extractionPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.extracting")}
</span>
<span>{setup.extractionPercent}%</span>
</div>
</>
)}
</div>
)}
{setup.phase === "ready" && (
<Button size="sm" className="gap-1.5" onClick={onComplete}>
<LuArrowRight className="size-4 shrink-0" />
{t("welcome.ready.cta")}
</Button>
)}
</>
)}
</motion.div>
)}
</AnimatePresence>
</DialogContent>
</Dialog>
);
}
+49 -34
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { isOnboardingActive } from "@/lib/onboarding-signal";
import {
dismissToast,
showDownloadToast,
@@ -327,31 +328,39 @@ export function useBrowserDownload() {
: i18n.t("browserDownload.toast.calculating");
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
showDownloadToast(
browserName,
progress.version,
"downloading",
{
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
},
{
onCancel: () => {
invoke("cancel_download", {
browserStr: progress.browser,
version: progress.version,
}).catch((err) => {
console.error("Failed to cancel download:", err);
});
dismissToast(toastId);
// During first-run onboarding the welcome dialog shows browser
// setup progress itself, so suppress the global download toast.
if (!isOnboardingActive()) {
showDownloadToast(
browserName,
progress.version,
"downloading",
{
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
},
},
);
{
onCancel: () => {
invoke("cancel_download", {
browserStr: progress.browser,
version: progress.version,
}).catch((err) => {
console.error("Failed to cancel download:", err);
});
dismissToast(toastId);
},
},
);
}
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "extracting");
}
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "verifying");
}
} else if (progress.stage === "cancelled") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
@@ -372,17 +381,21 @@ export function useBrowserDownload() {
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
showErrorToast(
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
),
},
);
// During first-run onboarding the welcome dialog surfaces a
// concrete setup error itself, so suppress the global toast.
if (!isOnboardingActive()) {
showErrorToast(
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
),
},
);
}
} else if (progress.stage === "completed") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
@@ -401,7 +414,9 @@ export function useBrowserDownload() {
} catch {
/* empty */
}
showDownloadToast(browserName, progress.version, "completed");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "completed");
}
setDownloadProgress(null);
}
},
+342
View File
@@ -0,0 +1,342 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
interface DownloadProgress {
browser: string;
version: string;
downloaded_bytes: number;
total_bytes: number | null;
percentage: number;
speed_bytes_per_sec: number;
eta_seconds?: number | null;
stage: string;
}
export type SetupPhase = "downloading" | "extracting" | "ready" | "error";
export type SetupErrorStage =
| "downloading"
| "extracting"
| "verifying"
| "other";
export interface SetupError {
stage: SetupErrorStage;
}
// The backend emits a real percentage only while downloading; extraction sends
// a single "extracting" event with no incremental progress (it takes ~2 min).
// So we estimate extraction progress from elapsed time vs. a learned average,
// seeded at 2 minutes and refined with the real durations we record.
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
const storageKey = (browser: string) => `donut.extractDurations.${browser}`;
function readDurations(browser: string): number[] {
try {
const raw = localStorage.getItem(storageKey(browser));
const arr = raw ? (JSON.parse(raw) as unknown) : null;
if (
Array.isArray(arr) &&
arr.length > 0 &&
arr.every((n) => typeof n === "number" && n > 0)
) {
return arr as number[];
}
} catch {
// fall through to the seed
}
return [DEFAULT_EXTRACT_MS];
}
function recordDuration(browser: string, ms: number) {
if (!(ms > 0)) return;
const current = readDurations(browser);
// Keep the 2-min seed as the first value, then the most recent real samples.
const samples =
current[0] === DEFAULT_EXTRACT_MS ? current.slice(1) : current;
const next = [
DEFAULT_EXTRACT_MS,
...[...samples, ms].slice(-(MAX_SAMPLES - 1)),
];
try {
localStorage.setItem(storageKey(browser), JSON.stringify(next));
} catch {
// ignore persistence failures
}
}
function average(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / values.length;
}
// Map a backend stage to the error stage we report when something fails.
function toErrorStage(stage: string): SetupErrorStage {
switch (stage) {
case "downloading":
return "downloading";
case "extracting":
return "extracting";
case "verifying":
return "verifying";
default:
return "other";
}
}
/**
* Tracks first-launch setup of a browser: real download progress plus an
* estimated extraction progress (no countdown timer, percentages only).
* `active` should be true while the owning dialog is open.
*/
export function useBrowserSetup(browser: string, active: boolean) {
const [phase, setPhase] = useState<SetupPhase>("downloading");
// Download metrics straight from the latest "downloading" event.
const [downloadPercent, setDownloadPercent] = useState(0);
const [downloadedBytes, setDownloadedBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState<number | null>(null);
const [speedBytesPerSec, setSpeedBytesPerSec] = useState(0);
const [etaSeconds, setEtaSeconds] = useState<number | null>(null);
// Estimated extraction progress (percentages only, capped at 99 until done).
const [extractionPercent, setExtractionPercent] = useState(0);
const [extractionOvertime, setExtractionOvertime] = useState(false);
const [error, setError] = useState<SetupError | null>(null);
const extractStartRef = useRef<number | null>(null);
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
// misses the single "extracting" event) can still show extraction progress.
const sawDownloadingRef = useRef(false);
const lastProgressAtRef = useRef<number | null>(null);
const lastDownloadPercentRef = useRef(0);
// The last non-terminal stage we observed, used to label an error.
const lastStageRef = useRef<string>("downloading");
// Set once a terminal state (ready/error) is reached. Stops the tick so the
// mid-flight extraction fallback can't re-arm and fight the readiness poll
// (which would oscillate "ready" ↔ "Almost finished" forever).
const doneRef = useRef(false);
useEffect(() => {
if (!active) {
// Fully reset when the owning dialog closes.
setPhase("downloading");
setDownloadPercent(0);
setDownloadedBytes(0);
setTotalBytes(null);
setSpeedBytesPerSec(0);
setEtaSeconds(null);
setExtractionPercent(0);
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
return;
}
let alive = true;
estimateRef.current = average(readDurations(browser));
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
const finishExtraction = () => {
if (extractStartRef.current != null) {
recordDuration(browser, Date.now() - extractStartRef.current);
extractStartRef.current = null;
}
};
const unlistenPromise = listen<DownloadProgress>(
"download-progress",
(event) => {
if (!alive) return;
const p = event.payload;
if (p.browser !== browser) return;
switch (p.stage) {
case "downloading":
lastStageRef.current = "downloading";
sawDownloadingRef.current = true;
lastProgressAtRef.current = Date.now();
lastDownloadPercentRef.current = p.percentage;
setPhase("downloading");
setDownloadPercent(Math.round(p.percentage));
setDownloadedBytes(p.downloaded_bytes);
setTotalBytes(p.total_bytes ?? null);
setSpeedBytesPerSec(p.speed_bytes_per_sec);
setEtaSeconds(p.eta_seconds ?? null);
break;
case "extracting":
lastStageRef.current = "extracting";
if (extractStartRef.current == null) {
extractStartRef.current = Date.now();
}
lastProgressAtRef.current = Date.now();
setPhase("extracting");
break;
case "verifying":
lastStageRef.current = "verifying";
finishExtraction();
// Verification is the tail of extraction; keep the bar near full
// but don't claim "ready" until "completed" arrives.
setPhase("extracting");
setExtractionPercent(99);
break;
case "completed":
doneRef.current = true;
finishExtraction();
setPhase("ready");
setExtractionPercent(100);
setExtractionOvertime(false);
setError(null);
break;
case "error":
doneRef.current = true;
finishExtraction();
setPhase("error");
setError({ stage: toErrorStage(lastStageRef.current) });
break;
case "cancelled":
// Treat a cancellation like an error so the dialog can offer retry.
doneRef.current = true;
finishExtraction();
setPhase("error");
setError({ stage: "other" });
break;
default:
break;
}
},
);
// Authoritative completion signal: poll the registry. The "completed" event
// is only a fast-path — we never rely on it alone. This MUST be a recurring
// interval rather than a one-shot loop: independent firings mean a single
// invoke that stalls during heavy extraction can't kill detection, it keeps
// confirming readiness so retry() re-detects an already-downloaded browser
// without restarting the effect, and it covers a browser downloaded before
// this hook mounted. setPhase("ready") is idempotent, so re-confirming is
// free (React bails out when state is unchanged).
let checkingReady = false;
const checkReady = async () => {
if (!alive || checkingReady) return;
checkingReady = true;
try {
const versions = await invoke<string[]>(
"get_downloaded_browser_versions",
{ browserStr: browser },
);
if (alive && versions.length > 0) {
doneRef.current = true;
finishExtraction();
setPhase("ready");
setExtractionPercent(100);
setExtractionOvertime(false);
setError(null);
}
} catch (err) {
console.error("Failed to check browser download status:", err);
} finally {
checkingReady = false;
}
};
void checkReady();
const readyPoll = setInterval(() => {
void checkReady();
}, 1000);
// Drive the estimated extraction percentage while extracting.
const tick = setInterval(() => {
if (!alive || doneRef.current) return;
// If the download visibly finished but we never saw the (single)
// "extracting" event, start estimating extraction anyway — anchored to
// the last download event, which is roughly when extraction began.
if (
extractStartRef.current == null &&
sawDownloadingRef.current &&
lastDownloadPercentRef.current >= 99 &&
lastProgressAtRef.current != null &&
Date.now() - lastProgressAtRef.current > 1200
) {
extractStartRef.current = lastProgressAtRef.current;
lastStageRef.current = "extracting";
setPhase("extracting");
}
if (extractStartRef.current == null) return;
const elapsed = Date.now() - extractStartRef.current;
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
if (elapsed >= est) {
// We've blown past the estimate — hold at 99 and flag overtime so the
// dialog can show "Almost finished" instead of a stalled number.
setExtractionPercent(99);
setExtractionOvertime(true);
} else {
setExtractionPercent(Math.min(99, Math.round((elapsed / est) * 100)));
setExtractionOvertime(false);
}
}, 250);
return () => {
alive = false;
clearInterval(tick);
clearInterval(readyPoll);
void unlistenPromise.then((u) => {
u();
});
};
}, [browser, active]);
const retry = useCallback(() => {
// Reset visible state and the bookkeeping refs, then kick off the download
// again. The effect's event listener and registry poll stay alive the whole
// time the dialog is open, so they pick up the fresh attempt — no need to
// restart the effect.
setPhase("downloading");
setDownloadPercent(0);
setDownloadedBytes(0);
setTotalBytes(null);
setSpeedBytesPerSec(0);
setEtaSeconds(null);
setExtractionPercent(0);
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
void (async () => {
try {
await invoke("ensure_active_browsers_downloaded");
} catch (err) {
console.error("Failed to re-trigger browser setup:", err);
setPhase("error");
setError({ stage: "other" });
}
})();
}, []);
return {
phase,
downloadPercent,
downloadedBytes,
totalBytes,
speedBytesPerSec,
etaSeconds,
extractionPercent,
extractionOvertime,
ready: phase === "ready",
error,
retry,
};
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "Downloading {{browser}} version ({{version}})...",
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
"latestAvailable": "Latest version ({{version}}) is available",
"latestDownloading": "Downloading version ({{version}})..."
"latestDownloading": "Downloading version ({{version}})...",
"upgradeAvailable": "A newer version ({{version}}) of {{browser}} is available."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Powered by Wayfern",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "Password protect this profile",
"description": "Encrypts the on-disk profile data. Required to launch."
}
},
"downloadingSubtitle": "Downloading…",
"browsersDownloading": "Browsers are still downloading. Profile creation will be available once a download finishes."
},
"deleteDialog": {
"title": "Delete Profile",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "Stop the profile before changing its password."
},
"fingerprint": {
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles."
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
"lockedTitle": "Fingerprint is a Pro feature",
"lockedDescription": "Viewing and editing a profile's fingerprint requires an active paid plan. Upgrade to unlock fingerprint protection."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"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.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
"fingerprintRequiresPro": "Fingerprint protection requires an active paid plan.",
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
},
"rail": {
"profiles": "Profiles",
@@ -1867,7 +1876,8 @@
"plan": "Plan",
"status": "Status",
"teamRole": "Team role",
"period": "Billing period"
"period": "Billing period",
"device": "Device"
},
"tabs": {
"account": "Account",
@@ -1881,7 +1891,10 @@
"statusUnknown": "Untested",
"testConnection": "Test connection",
"disconnect": "Disconnect"
}
},
"deviceOrdinal": "{{ordinal}} of {{count}}",
"automationPrimaryOnly": "Browser automation runs only on your primary device (Device 1). Sign out there to use it here.",
"automationActiveHere": "Browser automation is active on this device."
},
"shortcutsPage": {
"title": "Keyboard shortcuts",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "Browser support ending soon",
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Create your first profile",
"content": "Click here to create your first profile. Pick Wayfern as the browser — the recommended, fingerprint-protected Chromium."
},
"dnsBlocking": {
"title": "DNS blocking",
"content": "Use this dropdown to set a DNS blocklist level for the profile — it blocks ads, trackers, and malware at the network level. Higher levels block more."
}
},
"buttons": {
"skip": "Skip",
"back": "Back",
"next": "Next",
"finish": "Finish"
},
"thankYou": {
"title": "Thank you for choosing Donut Browser",
"body": "Hopefully it helps make your browsing more private — every identity kept its own, and nothing leaving your machine. Enjoy.",
"cta": "Start browsing"
}
},
"welcome": {
"title": "Welcome to Donut Browser",
"tagline": "An open-source anti-detect browser for managing many identities at once.",
"skip": "Skip",
"next": "Next",
"permissions": {
"title": "Allow microphone & camera",
"desc": "Grant access so sites that need a mic or camera work inside your browser profiles. macOS asks once; each site still asks you individually.",
"skip": "Not now",
"grant": "Allow access",
"requesting": "Requesting…"
},
"ready": {
"title": "Setting things up",
"descDownloading": "Downloading your first browser (Wayfern). This one-time setup runs in the background — hang tight.",
"descReady": "Your browser is ready. Let's create your first profile.",
"cta": "Create my first profile",
"downloading": "Downloading…",
"extracting": "Extracting…",
"stats": "{{downloaded}} of {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} left",
"descExtracting": "Extracting your browser. This one-time setup runs in the background — hang tight.",
"almostFinished": "Almost finished…",
"errorTitle": "Setup failed",
"errorDownload": "{{browser}} couldn't be downloaded. Check your connection and try again.",
"errorExtraction": "{{browser}} couldn't be extracted. Please try again.",
"errorGeneric": "Something went wrong while setting up {{browser}}. Please try again.",
"retry": "Try again"
},
"features": {
"title": "Features",
"items": {
"setDefault": "Set as Default Browser",
"proxy": "Proxy Support (HTTP/SOCKS5)",
"vpn": "VPN Support (WireGuard)",
"profiles": "Unlimited Local Profiles",
"api": "Profile Management API & MCP",
"openSource": "Open Source",
"groups": "Profile Groups",
"cookies": "Cookie Import & Export"
}
},
"license": {
"title": "Licensing",
"body": "Donut Browser is open source and free to use.",
"agree": "I understand",
"personalTitle": "Personal use",
"personalDesc": "Free forever.",
"commercialTitle": "Commercial use",
"trialBadge": "2 weeks free",
"commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "Descargando versión de {{browser}} ({{version}})...",
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
"latestAvailable": "La última versión ({{version}}) está disponible",
"latestDownloading": "Descargando versión ({{version}})..."
"latestDownloading": "Descargando versión ({{version}})...",
"upgradeAvailable": "Hay una versión más reciente ({{version}}) de {{browser}} disponible."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Impulsado por Wayfern",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "Proteger este perfil con contraseña",
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
}
},
"downloadingSubtitle": "Descargando…",
"browsersDownloading": "Los navegadores aún se están descargando. La creación de perfiles estará disponible cuando termine una descarga."
},
"deleteDialog": {
"title": "Eliminar Perfil",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
},
"fingerprint": {
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern."
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
"lockedTitle": "La huella digital es una función Pro",
"lockedDescription": "Ver y editar la huella digital de un perfil requiere un plan de pago activo. Mejora tu plan para desbloquear la protección de huella digital."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"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.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
"fingerprintRequiresPro": "La protección de huella digital requiere un plan de pago activo.",
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
},
"rail": {
"profiles": "Perfiles",
@@ -1867,7 +1876,8 @@
"plan": "Plan",
"status": "Estado",
"teamRole": "Rol en el equipo",
"period": "Período"
"period": "Período",
"device": "Dispositivo"
},
"tabs": {
"account": "Cuenta",
@@ -1881,7 +1891,10 @@
"statusUnknown": "Sin probar",
"testConnection": "Probar conexión",
"disconnect": "Desconectar"
}
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "La automatización del navegador solo funciona en tu dispositivo principal (Dispositivo 1). Cierra sesión allí para usarla aquí.",
"automationActiveHere": "La automatización del navegador está activa en este dispositivo."
},
"shortcutsPage": {
"title": "Atajos de teclado",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "El soporte del navegador finalizará pronto",
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Crea tu primer perfil",
"content": "Haz clic aquí para crear tu primer perfil. Elige Wayfern como navegador: el Chromium recomendado y protegido contra huellas digitales."
},
"dnsBlocking": {
"title": "Bloqueo DNS",
"content": "Usa este menú para definir el nivel de la lista de bloqueo DNS del perfil: bloquea anuncios, rastreadores y malware a nivel de red. Los niveles más altos bloquean más."
}
},
"buttons": {
"skip": "Omitir",
"back": "Atrás",
"next": "Siguiente",
"finish": "Finalizar"
},
"thankYou": {
"title": "Gracias por elegir Donut Browser",
"body": "Ojalá ayude a hacer tu navegación más privada: cada identidad por separado y sin que nada salga de tu equipo. ¡Que lo disfrutes!",
"cta": "Empezar a navegar"
}
},
"welcome": {
"title": "Te damos la bienvenida a Donut Browser",
"tagline": "Un navegador antidetección de código abierto para gestionar muchas identidades a la vez.",
"skip": "Omitir",
"next": "Siguiente",
"permissions": {
"title": "Permitir micrófono y cámara",
"desc": "Concede acceso para que los sitios que necesitan micrófono o cámara funcionen en tus perfiles de navegador. macOS lo pregunta una vez; cada sitio te lo seguirá pidiendo por separado.",
"skip": "Ahora no",
"grant": "Permitir acceso",
"requesting": "Solicitando…"
},
"ready": {
"title": "Preparando todo",
"descDownloading": "Descargando tu primer navegador (Wayfern). Esta configuración única se ejecuta en segundo plano; espera un momento.",
"descReady": "Tu navegador está listo. Vamos a crear tu primer perfil.",
"cta": "Crear mi primer perfil",
"downloading": "Descargando…",
"extracting": "Extrayendo…",
"stats": "{{downloaded}} de {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restante",
"descExtracting": "Extrayendo tu navegador. Esta configuración única se ejecuta en segundo plano: espera un momento.",
"almostFinished": "Casi terminado…",
"errorTitle": "Error en la configuración",
"errorDownload": "No se pudo descargar {{browser}}. Comprueba tu conexión e inténtalo de nuevo.",
"errorExtraction": "No se pudo extraer {{browser}}. Inténtalo de nuevo.",
"errorGeneric": "Algo salió mal al configurar {{browser}}. Inténtalo de nuevo.",
"retry": "Reintentar"
},
"features": {
"title": "Funciones",
"items": {
"setDefault": "Establecer como navegador predeterminado",
"proxy": "Compatibilidad con proxy (HTTP/SOCKS5)",
"vpn": "Compatibilidad con VPN (WireGuard)",
"profiles": "Perfiles locales ilimitados",
"api": "API de gestión de perfiles y MCP",
"openSource": "Código abierto",
"groups": "Grupos de perfiles",
"cookies": "Importar y exportar cookies"
}
},
"license": {
"title": "Licencias",
"body": "Donut Browser es de código abierto y de uso gratuito.",
"agree": "Entendido",
"personalTitle": "Uso personal",
"personalDesc": "Gratis para siempre.",
"commercialTitle": "Uso comercial",
"trialBadge": "2 semanas gratis",
"commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "Téléchargement de la version de {{browser}} ({{version}})...",
"latestNeedsDownload": "La dernière version ({{version}}) doit être téléchargée",
"latestAvailable": "La dernière version ({{version}}) est disponible",
"latestDownloading": "Téléchargement de la version ({{version}})..."
"latestDownloading": "Téléchargement de la version ({{version}})...",
"upgradeAvailable": "Une version plus récente ({{version}}) de {{browser}} est disponible."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Propulsé par Wayfern",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "Protéger ce profil par mot de passe",
"description": "Chiffre les données du profil sur disque. Requis au lancement."
}
},
"downloadingSubtitle": "Téléchargement…",
"browsersDownloading": "Les navigateurs sont encore en cours de téléchargement. La création de profils sera disponible une fois un téléchargement terminé."
},
"deleteDialog": {
"title": "Supprimer le profil",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "Arrêtez le profil avant de modifier son mot de passe."
},
"fingerprint": {
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern."
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern.",
"lockedTitle": "L'empreinte est une fonctionnalité Pro",
"lockedDescription": "Afficher et modifier l'empreinte d'un profil nécessite un forfait payant actif. Passez à un forfait supérieur pour débloquer la protection contre le fingerprinting."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"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.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
"fingerprintRequiresPro": "La protection contre le fingerprinting nécessite un forfait payant actif.",
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
},
"rail": {
"profiles": "Profils",
@@ -1867,7 +1876,8 @@
"plan": "Plan",
"status": "Statut",
"teamRole": "Rôle d’équipe",
"period": "Période"
"period": "Période",
"device": "Appareil"
},
"tabs": {
"account": "Compte",
@@ -1881,7 +1891,10 @@
"statusUnknown": "Non testé",
"testConnection": "Tester la connexion",
"disconnect": "Déconnecter"
}
},
"deviceOrdinal": "{{ordinal}} sur {{count}}",
"automationPrimaryOnly": "L'automatisation du navigateur ne fonctionne que sur votre appareil principal (Appareil 1). Déconnectez-vous là-bas pour l'utiliser ici.",
"automationActiveHere": "L'automatisation du navigateur est active sur cet appareil."
},
"shortcutsPage": {
"title": "Raccourcis clavier",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Créez votre premier profil",
"content": "Cliquez ici pour créer votre premier profil. Choisissez Wayfern comme navigateur : le Chromium recommandé et protégé contre le fingerprinting."
},
"dnsBlocking": {
"title": "Blocage DNS",
"content": "Utilisez ce menu pour définir le niveau de la liste de blocage DNS du profil : il bloque les publicités, les traqueurs et les logiciels malveillants au niveau du réseau. Les niveaux supérieurs bloquent davantage."
}
},
"buttons": {
"skip": "Passer",
"back": "Retour",
"next": "Suivant",
"finish": "Terminer"
},
"thankYou": {
"title": "Merci d'avoir choisi Donut Browser",
"body": "Avec un peu de chance, il rendra votre navigation plus privée : chaque identité séparée et rien ne quittant votre machine. Bonne navigation !",
"cta": "Commencer à naviguer"
}
},
"welcome": {
"title": "Bienvenue dans Donut Browser",
"tagline": "Un navigateur anti-détection open source pour gérer de nombreuses identités à la fois.",
"skip": "Passer",
"next": "Suivant",
"permissions": {
"title": "Autoriser le micro et la caméra",
"desc": "Accordez l'accès pour que les sites nécessitant un micro ou une caméra fonctionnent dans vos profils de navigateur. macOS le demande une fois ; chaque site vous le demandera quand même individuellement.",
"skip": "Plus tard",
"grant": "Autoriser l'accès",
"requesting": "Demande en cours…"
},
"ready": {
"title": "Préparation en cours",
"descDownloading": "Téléchargement de votre premier navigateur (Wayfern). Cette configuration unique s'exécute en arrière-plan — patientez un instant.",
"descReady": "Votre navigateur est prêt. Créons votre premier profil.",
"cta": "Créer mon premier profil",
"downloading": "Téléchargement…",
"extracting": "Extraction…",
"stats": "{{downloaded}} sur {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restant",
"descExtracting": "Extraction de votre navigateur. Cette configuration unique s'exécute en arrière-plan, patientez.",
"almostFinished": "Presque terminé…",
"errorTitle": "Échec de la configuration",
"errorDownload": "Impossible de télécharger {{browser}}. Vérifiez votre connexion et réessayez.",
"errorExtraction": "Impossible d'extraire {{browser}}. Veuillez réessayer.",
"errorGeneric": "Une erreur s'est produite lors de la configuration de {{browser}}. Veuillez réessayer.",
"retry": "Réessayer"
},
"features": {
"title": "Fonctionnalités",
"items": {
"setDefault": "Définir comme navigateur par défaut",
"proxy": "Prise en charge des proxys (HTTP/SOCKS5)",
"vpn": "Prise en charge du VPN (WireGuard)",
"profiles": "Profils locaux illimités",
"api": "API de gestion des profils et MCP",
"openSource": "Open source",
"groups": "Groupes de profils",
"cookies": "Import et export de cookies"
}
},
"license": {
"title": "Licence",
"body": "Donut Browser est open source et gratuit.",
"agree": "J'ai compris",
"personalTitle": "Usage personnel",
"personalDesc": "Gratuit à vie.",
"commercialTitle": "Usage commercial",
"trialBadge": "2 semaines gratuites",
"commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "{{browser}} バージョン ({{version}}) をダウンロード中...",
"latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります",
"latestAvailable": "最新バージョン ({{version}}) は利用可能です",
"latestDownloading": "バージョン ({{version}}) をダウンロード中..."
"latestDownloading": "バージョン ({{version}}) をダウンロード中...",
"upgradeAvailable": "{{browser}} の新しいバージョン({{version}})が利用可能です。"
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Wayfern搭載",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "このプロファイルをパスワードで保護",
"description": "ディスク上のプロファイルデータを暗号化します。起動に必要です。"
}
},
"downloadingSubtitle": "ダウンロード中…",
"browsersDownloading": "ブラウザをダウンロード中です。ダウンロードが完了するとプロファイルを作成できます。"
},
"deleteDialog": {
"title": "プロファイルを削除",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "パスワードを変更する前にプロファイルを停止してください。"
},
"fingerprint": {
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。"
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
"lockedTitle": "フィンガープリントは Pro 機能です",
"lockedDescription": "プロファイルのフィンガープリントの表示と編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。"
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。",
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -1867,7 +1876,8 @@
"plan": "プラン",
"status": "ステータス",
"teamRole": "チームロール",
"period": "請求周期"
"period": "請求周期",
"device": "デバイス"
},
"tabs": {
"account": "アカウント",
@@ -1881,7 +1891,10 @@
"statusUnknown": "未テスト",
"testConnection": "接続をテスト",
"disconnect": "切断"
}
},
"deviceOrdinal": "{{count}} 台中 {{ordinal}} 台目",
"automationPrimaryOnly": "ブラウザの自動化はプライマリデバイス(デバイス1)でのみ実行できます。ここで使用するには、そのデバイスでサインアウトしてください。",
"automationActiveHere": "ブラウザの自動化はこのデバイスで有効です。"
},
"shortcutsPage": {
"title": "キーボードショートカット",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
"endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
},
"onboarding": {
"steps": {
"createProfile": {
"title": "最初のプロファイルを作成",
"content": "ここをクリックして最初のプロファイルを作成します。ブラウザには Wayfern を選んでください。フィンガープリント対策済みの推奨 Chromium です。"
},
"dnsBlocking": {
"title": "DNS ブロック",
"content": "このドロップダウンでプロファイルの DNS ブロックリストのレベルを設定します。広告・トラッカー・マルウェアをネットワークレベルでブロックします。レベルが高いほど多くブロックします。"
}
},
"buttons": {
"skip": "スキップ",
"back": "戻る",
"next": "次へ",
"finish": "完了"
},
"thankYou": {
"title": "Donut Browser を選んでいただきありがとうございます",
"body": "それぞれのIDを分けて、データを端末の外に出さずに、よりプライベートなブラウジングのお役に立てれば幸いです。どうぞお楽しみください。",
"cta": "ブラウジングを始める"
}
},
"welcome": {
"title": "Donut Browser へようこそ",
"tagline": "複数のIDを同時に管理できるオープンソースのアンチディテクトブラウザ。",
"skip": "スキップ",
"next": "次へ",
"permissions": {
"title": "マイクとカメラを許可",
"desc": "マイクやカメラを必要とするサイトがブラウザプロファイル内で動作するよう、アクセスを許可してください。macOS は一度だけ確認します。各サイトは引き続き個別に許可を求めます。",
"skip": "後で",
"grant": "アクセスを許可",
"requesting": "リクエスト中…"
},
"ready": {
"title": "準備しています",
"descDownloading": "最初のブラウザ(Wayfern)をダウンロードしています。この初回セットアップはバックグラウンドで実行されます。少々お待ちください。",
"descReady": "ブラウザの準備ができました。最初のプロファイルを作成しましょう。",
"cta": "最初のプロファイルを作成",
"downloading": "ダウンロード中…",
"extracting": "展開中…",
"stats": "{{total}} 中 {{downloaded}}",
"speed": "{{speed}}/秒",
"timeLeft": "残り {{time}}",
"descExtracting": "ブラウザを展開しています。この初回セットアップはバックグラウンドで実行されます。少々お待ちください。",
"almostFinished": "まもなく完了します…",
"errorTitle": "セットアップに失敗しました",
"errorDownload": "{{browser}} をダウンロードできませんでした。接続を確認して、もう一度お試しください。",
"errorExtraction": "{{browser}} を展開できませんでした。もう一度お試しください。",
"errorGeneric": "{{browser}} のセットアップ中に問題が発生しました。もう一度お試しください。",
"retry": "再試行"
},
"features": {
"title": "機能",
"items": {
"setDefault": "既定のブラウザに設定",
"proxy": "プロキシ対応(HTTP/SOCKS5",
"vpn": "VPN対応(WireGuard",
"profiles": "無制限のローカルプロファイル",
"api": "プロファイル管理APIとMCP",
"openSource": "オープンソース",
"groups": "プロファイルグループ",
"cookies": "Cookieのインポート・エクスポート"
}
},
"license": {
"title": "ライセンス",
"body": "Donut Browser はオープンソースで、無料で利用できます。",
"agree": "了解しました",
"personalTitle": "個人利用",
"personalDesc": "永久に無料です。",
"commercialTitle": "商用利用",
"trialBadge": "2週間無料",
"commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。"
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "{{browser}} 버전 ({{version}})을 다운로드하는 중...",
"latestNeedsDownload": "최신 버전 ({{version}})을 다운로드해야 합니다",
"latestAvailable": "최신 버전 ({{version}})을 사용할 수 있습니다",
"latestDownloading": "버전 ({{version}})을 다운로드하는 중..."
"latestDownloading": "버전 ({{version}})을 다운로드하는 중...",
"upgradeAvailable": "{{browser}}의 최신 버전({{version}})을 사용할 수 있습니다."
},
"chromiumLabel": "크로미움",
"chromiumSubtitle": "Wayfern 기반",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "이 프로필을 비밀번호로 보호",
"description": "디스크에 저장된 프로필 데이터를 암호화합니다. 실행하려면 필요합니다."
}
},
"downloadingSubtitle": "다운로드 중…",
"browsersDownloading": "브라우저를 아직 다운로드하는 중입니다. 다운로드가 완료되면 프로필을 만들 수 있습니다."
},
"deleteDialog": {
"title": "프로필 삭제",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "비밀번호를 변경하기 전에 프로필을 중지하세요."
},
"fingerprint": {
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다."
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
"lockedTitle": "핑거프린트는 Pro 기능입니다",
"lockedDescription": "프로필의 핑거프린트를 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.",
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요."
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.",
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
},
"rail": {
"profiles": "프로필",
@@ -1867,7 +1876,8 @@
"plan": "플랜",
"status": "상태",
"teamRole": "팀 역할",
"period": "결제 기간"
"period": "결제 기간",
"device": "기기"
},
"tabs": {
"account": "계정",
@@ -1881,7 +1891,10 @@
"statusUnknown": "테스트 안 됨",
"testConnection": "연결 테스트",
"disconnect": "연결 해제"
}
},
"deviceOrdinal": "{{count}}대 중 {{ordinal}}번째",
"automationPrimaryOnly": "브라우저 자동화는 기본 기기(기기 1)에서만 실행됩니다. 여기서 사용하려면 해당 기기에서 로그아웃하세요.",
"automationActiveHere": "이 기기에서 브라우저 자동화가 활성화되어 있습니다."
},
"shortcutsPage": {
"title": "키보드 단축키",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "첫 프로필 만들기",
"content": "여기를 클릭하여 첫 프로필을 만드세요. 브라우저로는 Wayfern을 선택하세요. 핑거프린트 방지가 적용된 권장 Chromium입니다."
},
"dnsBlocking": {
"title": "DNS 차단",
"content": "이 드롭다운으로 프로필의 DNS 차단 목록 수준을 설정하세요. 광고, 추적기, 멀웨어를 네트워크 수준에서 차단합니다. 수준이 높을수록 더 많이 차단합니다."
}
},
"buttons": {
"skip": "건너뛰기",
"back": "뒤로",
"next": "다음",
"finish": "완료"
},
"thankYou": {
"title": "Donut Browser를 선택해 주셔서 감사합니다",
"body": "각 ID를 분리하고 어떤 데이터도 기기 밖으로 내보내지 않으면서 더 프라이빗한 브라우징에 도움이 되길 바랍니다. 즐겁게 사용하세요.",
"cta": "브라우징 시작"
}
},
"welcome": {
"title": "Donut Browser에 오신 것을 환영합니다",
"tagline": "여러 ID를 한 번에 관리할 수 있는 오픈 소스 안티디텍트 브라우저입니다.",
"skip": "건너뛰기",
"next": "다음",
"permissions": {
"title": "마이크 및 카메라 허용",
"desc": "마이크나 카메라가 필요한 사이트가 브라우저 프로필 안에서 작동하도록 액세스를 허용하세요. macOS는 한 번만 묻고, 각 사이트는 여전히 개별적으로 요청합니다.",
"skip": "나중에",
"grant": "액세스 허용",
"requesting": "요청 중…"
},
"ready": {
"title": "준비 중입니다",
"descDownloading": "첫 브라우저(Wayfern)를 다운로드하고 있습니다. 이 일회성 설정은 백그라운드에서 실행됩니다 — 잠시만 기다려 주세요.",
"descReady": "브라우저가 준비되었습니다. 첫 프로필을 만들어 봅시다.",
"cta": "첫 프로필 만들기",
"downloading": "다운로드 중…",
"extracting": "압축 푸는 중…",
"stats": "{{total}} 중 {{downloaded}}",
"speed": "{{speed}}/초",
"timeLeft": "{{time}} 남음",
"descExtracting": "브라우저를 추출하는 중입니다. 이 일회성 설정은 백그라운드에서 실행됩니다. 잠시만 기다려 주세요.",
"almostFinished": "거의 완료되었습니다…",
"errorTitle": "설정 실패",
"errorDownload": "{{browser}}을(를) 다운로드하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
"errorExtraction": "{{browser}}을(를) 추출하지 못했습니다. 다시 시도하세요.",
"errorGeneric": "{{browser}} 설정 중 문제가 발생했습니다. 다시 시도하세요.",
"retry": "다시 시도"
},
"features": {
"title": "기능",
"items": {
"setDefault": "기본 브라우저로 설정",
"proxy": "프록시 지원 (HTTP/SOCKS5)",
"vpn": "VPN 지원 (WireGuard)",
"profiles": "무제한 로컬 프로필",
"api": "프로필 관리 API 및 MCP",
"openSource": "오픈 소스",
"groups": "프로필 그룹",
"cookies": "쿠키 가져오기 및 내보내기"
}
},
"license": {
"title": "라이선스",
"body": "Donut Browser는 오픈 소스이며 무료로 사용할 수 있습니다.",
"agree": "이해했습니다",
"personalTitle": "개인 사용",
"personalDesc": "영구적으로 무료입니다.",
"commercialTitle": "상업적 사용",
"trialBadge": "2주 무료",
"commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "Baixando versão do {{browser}} ({{version}})...",
"latestNeedsDownload": "A versão mais recente ({{version}}) precisa ser baixada",
"latestAvailable": "A versão mais recente ({{version}}) está disponível",
"latestDownloading": "Baixando versão ({{version}})..."
"latestDownloading": "Baixando versão ({{version}})...",
"upgradeAvailable": "Uma versão mais recente ({{version}}) do {{browser}} está disponível."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Desenvolvido com Wayfern",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "Proteger este perfil com senha",
"description": "Criptografa os dados do perfil em disco. Necessário para iniciar."
}
},
"downloadingSubtitle": "Baixando…",
"browsersDownloading": "Os navegadores ainda estão sendo baixados. A criação de perfis ficará disponível assim que um download terminar."
},
"deleteDialog": {
"title": "Excluir Perfil",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "Pare o perfil antes de alterar a senha."
},
"fingerprint": {
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern."
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.",
"lockedTitle": "A impressão digital é um recurso Pro",
"lockedDescription": "Visualizar e editar a impressão digital de um perfil requer um plano pago ativo. Faça upgrade para desbloquear a proteção contra fingerprint."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"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.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
"fingerprintRequiresPro": "A proteção contra fingerprint requer um plano pago ativo.",
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
},
"rail": {
"profiles": "Perfis",
@@ -1867,7 +1876,8 @@
"plan": "Plano",
"status": "Status",
"teamRole": "Função na equipe",
"period": "Período"
"period": "Período",
"device": "Dispositivo"
},
"tabs": {
"account": "Conta",
@@ -1881,7 +1891,10 @@
"statusUnknown": "Não testado",
"testConnection": "Testar conexão",
"disconnect": "Desconectar"
}
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "A automação do navegador funciona apenas no seu dispositivo principal (Dispositivo 1). Saia da conta nele para usá-la aqui.",
"automationActiveHere": "A automação do navegador está ativa neste dispositivo."
},
"shortcutsPage": {
"title": "Atalhos de teclado",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "O suporte ao navegador terminará em breve",
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Crie seu primeiro perfil",
"content": "Clique aqui para criar seu primeiro perfil. Escolha o Wayfern como navegador: o Chromium recomendado e protegido contra fingerprint."
},
"dnsBlocking": {
"title": "Bloqueio de DNS",
"content": "Use este menu para definir o nível da lista de bloqueio DNS do perfil — ele bloqueia anúncios, rastreadores e malware no nível da rede. Níveis mais altos bloqueiam mais."
}
},
"buttons": {
"skip": "Pular",
"back": "Voltar",
"next": "Próximo",
"finish": "Concluir"
},
"thankYou": {
"title": "Obrigado por escolher o Donut Browser",
"body": "Com sorte, deixará sua navegação mais privada: cada identidade separada e nada saindo do seu dispositivo. Boa navegação!",
"cta": "Começar a navegar"
}
},
"welcome": {
"title": "Boas-vindas ao Donut Browser",
"tagline": "Um navegador anti-detecção de código aberto para gerenciar várias identidades ao mesmo tempo.",
"skip": "Pular",
"next": "Próximo",
"permissions": {
"title": "Permitir microfone e câmera",
"desc": "Conceda acesso para que sites que precisam de microfone ou câmera funcionem nos seus perfis de navegador. O macOS pergunta uma vez; cada site ainda pedirá individualmente.",
"skip": "Agora não",
"grant": "Permitir acesso",
"requesting": "Solicitando…"
},
"ready": {
"title": "Preparando tudo",
"descDownloading": "Baixando seu primeiro navegador (Wayfern). Esta configuração única é executada em segundo plano — aguarde um momento.",
"descReady": "Seu navegador está pronto. Vamos criar seu primeiro perfil.",
"cta": "Criar meu primeiro perfil",
"downloading": "Baixando…",
"extracting": "Extraindo…",
"stats": "{{downloaded}} de {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restante",
"descExtracting": "Extraindo seu navegador. Esta configuração única é executada em segundo plano — aguarde.",
"almostFinished": "Quase terminando…",
"errorTitle": "Falha na configuração",
"errorDownload": "Não foi possível baixar o {{browser}}. Verifique sua conexão e tente novamente.",
"errorExtraction": "Não foi possível extrair o {{browser}}. Tente novamente.",
"errorGeneric": "Algo deu errado ao configurar o {{browser}}. Tente novamente.",
"retry": "Tentar novamente"
},
"features": {
"title": "Recursos",
"items": {
"setDefault": "Definir como navegador padrão",
"proxy": "Suporte a proxy (HTTP/SOCKS5)",
"vpn": "Suporte a VPN (WireGuard)",
"profiles": "Perfis locais ilimitados",
"api": "API de gerenciamento de perfis e MCP",
"openSource": "Código aberto",
"groups": "Grupos de perfis",
"cookies": "Importar e exportar cookies"
}
},
"license": {
"title": "Licenciamento",
"body": "O Donut Browser é de código aberto e gratuito.",
"agree": "Entendi",
"personalTitle": "Uso pessoal",
"personalDesc": "Gratuito para sempre.",
"commercialTitle": "Uso comercial",
"trialBadge": "2 semanas grátis",
"commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "Загрузка версии {{browser}} ({{version}})...",
"latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать",
"latestAvailable": "Последняя версия ({{version}}) доступна",
"latestDownloading": "Загрузка версии ({{version}})..."
"latestDownloading": "Загрузка версии ({{version}})...",
"upgradeAvailable": "Доступна новая версия ({{version}}) {{browser}}."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "На базе Wayfern",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "Защитить этот профиль паролем",
"description": "Шифрует данные профиля на диске. Требуется для запуска."
}
},
"downloadingSubtitle": "Загрузка…",
"browsersDownloading": "Браузеры ещё загружаются. Создание профилей станет доступно после завершения загрузки."
},
"deleteDialog": {
"title": "Удалить профиль",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "Остановите профиль перед сменой пароля."
},
"fingerprint": {
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern."
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
"lockedTitle": "Отпечаток — функция Pro",
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков."
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.",
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
},
"rail": {
"profiles": "Профили",
@@ -1867,7 +1876,8 @@
"plan": "Тариф",
"status": "Статус",
"teamRole": "Роль в команде",
"period": "Период"
"period": "Период",
"device": "Устройство"
},
"tabs": {
"account": "Аккаунт",
@@ -1881,7 +1891,10 @@
"statusUnknown": "Не проверено",
"testConnection": "Проверить соединение",
"disconnect": "Отключить"
}
},
"deviceOrdinal": "{{ordinal}} из {{count}}",
"automationPrimaryOnly": "Автоматизация браузера работает только на вашем основном устройстве (Устройство 1). Выйдите из аккаунта на нём, чтобы использовать её здесь.",
"automationActiveHere": "Автоматизация браузера активна на этом устройстве."
},
"shortcutsPage": {
"title": "Сочетания клавиш",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "Поддержка браузера скоро завершится",
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Создайте первый профиль",
"content": "Нажмите здесь, чтобы создать первый профиль. Выберите Wayfern в качестве браузера — рекомендуемый Chromium с защитой от цифровых отпечатков."
},
"dnsBlocking": {
"title": "DNS-блокировка",
"content": "Используйте этот список, чтобы задать уровень DNS-блокировки для профиля — он блокирует рекламу, трекеры и вредоносное ПО на сетевом уровне. Чем выше уровень, тем больше блокируется."
}
},
"buttons": {
"skip": "Пропустить",
"back": "Назад",
"next": "Далее",
"finish": "Готово"
},
"thankYou": {
"title": "Спасибо, что выбрали Donut Browser",
"body": "Пусть он сделает ваш просмотр интернета более приватным — каждая личность отдельно, и ничего не покидает ваше устройство. Приятного использования!",
"cta": "Начать работу"
}
},
"welcome": {
"title": "Добро пожаловать в Donut Browser",
"tagline": "Браузер с защитой от обнаружения с открытым исходным кодом для управления множеством личностей одновременно.",
"skip": "Пропустить",
"next": "Далее",
"permissions": {
"title": "Разрешить микрофон и камеру",
"desc": "Предоставьте доступ, чтобы сайты, которым нужны микрофон или камера, работали в ваших профилях браузера. macOS спросит один раз; каждый сайт всё равно запросит отдельно.",
"skip": "Не сейчас",
"grant": "Разрешить доступ",
"requesting": "Запрос…"
},
"ready": {
"title": "Настраиваем",
"descDownloading": "Загружаем ваш первый браузер (Wayfern). Эта однократная настройка выполняется в фоне — подождите немного.",
"descReady": "Браузер готов. Давайте создадим ваш первый профиль.",
"cta": "Создать первый профиль",
"downloading": "Загрузка…",
"extracting": "Распаковка…",
"stats": "{{downloaded}} из {{total}}",
"speed": "{{speed}}/с",
"timeLeft": "осталось {{time}}",
"descExtracting": "Распаковка браузера. Эта однократная настройка выполняется в фоновом режиме — подождите.",
"almostFinished": "Почти готово…",
"errorTitle": "Ошибка настройки",
"errorDownload": "Не удалось загрузить {{browser}}. Проверьте подключение и повторите попытку.",
"errorExtraction": "Не удалось распаковать {{browser}}. Повторите попытку.",
"errorGeneric": "Что-то пошло не так при настройке {{browser}}. Повторите попытку.",
"retry": "Повторить"
},
"features": {
"title": "Возможности",
"items": {
"setDefault": "Сделать браузером по умолчанию",
"proxy": "Поддержка прокси (HTTP/SOCKS5)",
"vpn": "Поддержка VPN (WireGuard)",
"profiles": "Неограниченное число локальных профилей",
"api": "API управления профилями и MCP",
"openSource": "Открытый исходный код",
"groups": "Группы профилей",
"cookies": "Импорт и экспорт cookie"
}
},
"license": {
"title": "Лицензия",
"body": "Donut Browser имеет открытый исходный код и бесплатен в использовании.",
"agree": "Понятно",
"personalTitle": "Личное использование",
"personalDesc": "Бесплатно навсегда.",
"commercialTitle": "Коммерческое использование",
"trialBadge": "2 недели бесплатно",
"commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект."
}
}
}
+96 -6
View File
@@ -344,7 +344,8 @@
"downloading": "正在下载 {{browser}} 版本 ({{version}})...",
"latestNeedsDownload": "最新版本 ({{version}}) 需要下载",
"latestAvailable": "最新版本 ({{version}}) 可用",
"latestDownloading": "正在下载版本 ({{version}})..."
"latestDownloading": "正在下载版本 ({{version}})...",
"upgradeAvailable": "{{browser}} 有新版本({{version}})可用。"
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "由 Wayfern 驱动",
@@ -355,7 +356,9 @@
"passwordProtect": {
"label": "为此配置文件设置密码保护",
"description": "加密磁盘上的配置文件数据。启动时需要密码。"
}
},
"downloadingSubtitle": "正在下载…",
"browsersDownloading": "浏览器仍在下载中。下载完成后即可创建配置文件。"
},
"deleteDialog": {
"title": "删除配置文件",
@@ -1192,7 +1195,9 @@
"cannotWhileRunning": "更改密码前请先停止此配置文件。"
},
"fingerprint": {
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。"
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
"lockedTitle": "指纹是 Pro 功能",
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。"
}
},
"extensions": {
@@ -1801,7 +1806,11 @@
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。",
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
},
"rail": {
"profiles": "配置文件",
@@ -1867,7 +1876,8 @@
"plan": "套餐",
"status": "状态",
"teamRole": "团队角色",
"period": "计费周期"
"period": "计费周期",
"device": "设备"
},
"tabs": {
"account": "账户",
@@ -1881,7 +1891,10 @@
"statusUnknown": "未测试",
"testConnection": "测试连接",
"disconnect": "断开连接"
}
},
"deviceOrdinal": "第 {{ordinal}} 台,共 {{count}} 台",
"automationPrimaryOnly": "浏览器自动化仅在您的主设备(设备 1)上运行。请在该设备上退出登录,才能在此设备上使用。",
"automationActiveHere": "浏览器自动化已在此设备上启用。"
},
"shortcutsPage": {
"title": "键盘快捷键",
@@ -1927,5 +1940,82 @@
"browserSupport": {
"endingSoonTitle": "浏览器支持即将结束",
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
},
"onboarding": {
"steps": {
"createProfile": {
"title": "创建你的第一个配置文件",
"content": "点击这里创建您的第一个配置文件。浏览器请选择 Wayfern——推荐的、具备指纹保护的 Chromium。"
},
"dnsBlocking": {
"title": "DNS 拦截",
"content": "使用此下拉菜单为配置文件设置 DNS 拦截级别——在网络层面拦截广告、跟踪器和恶意软件。级别越高,拦截越多。"
}
},
"buttons": {
"skip": "跳过",
"back": "返回",
"next": "下一步",
"finish": "完成"
},
"thankYou": {
"title": "感谢您选择 Donut Browser",
"body": "希望它能让您的网络浏览更加私密——每个身份彼此独立,数据不会离开您的设备。祝您使用愉快!",
"cta": "开始浏览"
}
},
"welcome": {
"title": "欢迎使用 Donut Browser",
"tagline": "一款开源的反检测浏览器,可同时管理多个身份。",
"skip": "跳过",
"next": "下一步",
"permissions": {
"title": "允许使用麦克风和摄像头",
"desc": "授予权限,让需要麦克风或摄像头的网站在你的浏览器配置文件中正常工作。macOS 只询问一次;每个网站仍会单独请求。",
"skip": "暂不",
"grant": "允许访问",
"requesting": "正在请求…"
},
"ready": {
"title": "正在准备",
"descDownloading": "正在下载你的第一个浏览器(Wayfern)。此一次性设置在后台运行——请稍候。",
"descReady": "你的浏览器已就绪。来创建你的第一个配置文件吧。",
"cta": "创建我的第一个配置文件",
"downloading": "正在下载…",
"extracting": "正在解压…",
"stats": "{{downloaded}} / {{total}}",
"speed": "{{speed}}/秒",
"timeLeft": "剩余 {{time}}",
"descExtracting": "正在解压浏览器。此一次性设置在后台运行,请稍候。",
"almostFinished": "即将完成…",
"errorTitle": "设置失败",
"errorDownload": "无法下载 {{browser}}。请检查网络连接后重试。",
"errorExtraction": "无法解压 {{browser}}。请重试。",
"errorGeneric": "设置 {{browser}} 时出现问题。请重试。",
"retry": "重试"
},
"features": {
"title": "功能",
"items": {
"setDefault": "设为默认浏览器",
"proxy": "代理支持(HTTP/SOCKS5",
"vpn": "VPN 支持(WireGuard",
"profiles": "无限本地配置文件",
"api": "配置文件管理 API 和 MCP",
"openSource": "开源",
"groups": "配置文件分组",
"cookies": "Cookie 导入与导出"
}
},
"license": {
"title": "许可",
"body": "Donut Browser 是开源软件,可免费使用。",
"agree": "我知道了",
"personalTitle": "个人使用",
"personalDesc": "永久免费。",
"commercialTitle": "商业使用",
"trialBadge": "2 周免费",
"commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。"
}
}
}
+12
View File
@@ -28,6 +28,10 @@ export type BackendErrorCode =
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
| "SYNC_LOCKED_BY_PROFILE"
| "SYNC_NOT_CONFIGURED"
| "FINGERPRINT_REQUIRES_PRO"
| "PROXY_NOT_WORKING"
| "PROXY_PAYMENT_REQUIRED"
| "VPN_NOT_WORKING"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -120,6 +124,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.syncLockedByProfile");
case "SYNC_NOT_CONFIGURED":
return t("backendErrors.syncNotConfigured");
case "FINGERPRINT_REQUIRES_PRO":
return t("backendErrors.fingerprintRequiresPro");
case "PROXY_NOT_WORKING":
return t("backendErrors.proxyNotWorking");
case "PROXY_PAYMENT_REQUIRED":
return t("backendErrors.proxyPaymentRequired");
case "VPN_NOT_WORKING":
return t("backendErrors.vpnNotWorking");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+18
View File
@@ -0,0 +1,18 @@
// Lightweight module-level flag for whether the first-run onboarding is in
// progress. The welcome dialog shows its own browser-setup progress, so the
// global browser-download toasts (from use-browser-download) are suppressed
// while this is true to avoid a competing toast during onboarding.
let active = false;
export function setOnboardingActive(value: boolean): void {
active = value;
}
export function isOnboardingActive(): boolean {
return active;
}
// Dispatched on `window` when the product tour reaches its end and the user
// clicks "Finish" (not when they skip early). The page listens for it to show
// the celebratory thank-you dialog.
export const ONBOARDING_TOUR_FINISHED_EVENT = "donut:onboarding-tour-finished";
+9 -3
View File
@@ -10,6 +10,9 @@ interface BaseToastProps {
duration?: number;
action?: ExternalToast["action"];
onCancel?: () => void;
// When false, the toast cannot be dismissed by the user (no swipe; combine
// with duration: Infinity and no onCancel to make it fully non-closable).
dismissible?: boolean;
}
interface LoadingToastProps extends BaseToastProps {
@@ -110,12 +113,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
@@ -123,12 +127,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
@@ -136,12 +141,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
+6
View File
@@ -89,6 +89,12 @@ export interface CloudUser {
teamId?: string;
teamName?: string;
teamRole?: string;
// This device's position among the user's active devices (oldest = 1).
// Ordinal 1 / isPrimaryDevice === true is the only device that can run
// browser automation. Optional: older backends omit them.
deviceOrdinal?: number | null;
deviceCount?: number | null;
isPrimaryDevice?: boolean | null;
}
export interface ProfileLockInfo {