From 3bc9127c063dc03c2ede3537704f6ceeb678d4fa Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 28 May 2026 23:01:45 +0400 Subject: [PATCH 01/11] refactor: unify browser launch logic --- AGENTS.md | 10 + src-tauri/src/api_server.rs | 6 +- src-tauri/src/browser_runner.rs | 326 +++++------------------------- src-tauri/src/camoufox_manager.rs | 10 +- src-tauri/src/mcp_server.rs | 30 +-- 5 files changed, 89 insertions(+), 293 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89a1686..adb4354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,16 @@ donutbrowser/ - The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter: `pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed. +## Logs (when debugging a running app) + +Three log surfaces, in order of usefulness: + +- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead. +- **donut-proxy worker** — `$TMPDIR/donut-proxy-.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker ` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires. +- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem. + +Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir. + ## Code Quality - Don't leave comments that don't add value diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 8e908d4..11990ea 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1750,13 +1750,15 @@ async fn run_profile( port }; - // Use the same launch method as the main app, but with remote debugging enabled - match crate::browser_runner::launch_browser_profile_with_debugging( + // Use the same launch path as the main app, but force a fresh instance with + // remote debugging enabled so the returned port is the one the browser binds. + match crate::browser_runner::launch_browser_profile_impl( state.app_handle.clone(), profile.clone(), url, Some(remote_debugging_port), headless, + true, ) .await { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index bc67069..f256a62 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -7,78 +7,11 @@ use crate::platform_browser; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; -use chrono::{Datelike, TimeZone, Utc}; use serde::Serialize; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::System; -/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a -/// low-traffic window for the average user; everyone shares the same UTC -/// instant so the value here doesn't track any one user's local schedule. -const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4; - -/// File name of the per-profile marker recording the last fingerprint -/// refresh time. Lives at `//.last-fp-refresh` -/// and is excluded from cloud sync (see `sync::manifest`) so each device -/// runs its own refresh schedule. -const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh"; - -/// Most recent rollover instant on or before `now` — used as a staleness -/// threshold for Wayfern fingerprints. Anything generated before this -/// timestamp is considered stale and gets regenerated on next launch. -fn most_recent_rollover_epoch() -> u64 { - let now = Utc::now(); - let today_threshold = Utc - .with_ymd_and_hms( - now.year(), - now.month(), - now.day(), - FINGERPRINT_ROLLOVER_HOUR_UTC, - 0, - 0, - ) - .single() - .unwrap_or(now); - let threshold = if now >= today_threshold { - today_threshold - } else { - today_threshold - chrono::Duration::days(1) - }; - threshold.timestamp().max(0) as u64 -} - -fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf { - profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE) -} - -/// Read the epoch-seconds timestamp stored in the per-profile refresh marker. -/// Returns `None` if the file doesn't exist or its content can't be parsed — -/// both signal "needs a refresh" to the caller. -fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option { - let path = last_fp_refresh_path(profile_id, profiles_dir); - let content = std::fs::read_to_string(&path).ok()?; - content.trim().parse::().ok() -} - -/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for -/// this profile. Failure is logged but never propagated — a missing marker -/// only costs an extra regen on the next launch, never blocks one. -fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) { - let path = last_fp_refresh_path(profile_id, profiles_dir); - if let Some(parent) = path.parent() { - if !parent.exists() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}"); - return; - } - } - } - if let Err(e) = std::fs::write(&path, ts.to_string()) { - log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}"); - } -} - pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -448,6 +381,7 @@ impl BrowserRunner { camoufox_config, url, override_profile_path, + remote_debugging_port, headless, ) .await @@ -612,32 +546,12 @@ impl BrowserRunner { wayfern_config.proxy ); - // Decide whether to (re)generate the Wayfern fingerprint for this - // launch. Two triggers: - // - // 1. `randomize_fingerprint_on_launch = true` — explicit per-launch - // randomization the user opted into. - // 2. The fingerprint hasn't been refreshed since the most recent - // rollover instant. We check the per-profile marker file first - // (`.last-fp-refresh`); if it's absent we fall back to - // `profile.created_at` so brand-new profiles don't immediately - // regenerate the fingerprint they were just created with. - // Profiles with neither (truly legacy) are treated as ancient - // and refresh on next launch — once. + // Check if we need to generate a new fingerprint on every launch let mut updated_profile = profile.clone(); - let stale_threshold = most_recent_rollover_epoch(); - let profile_id_str = profile.id.to_string(); - let profiles_dir_for_marker = self.profile_manager.get_profiles_dir(); - let effective_last_refresh = - read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at); - let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold); - let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true); - if randomize_every_launch || is_stale_profile { + if wayfern_config.randomize_fingerprint_on_launch == Some(true) { log::info!( - "Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})", - profile.name, - randomize_every_launch, - is_stale_profile + "Generating random fingerprint for Wayfern profile: {}", + profile.name ); // Create a config copy without the existing fingerprint to force generation of a new one @@ -659,24 +573,12 @@ impl BrowserRunner { // Update the config with the new fingerprint for launching wayfern_config.fingerprint = Some(new_fingerprint.clone()); - // Write the marker so the next launch within the same rollover - // window skips this branch. The marker is excluded from cloud - // sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each - // device's refresh schedule is independent. - let now_epoch = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(stale_threshold); - write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch); - // Save the updated fingerprint to the profile so it persists. let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default(); updated_wayfern_config.fingerprint = Some(new_fingerprint); - // Preserve the user's randomize-on-launch preference rather than - // forcing it on. The rollover path must not silently flip this - // flag for users who only opted into the scheduled refresh. - updated_wayfern_config.randomize_fingerprint_on_launch = - wayfern_config.randomize_fingerprint_on_launch; + // Preserve the randomize flag so it persists across launches + updated_wayfern_config.randomize_fingerprint_on_launch = Some(true); + // Preserve the OS setting so it's used for future fingerprint generation if wayfern_config.os.is_some() { updated_wayfern_config.os = wayfern_config.os.clone(); } @@ -935,57 +837,19 @@ impl BrowserRunner { remote_debugging_port: Option, headless: bool, ) -> Result> { - // Always start a local proxy for API launches - let upstream_proxy = self - .resolve_launch_proxy(profile) - .await - .map_err(|e| -> Box { e.into() })?; - - // Use a temporary PID (1) to start the proxy, we'll update it after browser launch - let temp_pid = 1u32; - let profile_id_str = profile.id.to_string(); - - // Start local proxy - if this fails, DO NOT launch browser - let blocklist_file = Self::resolve_blocklist_file(profile) - .await - .map_err(|e| -> Box { e.into() })?; - let internal_proxy = PROXY_MANAGER - .start_proxy( - app_handle.clone(), - upstream_proxy.as_ref(), - temp_pid, - Some(&profile_id_str), - profile.proxy_bypass_rules.clone(), - blocklist_file, - ) - .await - .map_err(|e| { - let error_msg = format!("Failed to start local proxy: {e}"); - log::error!("{}", error_msg); - error_msg - })?; - - let internal_proxy_settings = Some(internal_proxy.clone()); - - let result = self + // Camoufox and Wayfern start (and PID-reconcile) their own local proxy + // inside `launch_browser_internal`, so we hand it None here rather than + // staging a second, orphaned proxy worker. + self .launch_browser_internal( - app_handle.clone(), + app_handle, profile, url, - internal_proxy_settings.as_ref(), + None, remote_debugging_port, headless, ) - .await; - - // Update proxy with correct PID if launch succeeded - if let Ok(ref updated_profile) = result { - if let Some(actual_pid) = updated_profile.process_id { - let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid); - } - } - - result + .await } pub async fn launch_or_open_url( @@ -2395,6 +2259,17 @@ pub async fn launch_browser_profile( app_handle: tauri::AppHandle, profile: BrowserProfile, url: Option, +) -> Result { + launch_browser_profile_impl(app_handle, profile, url, None, false, false).await +} + +pub async fn launch_browser_profile_impl( + app_handle: tauri::AppHandle, + profile: BrowserProfile, + url: Option, + remote_debugging_port: Option, + headless: bool, + force_new: bool, ) -> Result { log::info!( "Launch request received for profile: {} (ID: {})", @@ -2424,9 +2299,6 @@ pub async fn launch_browser_profile( let browser_runner = BrowserRunner::instance(); - // Store the internal proxy settings for passing to launch_browser - let mut internal_proxy_settings: Option = None; - // Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state let profile_for_launch = match browser_runner .profile_manager @@ -2448,112 +2320,36 @@ pub async fn launch_browser_profile( profile_for_launch.id ); - // Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow) - // This ensures all traffic goes through the local proxy for monitoring and future features - if profile.browser != "camoufox" && profile.browser != "wayfern" { - // Determine upstream proxy if configured; otherwise use DIRECT (no upstream) - // Refresh cloud proxy credentials and inject profile-specific sid - let mut upstream_proxy = BrowserRunner::instance() - .resolve_launch_proxy(&profile_for_launch) - .await?; - - // If profile has a VPN instead of proxy, start VPN worker and use it as upstream - if upstream_proxy.is_none() { - if let Some(ref vpn_id) = profile_for_launch.vpn_id { - match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await { - Ok(vpn_worker) => { - if let Some(port) = vpn_worker.local_port { - upstream_proxy = Some(ProxySettings { - proxy_type: "socks5".to_string(), - host: "127.0.0.1".to_string(), - port, - username: None, - password: None, - }); - log::info!("VPN worker started for profile on port {}", port); - } - } - Err(e) => { - return Err(format!("Failed to start VPN worker: {e}")); - } - } - } - } - - // Use a temporary PID (1) to start the proxy, we'll update it after browser launch - let temp_pid = 1u32; - let profile_id_str = profile.id.to_string(); - - // Always start a local proxy, even if there's no upstream proxy - // This allows for traffic monitoring and future features - let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?; - match PROXY_MANAGER - .start_proxy( - app_handle.clone(), - upstream_proxy.as_ref(), - temp_pid, - Some(&profile_id_str), - profile_for_launch.proxy_bypass_rules.clone(), - blocklist_file, - ) - .await - { - Ok(internal_proxy) => { - // Use internal proxy for subsequent launch - internal_proxy_settings = Some(internal_proxy.clone()); - - // For Firefox-based browsers, always apply PAC/user.js to point to the local proxy - if matches!( - profile_for_launch.browser.as_str(), - "firefox" | "firefox-developer" | "zen" - ) { - let profiles_dir = browser_runner.profile_manager.get_profiles_dir(); - let profile_path = profiles_dir - .join(profile_for_launch.id.to_string()) - .join("profile"); - - // Provide a dummy upstream (ignored when internal proxy is provided) - let dummy_upstream = ProxySettings { - proxy_type: "http".to_string(), - host: "127.0.0.1".to_string(), - port: internal_proxy.port, - username: None, - password: None, - }; - - browser_runner - .profile_manager - .apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy)) - .map_err(|e| format!("Failed to update profile proxy: {e}"))?; - } - - log::info!( - "Local proxy prepared for profile: {} on port: {} (upstream: {})", - profile_for_launch.name, - internal_proxy.port, - upstream_proxy - .as_ref() - .map(|p| format!("{}:{}", p.host, p.port)) - .unwrap_or_else(|| "DIRECT".to_string()) - ); - } - Err(e) => { - let error_msg = format!("Failed to start local proxy: {e}"); - log::error!("{}", error_msg); - // DO NOT launch browser if proxy startup fails - all browsers must use local proxy - return Err(error_msg); - } - } - } - log::info!( "Starting browser launch for profile: {} (ID: {})", profile_for_launch.name, profile_for_launch.id ); - // Launch browser or open URL in existing instance - let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| { + // Launch browser or open URL in existing instance. Camoufox and Wayfern + // start their own local proxies inside `launch_browser_internal`; any + // other browser type is rejected there (we only support those for import, + // not launch), so no proxy needs to be staged here. + // + // `force_new` callers (API/MCP) always start a fresh instance with the + // requested debug port and headless mode, bypassing the "open URL in the + // existing window" path which would otherwise ignore both. + let launch_result = if force_new { + browser_runner + .launch_browser_with_debugging( + app_handle.clone(), + &profile_for_launch, + url, + remote_debugging_port, + headless, + ) + .await + } else { + browser_runner + .launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None) + .await + }; + let updated_profile = launch_result.map_err(|e| { log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e); // Emit a failure event to clear loading states in the frontend @@ -2710,28 +2506,6 @@ pub async fn kill_browser_profile( } } -pub async fn launch_browser_profile_with_debugging( - app_handle: tauri::AppHandle, - profile: BrowserProfile, - url: Option, - remote_debugging_port: Option, - headless: bool, -) -> Result { - if profile.is_cross_os() { - return Err(format!( - "Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system", - profile.name, - profile.host_os.as_deref().unwrap_or("another OS"), - )); - } - - let browser_runner = BrowserRunner::instance(); - browser_runner - .launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless) - .await - .map_err(|e| format!("Failed to launch browser with debugging: {e}")) -} - #[tauri::command] pub async fn open_url_with_profile( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index f8c27b1..6b388a3 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -200,6 +200,7 @@ impl CamoufoxManager { } /// Launch Camoufox browser by directly spawning the process + #[allow(clippy::too_many_arguments)] pub async fn launch_camoufox( &self, _app_handle: &AppHandle, @@ -207,6 +208,7 @@ impl CamoufoxManager { profile_path: &str, config: &CamoufoxConfig, url: Option<&str>, + remote_debugging_port: Option, headless: bool, ) -> Result> { let custom_config = if let Some(existing_fingerprint) = &config.fingerprint { @@ -249,7 +251,10 @@ impl CamoufoxManager { .to_string(), ]; - let cdp_port = Self::find_free_port().await?; + let cdp_port = match remote_debugging_port { + Some(p) => p, + None => Self::find_free_port().await?, + }; args.push(format!("--remote-debugging-port={cdp_port}")); // Add URL if provided @@ -666,6 +671,7 @@ impl CamoufoxManager { } impl CamoufoxManager { + #[allow(clippy::too_many_arguments)] pub async fn launch_camoufox_profile( &self, app_handle: AppHandle, @@ -673,6 +679,7 @@ impl CamoufoxManager { config: CamoufoxConfig, url: Option, override_profile_path: Option, + remote_debugging_port: Option, headless: bool, ) -> Result { // Get profile path @@ -817,6 +824,7 @@ impl CamoufoxManager { &profile_path_str, &config, url.as_deref(), + remote_debugging_port, headless, ) .await diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 69caa14..d04187f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1832,7 +1832,7 @@ impl McpServer { })?; let url = arguments.get("url").and_then(|v| v.as_str()); - let _headless = arguments + let headless = arguments .get("headless") .and_then(|v| v.as_bool()) .unwrap_or(false); @@ -1876,19 +1876,21 @@ impl McpServer { message: "MCP server not properly initialized".to_string(), })?; - // Launch the browser - crate::browser_runner::BrowserRunner::instance() - .launch_browser( - app_handle.clone(), - profile, - url.map(|s| s.to_string()), - None, - ) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to launch browser: {e}"), - })?; + // Launch a fresh instance, honoring the requested headless mode. The CDP + // port is self-allocated and discovered later via get_cdp_port_for_profile. + crate::browser_runner::launch_browser_profile_impl( + app_handle.clone(), + profile.clone(), + url.map(|s| s.to_string()), + None, + headless, + true, + ) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to launch browser: {e}"), + })?; Ok(serde_json::json!({ "content": [{ From 4436b69bf907c92b6d4232681255e3f6df71b781 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 29 May 2026 00:08:47 +0400 Subject: [PATCH 02/11] chore: ignore CHANGELOG.md --- _typos.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/_typos.toml b/_typos.toml index dafceb5..4e1a00f 100644 --- a/_typos.toml +++ b/_typos.toml @@ -4,6 +4,9 @@ extend-exclude = [ "src-tauri/src/camoufox/data/*.xml", "src/i18n/locales/*.json", "src-tauri/build.rs", + # Auto-generated from commit subjects by release.yml; typos here originate + # in commit messages, which are immutable, so don't spell-check it. + "CHANGELOG.md", ] [default.extend-words] From 17e33aa53f158c8326cb213372de9ff0f15d8b72 Mon Sep 17 00:00:00 2001 From: huy97 <30153437+huy97@users.noreply.github.com> Date: Fri, 29 May 2026 06:11:50 +0400 Subject: [PATCH 03/11] chore: add huy97 as the contributor From ecafb5e1c0de3d1f53a0a8d0296cbbb7736eb187 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 29 May 2026 06:31:42 +0400 Subject: [PATCH 04/11] refactor: cleanup --- .github/workflows/lint-rs.yml | 4 - .github/workflows/release.yml | 4 - .github/workflows/rolling-release.yml | 4 - _typos.toml | 1 - src-tauri/Cargo.lock | 45 +- src-tauri/Cargo.toml | 8 +- src-tauri/build.rs | 16 +- src-tauri/copy-proxy-binary.mjs | 1 - src-tauri/copy-proxy-binary.sh | 3 - src-tauri/src/api_server.rs | 8 - src-tauri/src/bin/donut_daemon.rs | 498 ------------------ src-tauri/src/daemon/autostart.rs | 351 ------------ src-tauri/src/daemon/mod.rs | 3 - src-tauri/src/daemon/services.rs | 51 -- src-tauri/src/daemon/tray.rs | 204 ------- src-tauri/src/daemon_client.rs | 152 ------ src-tauri/src/daemon_spawn.rs | 360 ------------- src-tauri/src/daemon_ws.rs | 134 ----- src-tauri/src/events/mod.rs | 75 +-- src-tauri/src/lib.rs | 63 ++- src-tauri/src/proxy_runner.rs | 1 - src-tauri/src/settings_manager.rs | 61 --- src-tauri/tauri.conf.json | 2 +- src/app/page.tsx | 43 +- src/components/close-confirm-dialog.tsx | 20 +- src/components/launch-on-login-dialog.tsx | 106 ---- .../shared-camoufox-config-form.tsx | 112 ++-- src/components/wayfern-config-form.tsx | 124 +++-- src/hooks/use-app-update-notifications.tsx | 2 +- src/hooks/use-browser-download.ts | 4 +- src/hooks/use-update-notifications.tsx | 48 +- src/hooks/use-version-updater.ts | 8 +- src/i18n/locales/en.json | 39 +- src/i18n/locales/es.json | 39 +- src/i18n/locales/fr.json | 39 +- src/i18n/locales/ja.json | 39 +- src/i18n/locales/ko.json | 39 +- src/i18n/locales/pt.json | 39 +- src/i18n/locales/ru.json | 39 +- src/i18n/locales/zh.json | 39 +- 40 files changed, 484 insertions(+), 2344 deletions(-) delete mode 100644 src-tauri/src/bin/donut_daemon.rs delete mode 100644 src-tauri/src/daemon/autostart.rs delete mode 100644 src-tauri/src/daemon/mod.rs delete mode 100644 src-tauri/src/daemon/services.rs delete mode 100644 src-tauri/src/daemon/tray.rs delete mode 100644 src-tauri/src/daemon_client.rs delete mode 100644 src-tauri/src/daemon_spawn.rs delete mode 100644 src-tauri/src/daemon_ws.rs delete mode 100644 src/components/launch-on-login-dialog.tsx diff --git a/.github/workflows/lint-rs.yml b/.github/workflows/lint-rs.yml index e9a2859..16b535e 100644 --- a/.github/workflows/lint-rs.yml +++ b/.github/workflows/lint-rs.yml @@ -88,7 +88,6 @@ jobs: working-directory: ./src-tauri run: | cargo build --bin donut-proxy --release - cargo build --bin donut-daemon --release - name: Copy sidecar binaries to Tauri binaries shell: bash @@ -97,12 +96,9 @@ jobs: HOST_TARGET="${{ steps.host_target.outputs.target }}" if [[ "$HOST_TARGET" == *"windows"* ]]; then cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe - cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe else cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET} - cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET} chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET} - chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET} fi - name: Run rustfmt check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0490748..73258c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,6 @@ jobs: working-directory: ./src-tauri run: | cargo build --bin donut-proxy --target ${{ matrix.target }} --release - cargo build --bin donut-daemon --target ${{ matrix.target }} --release - name: Copy sidecar binaries to Tauri binaries shell: bash @@ -170,12 +169,9 @@ jobs: mkdir -p src-tauri/binaries if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe - cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe else cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }} - cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }} chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }} - chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }} fi - name: Import Apple certificate diff --git a/.github/workflows/rolling-release.yml b/.github/workflows/rolling-release.yml index 45bb3f7..15fa530 100644 --- a/.github/workflows/rolling-release.yml +++ b/.github/workflows/rolling-release.yml @@ -161,7 +161,6 @@ jobs: working-directory: ./src-tauri run: | cargo build --bin donut-proxy --target ${{ matrix.target }} --release - cargo build --bin donut-daemon --target ${{ matrix.target }} --release - name: Copy sidecar binaries to Tauri binaries shell: bash @@ -169,12 +168,9 @@ jobs: mkdir -p src-tauri/binaries if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe - cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe else cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }} - cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }} chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }} - chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }} fi - name: Import Apple certificate diff --git a/_typos.toml b/_typos.toml index 4e1a00f..ec93ca5 100644 --- a/_typos.toml +++ b/_typos.toml @@ -3,7 +3,6 @@ extend-exclude = [ "src-tauri/src/camoufox/data/*.json", "src-tauri/src/camoufox/data/*.xml", "src/i18n/locales/*.json", - "src-tauri/build.rs", # Auto-generated from commit subjects by release.yml; typos here originate # in commit messages, which are immutable, so don't spell-check it. "CHANGELOG.md", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ae1565..591b2f7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1840,7 +1840,6 @@ dependencies = [ "smoltcp", "sys-locale", "sysinfo", - "tao", "tar", "tauri", "tauri-build", @@ -1861,7 +1860,6 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "tower", "tower-http", - "tray-icon 0.24.0", "url", "urlencoding", "utoipa", @@ -3667,25 +3665,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3923,7 +3902,6 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "libxdo", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -6633,7 +6611,7 @@ dependencies = [ "tauri-utils", "thiserror 2.0.18", "tokio", - "tray-icon 0.23.1", + "tray-icon", "url", "webkit2gtk", "webview2-com", @@ -7507,27 +7485,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "tray-icon" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - [[package]] name = "tree_magic_mini" version = "3.2.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 14cc39f..ef1c041 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,10 +24,6 @@ path = "src/main.rs" name = "donut-proxy" path = "src/bin/proxy_server.rs" -[[bin]] -name = "donut-daemon" -path = "src/bin/donut_daemon.rs" - [build-dependencies] tauri-build = { version = "2", features = [] } resvg = "0.47" @@ -111,9 +107,7 @@ quick-xml = { version = "0.40", features = ["serialize"] } boringtun = "0.7" smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } -# Daemon dependencies (tray icon) -tray-icon = "0.24" -tao = "0.35" +# Tray icon decoding (main-process system tray) image = "0.25" dirs = "6" crossbeam-channel = "0.5" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index a53ddb6..69d1d0f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -5,7 +5,7 @@ fn main() { // This allows running cargo test without building the frontend first ensure_dist_folder_exists(); - // Generate tray icon PNGs from SVG (macOS template icon format) + // Generate tray icon PNG files from SVG (macOS template icon format) generate_tray_icons(); #[cfg(target_os = "macos")] @@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool { let binaries_dir = PathBuf::from(&manifest_dir).join("binaries"); // Check for all required external binaries (must match tauri.conf.json externalBin) - let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") { - ( - format!("donut-proxy-{}.exe", target), - format!("donut-daemon-{}.exe", target), - ) + let donut_proxy_name = if target.contains("windows") { + format!("donut-proxy-{}.exe", target) } else { - ( - format!("donut-proxy-{}", target), - format!("donut-daemon-{}", target), - ) + format!("donut-proxy-{}", target) }; - binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists() + binaries_dir.join(&donut_proxy_name).exists() } fn ensure_dist_folder_exists() { diff --git a/src-tauri/copy-proxy-binary.mjs b/src-tauri/copy-proxy-binary.mjs index 83fdc7f..77f8049 100644 --- a/src-tauri/copy-proxy-binary.mjs +++ b/src-tauri/copy-proxy-binary.mjs @@ -77,4 +77,3 @@ function copyBinary(baseName) { } copyBinary("donut-proxy"); -copyBinary("donut-daemon"); diff --git a/src-tauri/copy-proxy-binary.sh b/src-tauri/copy-proxy-binary.sh index d0d3f25..95711be 100755 --- a/src-tauri/copy-proxy-binary.sh +++ b/src-tauri/copy-proxy-binary.sh @@ -102,6 +102,3 @@ copy_binary() { # Copy donut-proxy binary copy_binary "donut-proxy" -# Copy donut-daemon binary -copy_binary "donut-daemon" - diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 11990ea..69393d1 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1,6 +1,5 @@ use crate::browser::ProxySettings; use crate::camoufox_manager::CamoufoxConfig; -use crate::daemon_ws::{ws_handler, WsState}; use crate::events; use crate::group_manager::GROUP_MANAGER; use crate::profile::manager::ProfileManager; @@ -412,16 +411,9 @@ impl ApiServer { )) .layer(middleware::from_fn(terms_check_middleware)); - // Create WebSocket route with its own state (no auth required for daemon IPC) - let ws_state = WsState::new(); - let ws_routes = Router::new() - .route("/events", get(ws_handler)) - .with_state(ws_state); - let api_for_v1 = api.clone(); let app = Router::new() .merge(v1_routes) - .nest("/ws", ws_routes) .route("/openapi.json", get(move || async move { Json(api) })) .route( "/v1/openapi.json", diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs deleted file mode 100644 index 7ad6a7a..0000000 --- a/src-tauri/src/bin/donut_daemon.rs +++ /dev/null @@ -1,498 +0,0 @@ -// Donut Browser Daemon - Background process for tray icon and services -// This runs independently of the main Tauri GUI - -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; -use std::time::{Duration, Instant}; - -use serde::{Deserialize, Serialize}; -use tao::event::{Event, StartCause}; -use tao::event_loop::{ControlFlow, EventLoopBuilder}; -use tokio::runtime::Runtime; -use tray_icon::menu::MenuEvent; -use tray_icon::TrayIcon; -#[cfg(not(target_os = "macos"))] -use tray_icon::{MouseButton, TrayIconEvent}; - -use donutbrowser_lib::daemon::{autostart, services, tray}; - -static SHOULD_QUIT: AtomicBool = AtomicBool::new(false); - -#[cfg(windows)] -fn win_process_exists(pid: u32) -> bool { - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - - extern "system" { - fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut (); - fn CloseHandle(hObject: *mut ()) -> i32; - } - - let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; - if handle.is_null() { - false - } else { - unsafe { CloseHandle(handle) }; - true - } -} - -enum ServiceStatus { - Ready { - api_port: Option, - mcp_running: bool, - }, - Failed(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct DaemonState { - daemon_pid: Option, - api_port: Option, - mcp_running: bool, - version: String, -} - -fn get_state_path() -> PathBuf { - autostart::get_data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("daemon-state.json") -} - -fn ensure_data_dir() -> std::io::Result<()> { - if let Some(data_dir) = autostart::get_data_dir() { - fs::create_dir_all(&data_dir)?; - } - Ok(()) -} - -fn read_state() -> DaemonState { - let path = get_state_path(); - if path.exists() { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(state) = serde_json::from_str(&content) { - return state; - } - } - } - DaemonState::default() -} - -fn write_state(state: &DaemonState) -> std::io::Result<()> { - let path = get_state_path(); - let content = serde_json::to_string_pretty(state)?; - fs::write(path, content) -} - -fn set_high_priority() { - #[cfg(unix)] - { - // Set high priority so the daemon is killed last under resource pressure - // Negative nice value = higher priority. Try -10, fall back to -5 if it fails. - unsafe { - if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 { - let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5); - } - } - } - - #[cfg(windows)] - { - use windows::Win32::Foundation::CloseHandle; - use windows::Win32::System::Threading::{ - GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, - }; - - // Set high priority so the daemon is killed last under resource pressure - unsafe { - let handle = GetCurrentProcess(); - let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS); - // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed, - // but we do it anyway for consistency - let _ = CloseHandle(handle); - } - } -} - -fn run_daemon() { - // Set high priority so the daemon is less likely to be killed under resource pressure - set_high_priority(); - - // Initialize logging to file for debugging (since stdout/stderr may be redirected) - let log_path = autostart::get_data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("daemon.log"); - - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path); - - env_logger::Builder::from_default_env() - .filter_level(log::LevelFilter::Info) - .format_timestamp_millis() - .target(if let Ok(file) = log_file { - env_logger::Target::Pipe(Box::new(file)) - } else { - env_logger::Target::Stderr - }) - .init(); - - if let Err(e) = ensure_data_dir() { - eprintln!("Failed to create data directory: {}", e); - process::exit(1); - } - - log::info!("[daemon] Starting with PID {}", process::id()); - - // Create tokio runtime for async operations - let rt = Runtime::new().expect("Failed to create tokio runtime"); - - // Create channel for service status updates - let (tx, rx) = mpsc::channel::(); - - // Spawn services in a background thread so we don't block the event loop - let rt_handle = rt.handle().clone(); - std::thread::spawn(move || { - let result = rt_handle.block_on(async { services::DaemonServices::start().await }); - let status = match result { - Ok(s) => ServiceStatus::Ready { - api_port: s.api_port, - mcp_running: s.mcp_running, - }, - Err(e) => ServiceStatus::Failed(e), - }; - let _ = tx.send(status); - }); - - // Write initial state (services still starting) - let state = DaemonState { - daemon_pid: Some(process::id()), - api_port: None, - mcp_running: false, - version: env!("CARGO_PKG_VERSION").to_string(), - }; - if let Err(e) = write_state(&state) { - log::error!("Failed to write state: {}", e); - } - - // Prepare tray menu and icon (but don't create the tray icon yet) - let tray_menu = tray::TrayMenu::new(); - - let icon = tray::load_icon(); - let menu_channel = MenuEvent::receiver(); - - // Create the event loop IMMEDIATELY (critical for macOS tray icon) - let event_loop = EventLoopBuilder::new().build(); - - // Store tray icon in Option - created after event loop starts - let mut tray_icon: Option = None; - - // Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown - #[cfg(unix)] - unsafe { - extern "C" fn signal_handler(_sig: libc::c_int) { - SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst); - } - libc::signal( - libc::SIGTERM, - signal_handler as *const () as libc::sighandler_t, - ); - libc::signal( - libc::SIGINT, - signal_handler as *const () as libc::sighandler_t, - ); - } - - #[cfg(windows)] - { - extern "system" { - fn SetConsoleCtrlHandler( - handler: Option i32>, - add: i32, - ) -> i32; - } - - unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 { - SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst); - 1 // TRUE - } - - unsafe { - SetConsoleCtrlHandler(Some(ctrl_handler), 1); - } - } - - // Run the event loop - event_loop.run(move |event, _, control_flow| { - // Use WaitUntil to check for menu events periodically while staying low on CPU - *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100)); - - match event { - Event::NewEvents(StartCause::Init) => { - // Hide from dock on macOS (must be done after event loop starts) - #[cfg(target_os = "macos")] - { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; - - if let Some(mtm) = MainThreadMarker::new() { - let app = NSApplication::sharedApplication(mtm); - app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - } - } - - // Create tray icon after event loop has started (required for macOS) - tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu)); - log::info!("[daemon] Tray icon created"); - } - Event::MainEventsCleared => { - // Check for service status updates from background thread - if let Ok(status) = rx.try_recv() { - match status { - ServiceStatus::Ready { - api_port, - mcp_running, - } => { - log::info!("[daemon] Services started successfully"); - - // Update state file - let mut state = read_state(); - state.api_port = api_port; - state.mcp_running = mcp_running; - if let Err(e) = write_state(&state) { - log::error!("Failed to write state: {}", e); - } - } - ServiceStatus::Failed(e) => { - log::error!("Failed to start services: {}", e); - } - } - } - - // Process menu events - while let Ok(event) = menu_channel.try_recv() { - if event.id == tray_menu.quit_item.id() { - log::info!("[daemon] Quit requested"); - SHOULD_QUIT.store(true, Ordering::SeqCst); - } - } - - // Handle tray icon click (left-click opens the app) - // On macOS, left-click already shows the menu, so don't also launch the GUI. - #[cfg(not(target_os = "macos"))] - while let Ok(event) = TrayIconEvent::receiver().try_recv() { - if let TrayIconEvent::Click { - button: MouseButton::Left, - .. - } = event - { - tray::open_gui(); - } - } - - // Use swap to only run cleanup once - if SHOULD_QUIT.swap(false, Ordering::SeqCst) { - // Remove tray icon from status bar immediately so the UI feels responsive - tray_icon = None; - - tray::quit_gui(); - - let mut state = read_state(); - state.daemon_pid = None; - let _ = write_state(&state); - log::info!("[daemon] Exiting"); - - // Use process::exit for immediate termination instead of ControlFlow::Exit. - // ControlFlow::Exit can delay because tao's macOS event loop defers exit, - // and dropping the tokio runtime blocks until all spawned tasks finish. - process::exit(0); - } - } - Event::Reopen { .. } => { - tray::open_gui(); - - // Re-hide daemon from Dock. macOS activates the daemon (making it - // visible) when the user clicks the Dock icon, overriding the - // Accessory policy set at init. - #[cfg(target_os = "macos")] - { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; - - if let Some(mtm) = MainThreadMarker::new() { - let app = NSApplication::sharedApplication(mtm); - app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - } - } - } - _ => {} - } - - // Keep tray_icon alive - let _ = &tray_icon; - - // Keep runtime alive - let _ = &rt; - }); -} - -fn stop_daemon() { - let state = read_state(); - - if let Some(pid) = state.daemon_pid { - // On Windows, taskkill /F kills instantly with no handler, so kill GUI first - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - use std::process::Command; - const CREATE_NO_WINDOW: u32 = 0x08000000; - - let state_path = get_state_path(); - if let Ok(content) = fs::read_to_string(&state_path) { - if let Ok(val) = serde_json::from_str::(&content) { - if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) { - let _ = Command::new("taskkill") - .args(["/PID", &gui_pid.to_string(), "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .output(); - } - } - } - - let _ = Command::new("taskkill") - .args(["/PID", &pid.to_string(), "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .output(); - eprintln!("Sent stop signal to daemon (PID {})", pid); - } - - #[cfg(unix)] - { - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } - eprintln!("Sent stop signal to daemon (PID {})", pid); - } - } else { - eprintln!("Daemon is not running"); - } -} - -fn show_status() { - let state = read_state(); - - if let Some(pid) = state.daemon_pid { - #[cfg(unix)] - let is_running = unsafe { libc::kill(pid as i32, 0) == 0 }; - - #[cfg(windows)] - let is_running = win_process_exists(pid); - - #[cfg(not(any(unix, windows)))] - let is_running = false; - - if is_running { - eprintln!("Daemon is running (PID {})", pid); - if let Some(port) = state.api_port { - eprintln!(" API: Running on port {}", port); - } else { - eprintln!(" API: Stopped"); - } - eprintln!( - " MCP: {}", - if state.mcp_running { - "Running" - } else { - "Stopped" - } - ); - } else { - eprintln!("Daemon is not running (stale PID in state file)"); - } - } else { - eprintln!("Daemon is not running"); - } -} - -fn print_usage() { - eprintln!("Donut Browser Daemon"); - eprintln!(); - eprintln!("Usage: donut-daemon "); - eprintln!(); - eprintln!("Commands:"); - eprintln!(" start Start the daemon (detaches from terminal)"); - eprintln!(" stop Stop the running daemon"); - eprintln!(" status Show daemon status"); - eprintln!(" run Run in foreground (for debugging)"); - eprintln!(" autostart Manage autostart settings"); - eprintln!(" enable Enable autostart on login"); - eprintln!(" disable Disable autostart on login"); - eprintln!(" status Show autostart status"); -} - -fn main() { - let args: Vec = env::args().collect(); - - if args.len() < 2 { - print_usage(); - process::exit(1); - } - - match args[1].as_str() { - "start" => { - run_daemon(); - } - "stop" => { - stop_daemon(); - } - "status" => { - show_status(); - } - "run" => { - run_daemon(); - } - "autostart" => { - if args.len() < 3 { - eprintln!("Usage: donut-daemon autostart "); - process::exit(1); - } - match args[2].as_str() { - "enable" => { - if let Err(e) = autostart::enable_autostart() { - eprintln!("Failed to enable autostart: {}", e); - process::exit(1); - } - eprintln!("Autostart enabled"); - } - "disable" => { - if let Err(e) = autostart::disable_autostart() { - eprintln!("Failed to disable autostart: {}", e); - process::exit(1); - } - eprintln!("Autostart disabled"); - } - "status" => { - if autostart::is_autostart_enabled() { - eprintln!("Autostart is enabled"); - } else { - eprintln!("Autostart is disabled"); - } - } - _ => { - eprintln!("Unknown autostart command: {}", args[2]); - process::exit(1); - } - } - } - _ => { - print_usage(); - process::exit(1); - } - } -} diff --git a/src-tauri/src/daemon/autostart.rs b/src-tauri/src/daemon/autostart.rs deleted file mode 100644 index 9727f12..0000000 --- a/src-tauri/src/daemon/autostart.rs +++ /dev/null @@ -1,351 +0,0 @@ -use directories::ProjectDirs; -#[cfg(any(target_os = "macos", target_os = "linux"))] -use std::fs; -use std::io; -use std::path::PathBuf; - -fn get_daemon_path() -> Option { - // First try to find the daemon binary in the same directory as the current executable - if let Ok(current_exe) = std::env::current_exe() { - let daemon_path = current_exe.parent()?.join(daemon_binary_name()); - if daemon_path.exists() { - return Some(daemon_path); - } - } - - // Try common installation paths - #[cfg(target_os = "macos")] - { - let paths = [ - PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"), - dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"), - ]; - for path in paths { - if path.exists() { - return Some(path); - } - } - } - - #[cfg(target_os = "windows")] - { - let paths = [ - dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"), - PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"), - ]; - for path in paths { - if path.exists() { - return Some(path); - } - } - } - - #[cfg(target_os = "linux")] - { - let paths = [ - PathBuf::from("/usr/bin/donut-daemon"), - PathBuf::from("/usr/local/bin/donut-daemon"), - dirs::home_dir()?.join(".local/bin/donut-daemon"), - ]; - for path in paths { - if path.exists() { - return Some(path); - } - } - } - - None -} - -fn daemon_binary_name() -> &'static str { - #[cfg(windows)] - { - "donut-daemon.exe" - } - #[cfg(not(windows))] - { - "donut-daemon" - } -} - -#[cfg(target_os = "macos")] -pub fn enable_autostart() -> io::Result<()> { - let daemon_path = get_daemon_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; - - let plist_dir = dirs::home_dir() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))? - .join("Library/LaunchAgents"); - - fs::create_dir_all(&plist_dir)?; - - let plist_path = plist_dir.join("com.donutbrowser.daemon.plist"); - - // Get log directory (use data directory instead of /tmp) - let log_dir = get_data_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("logs"); - fs::create_dir_all(&log_dir)?; - - let plist_content = format!( - r#" - - - - Label - com.donutbrowser.daemon - ProgramArguments - - {daemon_path} - run - - RunAtLoad - - LimitLoadToSessionType - Aqua - ProcessType - Interactive - StandardOutPath - {log_dir}/daemon.out.log - StandardErrorPath - {log_dir}/daemon.err.log - - -"#, - daemon_path = daemon_path.display(), - log_dir = log_dir.display() - ); - - fs::write(&plist_path, plist_content)?; - - log::info!("Created launch agent at {:?}", plist_path); - Ok(()) -} - -#[cfg(target_os = "macos")] -pub fn get_plist_path() -> Option { - dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist")) -} - -#[cfg(target_os = "macos")] -pub fn disable_autostart() -> io::Result<()> { - let plist_path = get_plist_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?; - - if plist_path.exists() { - // First unload the launch agent if it's loaded - let _ = unload_launch_agent(); - fs::remove_file(&plist_path)?; - log::info!("Removed launch agent at {:?}", plist_path); - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -pub fn is_autostart_enabled() -> bool { - get_plist_path().is_some_and(|p| p.exists()) -} - -#[cfg(target_os = "macos")] -pub fn load_launch_agent() -> io::Result<()> { - use std::process::Command; - - let plist_path = get_plist_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?; - - if !plist_path.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "Launch agent plist does not exist", - )); - } - - // Use launchctl load to start the daemon via launchd - // The -w flag writes the "disabled" key to the override plist - let output = Command::new("launchctl") - .args(["load", "-w"]) - .arg(&plist_path) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // "already loaded" is not an error condition for us - if !stderr.contains("already loaded") { - return Err(io::Error::other(format!( - "launchctl load failed: {}", - stderr - ))); - } - } - - log::info!("Loaded launch agent via launchctl"); - Ok(()) -} - -#[cfg(target_os = "macos")] -pub fn start_launch_agent() -> io::Result<()> { - use std::process::Command; - - let output = Command::new("launchctl") - .args(["start", "com.donutbrowser.daemon"]) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(io::Error::other(format!( - "launchctl start failed: {}", - stderr - ))); - } - - log::info!("Started launch agent via launchctl"); - Ok(()) -} - -#[cfg(target_os = "macos")] -pub fn unload_launch_agent() -> io::Result<()> { - use std::process::Command; - - let plist_path = get_plist_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?; - - if !plist_path.exists() { - return Ok(()); - } - - let output = Command::new("launchctl") - .args(["unload"]) - .arg(&plist_path) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - // Not being loaded is not an error - if !stderr.contains("Could not find specified service") { - log::warn!("launchctl unload warning: {}", stderr); - } - } - - log::info!("Unloaded launch agent via launchctl"); - Ok(()) -} - -#[cfg(target_os = "linux")] -pub fn enable_autostart() -> io::Result<()> { - let daemon_path = get_daemon_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; - - let autostart_dir = dirs::config_dir() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))? - .join("autostart"); - - fs::create_dir_all(&autostart_dir)?; - - let desktop_path = autostart_dir.join("donut-daemon.desktop"); - - let escaped_daemon_path = daemon_path - .display() - .to_string() - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('`', "\\`") - .replace('$', "\\$"); - let desktop_content = format!( - r#"[Desktop Entry] -Type=Application -Name=Donut Browser Daemon -Exec="{escaped_daemon_path}" run -Hidden=false -NoDisplay=true -X-GNOME-Autostart-enabled=true -"#, - ); - - fs::write(&desktop_path, desktop_content)?; - - log::info!("Created autostart entry at {:?}", desktop_path); - Ok(()) -} - -#[cfg(target_os = "linux")] -pub fn disable_autostart() -> io::Result<()> { - let desktop_path = dirs::config_dir() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))? - .join("autostart/donut-daemon.desktop"); - - if desktop_path.exists() { - fs::remove_file(&desktop_path)?; - log::info!("Removed autostart entry at {:?}", desktop_path); - } - - Ok(()) -} - -#[cfg(target_os = "linux")] -pub fn is_autostart_enabled() -> bool { - dirs::config_dir() - .map(|c| c.join("autostart/donut-daemon.desktop").exists()) - .unwrap_or(false) -} - -#[cfg(target_os = "windows")] -pub fn enable_autostart() -> io::Result<()> { - use winreg::enums::HKEY_CURRENT_USER; - use winreg::RegKey; - - let daemon_path = get_daemon_path() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?; - - key.set_value( - "DonutBrowserDaemon", - &format!("\"{}\" run", daemon_path.display()), - )?; - - log::info!("Added registry autostart entry"); - Ok(()) -} - -#[cfg(target_os = "windows")] -pub fn disable_autostart() -> io::Result<()> { - use winreg::enums::HKEY_CURRENT_USER; - use winreg::RegKey; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(key) = hkcu.open_subkey_with_flags( - "Software\\Microsoft\\Windows\\CurrentVersion\\Run", - winreg::enums::KEY_WRITE, - ) { - let _ = key.delete_value("DonutBrowserDaemon"); - log::info!("Removed registry autostart entry"); - } - - Ok(()) -} - -#[cfg(target_os = "windows")] -pub fn is_autostart_enabled() -> bool { - use winreg::enums::HKEY_CURRENT_USER; - use winreg::RegKey; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") { - key.get_value::("DonutBrowserDaemon").is_ok() - } else { - false - } -} - -pub fn get_data_dir() -> Option { - if crate::app_dirs::is_portable() { - return Some(crate::app_dirs::data_dir()); - } - if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") { - Some(proj_dirs.data_dir().to_path_buf()) - } else { - dirs::home_dir().map(|h| h.join(".donutbrowser")) - } -} diff --git a/src-tauri/src/daemon/mod.rs b/src-tauri/src/daemon/mod.rs deleted file mode 100644 index f5280f9..0000000 --- a/src-tauri/src/daemon/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod autostart; -pub mod services; -pub mod tray; diff --git a/src-tauri/src/daemon/services.rs b/src-tauri/src/daemon/services.rs deleted file mode 100644 index 870f823..0000000 --- a/src-tauri/src/daemon/services.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::events::{self, DaemonEmitter, DaemonEvent}; -use std::sync::Arc; -use tokio::sync::broadcast; - -pub struct DaemonServices { - pub api_port: Option, - pub mcp_running: bool, - event_emitter: Arc, -} - -impl DaemonServices { - pub async fn start() -> Result { - log::info!("Starting daemon services..."); - - // Create the daemon event emitter - let (emitter, _rx) = DaemonEmitter::with_capacity(256); - let emitter_arc = Arc::new(emitter); - - // Set the global event emitter - if let Err(e) = events::set_global_emitter(emitter_arc.clone()) { - log::warn!("Failed to set global event emitter: {}", e); - } - - // NOTE: The API server currently requires an AppHandle which is only available - // in the Tauri GUI context. For now, the daemon starts with minimal services. - // The GUI will start the API server when it connects to the daemon. - // - // TODO: Refactor API server to work without AppHandle for daemon mode - let api_port = None; - let mcp_running = false; - - log::info!("Daemon services started (minimal mode - waiting for GUI connection)"); - - Ok(Self { - api_port, - mcp_running, - event_emitter: emitter_arc, - }) - } - - pub fn subscribe_events(&self) -> broadcast::Receiver { - self.event_emitter.subscribe() - } - - pub async fn stop(&mut self) { - log::info!("Stopping daemon services..."); - - self.api_port = None; - self.mcp_running = false; - } -} diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs deleted file mode 100644 index 4787fbd..0000000 --- a/src-tauri/src/daemon/tray.rs +++ /dev/null @@ -1,204 +0,0 @@ -use std::process::Command; -use tray_icon::menu::{Menu, MenuItem}; -use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; - -pub fn load_icon() -> Icon { - // On Windows, use the full-color icon so it renders well on dark taskbars. - // On macOS/Linux, use the template icon (black with alpha) for system light/dark handling. - #[cfg(target_os = "windows")] - let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png"); - #[cfg(not(target_os = "windows"))] - let icon_bytes = include_bytes!("../../icons/tray-icon-44.png"); - - let image = image::load_from_memory(icon_bytes) - .expect("Failed to load icon") - .into_rgba8(); - - let (width, height) = image.dimensions(); - let rgba = image.into_raw(); - - Icon::from_rgba(rgba, width, height).expect("Failed to create icon") -} - -pub struct TrayMenu { - pub menu: Menu, - pub quit_item: MenuItem, -} - -impl Default for TrayMenu { - fn default() -> Self { - Self::new() - } -} - -impl TrayMenu { - pub fn new() -> Self { - let menu = Menu::new(); - - let quit_item = MenuItem::new("Quit Donut Browser", true, None); - - menu.append(&quit_item).unwrap(); - - Self { menu, quit_item } - } -} - -pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon { - let builder = TrayIconBuilder::new() - .with_icon(icon) - .with_tooltip("Donut Browser") - .with_menu(Box::new(menu.clone())); - - // On macOS, template icons are automatically colored by the system for light/dark mode - #[cfg(target_os = "macos")] - let builder = builder.with_icon_as_template(true); - - builder.build().expect("Failed to create tray icon") -} - -/// Resolve the .app bundle path from the current daemon executable. -/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`. -#[cfg(target_os = "macos")] -fn get_app_bundle_path() -> Option { - let exe = std::env::current_exe().ok()?; - let macos_dir = exe.parent()?; - let contents_dir = macos_dir.parent()?; - let app_dir = contents_dir.parent()?; - if app_dir.extension().and_then(|e| e.to_str()) == Some("app") { - Some(app_dir.to_path_buf()) - } else { - None - } -} - -pub fn open_gui() { - log::info!("Opening GUI..."); - - #[cfg(target_os = "macos")] - { - // Launch the GUI binary directly. The daemon lives inside the same .app - // bundle, so `open` (even with `-n`) can re-activate the daemon instead - // of launching the GUI. Directly running the binary avoids macOS's app - // activation machinery. The single-instance Tauri plugin in the GUI - // handles deduplication if a GUI instance is already running. - if let Some(app_bundle) = get_app_bundle_path() { - let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut"); - if gui_binary.exists() { - let _ = Command::new(&gui_binary).spawn(); - } else { - let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn(); - } - } else { - let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn(); - } - } - - #[cfg(target_os = "windows")] - { - use std::path::PathBuf; - - if let Ok(current_exe) = std::env::current_exe() { - if let Some(exe_dir) = current_exe.parent() { - let app_path = exe_dir.join("donutbrowser.exe"); - if app_path.exists() { - let _ = Command::new(app_path).spawn(); - return; - } - } - } - - let paths = [ - dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")), - Some(PathBuf::from( - "C:\\Program Files\\Donut Browser\\Donut Browser.exe", - )), - ]; - - for path in paths.iter().flatten() { - if path.exists() { - let _ = Command::new(path).spawn(); - return; - } - } - } - - #[cfg(target_os = "linux")] - { - let _ = Command::new("donutbrowser").spawn(); - } -} - -fn read_gui_pid() -> Option { - let path = super::autostart::get_data_dir()?.join("daemon-state.json"); - let content = std::fs::read_to_string(path).ok()?; - let val: serde_json::Value = serde_json::from_str(&content).ok()?; - val.get("gui_pid")?.as_u64().map(|p| p as u32) -} - -fn kill_gui_by_pid() -> bool { - let Some(pid) = read_gui_pid() else { - return false; - }; - - #[cfg(unix)] - { - let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; - ret == 0 - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - Command::new("taskkill") - .args(["/PID", &pid.to_string(), "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - - #[cfg(not(any(unix, windows)))] - { - false - } -} - -pub fn quit_gui() { - log::info!("[daemon] Quitting GUI..."); - - if kill_gui_by_pid() { - log::info!("[daemon] GUI killed by PID"); - return; - } - - log::info!("[daemon] PID-based kill failed, falling back to name-based kill"); - - #[cfg(target_os = "macos")] - { - // Use spawn() instead of output() to avoid blocking the event loop. - // AppleScript has a ~2 minute default timeout that would freeze the tray icon. - let _ = Command::new("osascript") - .args(["-e", "tell application \"Donut\" to quit"]) - .spawn(); - } - - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let _ = Command::new("taskkill") - .args(["/IM", "Donut.exe", "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .spawn(); - let _ = Command::new("taskkill") - .args(["/IM", "donutbrowser.exe", "/F"]) - .creation_flags(CREATE_NO_WINDOW) - .spawn(); - } - - #[cfg(target_os = "linux")] - { - let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn(); - } -} diff --git a/src-tauri/src/daemon_client.rs b/src-tauri/src/daemon_client.rs deleted file mode 100644 index de786da..0000000 --- a/src-tauri/src/daemon_client.rs +++ /dev/null @@ -1,152 +0,0 @@ -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use tauri::Emitter; -use tokio::sync::Mutex; -use tokio_tungstenite::{connect_async, tungstenite::Message}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WsMessage { - #[serde(rename = "type")] - pub msg_type: String, - pub event: Option, - pub payload: Option, -} - -pub struct DaemonClient { - app_handle: tauri::AppHandle, - connected: Arc, - shutdown: Arc, - daemon_port: Arc>>, -} - -impl DaemonClient { - pub fn new(app_handle: tauri::AppHandle) -> Self { - Self { - app_handle, - connected: Arc::new(AtomicBool::new(false)), - shutdown: Arc::new(AtomicBool::new(false)), - daemon_port: Arc::new(Mutex::new(None)), - } - } - - pub fn is_connected(&self) -> bool { - self.connected.load(Ordering::SeqCst) - } - - pub async fn connect(&self, port: u16) -> Result<(), String> { - *self.daemon_port.lock().await = Some(port); - - let url = format!("ws://127.0.0.1:{}/ws/events", port); - - log::info!("[daemon-client] Connecting to daemon at {}", url); - - let (ws_stream, _) = connect_async(&url) - .await - .map_err(|e| format!("Failed to connect to daemon: {}", e))?; - - self.connected.store(true, Ordering::SeqCst); - log::info!("[daemon-client] Connected to daemon"); - - let (mut write, mut read) = ws_stream.split(); - - let app_handle = self.app_handle.clone(); - let connected = self.connected.clone(); - let shutdown = self.shutdown.clone(); - - // Spawn task to handle incoming messages - tokio::spawn(async move { - while !shutdown.load(Ordering::SeqCst) { - match read.next().await { - Some(Ok(Message::Text(text))) => { - if let Ok(ws_msg) = serde_json::from_str::(&text) { - match ws_msg.msg_type.as_str() { - "event" => { - if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) { - // Forward event to Tauri frontend - if let Err(e) = app_handle.emit(&event, payload) { - log::error!("[daemon-client] Failed to emit event: {}", e); - } - } - } - "connected" => { - log::info!("[daemon-client] Received connection confirmation"); - } - "pong" => { - log::debug!("[daemon-client] Received pong"); - } - _ => { - log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type); - } - } - } - } - Some(Ok(Message::Ping(data))) => { - log::debug!("[daemon-client] Received ping"); - if let Err(e) = write.send(Message::Pong(data)).await { - log::error!("[daemon-client] Failed to send pong: {}", e); - break; - } - } - Some(Ok(Message::Close(_))) => { - log::info!("[daemon-client] Daemon closed connection"); - break; - } - Some(Err(e)) => { - log::error!("[daemon-client] WebSocket error: {}", e); - break; - } - None => { - log::info!("[daemon-client] WebSocket stream ended"); - break; - } - _ => {} - } - } - - connected.store(false, Ordering::SeqCst); - log::info!("[daemon-client] Disconnected from daemon"); - }); - - Ok(()) - } - - pub fn disconnect(&self) { - self.shutdown.store(true, Ordering::SeqCst); - self.connected.store(false, Ordering::SeqCst); - } -} - -pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient { - let client = DaemonClient::new(app_handle); - - if let Err(e) = client.connect(port).await { - log::error!("[daemon-client] Failed to connect: {}", e); - } - - client -} - -pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option { - // Try default port first - let default_port = 10108; - - log::info!( - "[daemon-client] Looking for daemon on port {}", - default_port - ); - - let client = DaemonClient::new(app_handle); - - match client.connect(default_port).await { - Ok(()) => Some(client), - Err(e) => { - log::warn!( - "[daemon-client] Could not connect to daemon on default port: {}", - e - ); - None - } - } -} diff --git a/src-tauri/src/daemon_spawn.rs b/src-tauri/src/daemon_spawn.rs deleted file mode 100644 index 4e4d797..0000000 --- a/src-tauri/src/daemon_spawn.rs +++ /dev/null @@ -1,360 +0,0 @@ -// Daemon Spawn - Start the daemon from the GUI -// Currently disabled; will be re-enabled in the future - -use serde::Deserialize; -use std::fs; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::thread; -use std::time::Duration; - -use crate::daemon::autostart; - -/// Check if a process with the given PID exists using the Windows API. -/// This avoids spawning tasklist.exe which causes a visible conhost window flash. -#[cfg(windows)] -fn win_process_exists(pid: u32) -> bool { - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - - extern "system" { - fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut (); - fn CloseHandle(hObject: *mut ()) -> i32; - } - - let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; - if handle.is_null() { - false - } else { - unsafe { CloseHandle(handle) }; - true - } -} - -#[derive(Debug, Deserialize, Default)] -struct DaemonState { - daemon_pid: Option, -} - -fn get_state_path() -> PathBuf { - autostart::get_data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("daemon-state.json") -} - -fn read_state() -> DaemonState { - let path = get_state_path(); - if path.exists() { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(state) = serde_json::from_str(&content) { - return state; - } - } - } - DaemonState::default() -} - -pub fn is_daemon_running() -> bool { - let state = read_state(); - - if let Some(pid) = state.daemon_pid { - #[cfg(unix)] - { - unsafe { libc::kill(pid as i32, 0) == 0 } - } - - #[cfg(windows)] - { - win_process_exists(pid) - } - - #[cfg(not(any(unix, windows)))] - { - false - } - } else { - false - } -} - -#[cfg(target_os = "macos")] -fn is_dev_mode() -> bool { - if let Ok(current_exe) = std::env::current_exe() { - let path_str = current_exe.to_string_lossy(); - path_str.contains("target/debug") || path_str.contains("target/release") - } else { - false - } -} - -#[cfg(target_os = "macos")] -fn get_daemon_path() -> Option { - // First try to find the daemon binary next to the current executable - if let Ok(current_exe) = std::env::current_exe() { - if let Some(exe_dir) = current_exe.parent() { - let daemon_path = exe_dir.join("donut-daemon"); - if daemon_path.exists() { - return Some(daemon_path); - } - } - } - - // Try common installation paths - let paths = [ - PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"), - dirs::home_dir() - .map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon")) - .unwrap_or_default(), - ]; - paths.into_iter().find(|path| path.exists()) -} - -#[cfg(any(target_os = "linux", windows))] -fn get_daemon_path() -> Option { - // First, try to find it next to the current executable - if let Ok(current_exe) = std::env::current_exe() { - let exe_dir = current_exe.parent()?; - - // Check for daemon binary in same directory - #[cfg(target_os = "windows")] - let daemon_name = "donut-daemon.exe"; - #[cfg(target_os = "linux")] - let daemon_name = "donut-daemon"; - - let daemon_path = exe_dir.join(daemon_name); - if daemon_path.exists() { - return Some(daemon_path); - } - } - - // Try to find it in PATH - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - if let Ok(output) = Command::new("where") - .arg("donut-daemon") - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout); - let path = path.lines().next()?.trim(); - return Some(PathBuf::from(path)); - } - } - } - - #[cfg(target_os = "linux")] - { - if let Ok(output) = Command::new("which").arg("donut-daemon").output() { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout); - let path = path.trim(); - if !path.is_empty() { - return Some(PathBuf::from(path)); - } - } - } - } - - None -} - -pub fn spawn_daemon() -> Result<(), String> { - // Log the daemon state for debugging - let state = read_state(); - log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid); - - // Check if already running - if is_daemon_running() { - log::info!("Daemon is already running (verified by PID check)"); - return Ok(()); - } - - log::info!("Daemon is not running, attempting to start..."); - - // Log current exe location for debugging - let current_exe = std::env::current_exe().ok(); - log::info!("Current exe: {:?}", current_exe); - - // On macOS, use launchctl to start the daemon via launchd - // This ensures the daemon runs in the user's Aqua session with WindowServer access - // and survives app termination since it's managed by launchd, not as a child process - #[cfg(target_os = "macos")] - { - spawn_daemon_macos()?; - } - - // On Linux, use direct spawn - #[cfg(target_os = "linux")] - { - spawn_daemon_unix()?; - } - - #[cfg(windows)] - { - spawn_daemon_windows()?; - } - - // Wait for daemon to start (max 3 seconds) - for i in 0..30 { - thread::sleep(Duration::from_millis(100)); - if is_daemon_running() { - log::info!("Daemon started successfully after {}ms", (i + 1) * 100); - return Ok(()); - } - } - - // Check if we got a state file at least - let state = read_state(); - if let Some(pid) = state.daemon_pid { - log::info!("Daemon appears to have started (PID {} in state file)", pid); - return Ok(()); - } - - Err("Daemon did not start within timeout".to_string()) -} - -#[cfg(target_os = "macos")] -fn spawn_daemon_macos() -> Result<(), String> { - use std::os::unix::process::CommandExt; - - // In dev mode, use direct spawn instead of launchctl - // This avoids issues with plist paths pointing to wrong binaries - if is_dev_mode() { - log::info!("Dev mode detected, using direct spawn instead of launchctl"); - - let daemon_path = get_daemon_path().ok_or_else(|| { - format!( - "Could not find daemon binary. Current exe: {:?}", - std::env::current_exe().ok() - ) - })?; - - log::info!("Spawning daemon from: {:?}", daemon_path); - - // Create a new process group so daemon survives parent exit - let mut cmd = Command::new(&daemon_path); - cmd - .arg("run") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .process_group(0); - - cmd - .spawn() - .map_err(|e| format!("Failed to spawn daemon: {}", e))?; - - return Ok(()); - } - - // Production mode: use launchctl for proper daemon management - // First, ensure the LaunchAgent plist is installed - let autostart_enabled = autostart::is_autostart_enabled(); - log::info!("LaunchAgent plist exists: {}", autostart_enabled); - - if !autostart_enabled { - log::info!("Installing LaunchAgent plist for daemon management"); - autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?; - log::info!("LaunchAgent plist installed successfully"); - } - - // Load the launch agent via launchctl - log::info!("Loading daemon via launchctl..."); - autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?; - log::info!("launchctl load completed"); - - // Also explicitly start the agent in case it was already loaded but stopped - if let Err(e) = autostart::start_launch_agent() { - log::debug!("launchctl start note (non-fatal): {}", e); - } - - Ok(()) -} - -#[cfg(target_os = "linux")] -fn spawn_daemon_unix() -> Result<(), String> { - use std::os::unix::process::CommandExt; - - let daemon_path = get_daemon_path().ok_or_else(|| { - format!( - "Could not find daemon binary. Current exe: {:?}", - std::env::current_exe().ok() - ) - })?; - - log::info!("Spawning daemon from: {:?}", daemon_path); - - // Create a new process group so daemon survives parent exit - let mut cmd = Command::new(&daemon_path); - cmd - .arg("run") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .process_group(0); - - cmd - .spawn() - .map_err(|e| format!("Failed to spawn daemon: {}", e))?; - - Ok(()) -} - -#[cfg(windows)] -fn spawn_daemon_windows() -> Result<(), String> { - use std::os::windows::process::CommandExt; - const DETACHED_PROCESS: u32 = 0x00000008; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - - let daemon_path = get_daemon_path().ok_or_else(|| { - format!( - "Could not find daemon binary. Current exe: {:?}", - std::env::current_exe().ok() - ) - })?; - - log::info!("Spawning daemon from: {:?}", daemon_path); - - Command::new(&daemon_path) - .arg("run") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) - .spawn() - .map_err(|e| format!("Failed to spawn daemon: {}", e))?; - - Ok(()) -} - -pub fn ensure_daemon_running() -> Result<(), String> { - if !is_daemon_running() { - spawn_daemon()?; - } - Ok(()) -} - -pub fn register_gui_pid() { - let path = get_state_path(); - let mut val: serde_json::Value = if path.exists() { - fs::read_to_string(&path) - .ok() - .and_then(|c| serde_json::from_str(&c).ok()) - .unwrap_or_else(|| serde_json::json!({})) - } else { - serde_json::json!({}) - }; - - if let Some(obj) = val.as_object_mut() { - obj.insert( - "gui_pid".to_string(), - serde_json::Value::Number(std::process::id().into()), - ); - } - - if let Ok(content) = serde_json::to_string_pretty(&val) { - let _ = fs::write(&path, content); - } -} diff --git a/src-tauri/src/daemon_ws.rs b/src-tauri/src/daemon_ws.rs deleted file mode 100644 index ed0052c..0000000 --- a/src-tauri/src/daemon_ws.rs +++ /dev/null @@ -1,134 +0,0 @@ -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - response::IntoResponse, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::events::{DaemonEmitter, DaemonEvent}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WsMessage { - #[serde(rename = "type")] - pub msg_type: String, - pub event: Option, - pub payload: Option, -} - -#[derive(Clone)] -pub struct WsState { - event_emitter: Option>, -} - -impl WsState { - pub fn new() -> Self { - Self { - event_emitter: None, - } - } - - pub fn with_emitter(emitter: Arc) -> Self { - Self { - event_emitter: Some(emitter), - } - } -} - -impl Default for WsState { - fn default() -> Self { - Self::new() - } -} - -pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state)) -} - -async fn handle_socket(socket: WebSocket, state: WsState) { - let (mut sender, mut receiver) = socket.split(); - - // Subscribe to daemon events if emitter is available - let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe()); - - log::info!("[ws] Client connected"); - - // Send initial ping to confirm connection - let ping_msg = WsMessage { - msg_type: "connected".to_string(), - event: None, - payload: None, - }; - if let Ok(msg_str) = serde_json::to_string(&ping_msg) { - let _ = sender.send(Message::Text(msg_str.into())).await; - } - - loop { - tokio::select! { - // Handle incoming messages from client - Some(msg) = receiver.next() => { - match msg { - Ok(Message::Text(text)) => { - if let Ok(ws_msg) = serde_json::from_str::(&text) { - match ws_msg.msg_type.as_str() { - "ping" => { - let pong = WsMessage { - msg_type: "pong".to_string(), - event: None, - payload: None, - }; - if let Ok(msg_str) = serde_json::to_string(&pong) { - let _ = sender.send(Message::Text(msg_str.into())).await; - } - } - _ => { - log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type); - } - } - } - } - Ok(Message::Ping(data)) => { - let _ = sender.send(Message::Pong(data)).await; - } - Ok(Message::Close(_)) => { - log::info!("[ws] Client disconnected"); - break; - } - Err(e) => { - log::error!("[ws] Error receiving message: {}", e); - break; - } - _ => {} - } - } - - // Forward daemon events to client - Some(daemon_event) = async { - if let Some(ref mut rx) = event_rx { - rx.recv().await.ok() - } else { - std::future::pending::>().await - } - } => { - let ws_msg = WsMessage { - msg_type: "event".to_string(), - event: Some(daemon_event.event_type), - payload: Some(daemon_event.payload), - }; - if let Ok(msg_str) = serde_json::to_string(&ws_msg) { - if sender.send(Message::Text(msg_str.into())).await.is_err() { - log::error!("[ws] Failed to send event to client"); - break; - } - } - } - - else => break, - } - } - - log::info!("[ws] WebSocket connection closed"); -} diff --git a/src-tauri/src/events/mod.rs b/src-tauri/src/events/mod.rs index 159f494..985262e 100644 --- a/src-tauri/src/events/mod.rs +++ b/src-tauri/src/events/mod.rs @@ -1,10 +1,7 @@ use serde::Serialize; use std::sync::Arc; -use tokio::sync::broadcast; -/// Trait for emitting events to the frontend or connected clients. -/// This abstraction allows the same code to work in both GUI (Tauri) mode -/// and daemon mode (WebSocket broadcast). +/// Trait for emitting events to the frontend. /// /// Note: This trait uses `serde_json::Value` to be dyn-compatible. /// Use the convenience functions `emit()` and `emit_empty()` which accept @@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter { } } -/// Event message sent through the daemon's broadcast channel. -#[derive(Clone, Debug)] -pub struct DaemonEvent { - pub event_type: String, - pub payload: serde_json::Value, -} - -/// Daemon-based event emitter for background daemon mode. -/// Broadcasts events to all connected WebSocket clients. -#[derive(Clone)] -pub struct DaemonEmitter { - tx: broadcast::Sender, -} - -impl DaemonEmitter { - pub fn new(tx: broadcast::Sender) -> Self { - Self { tx } - } - - /// Create a new DaemonEmitter with a default channel capacity. - pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver) { - let (tx, rx) = broadcast::channel(capacity); - (Self { tx }, rx) - } - - /// Subscribe to events from this emitter. - pub fn subscribe(&self) -> broadcast::Receiver { - self.tx.subscribe() - } -} - -impl EventEmitter for DaemonEmitter { - fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> { - let daemon_event = DaemonEvent { - event_type: event.to_string(), - payload, - }; - // Ignore send errors (no receivers connected) - let _ = self.tx.send(daemon_event); - Ok(()) - } -} - /// No-op emitter for testing or when events are not needed. #[derive(Clone, Default)] pub struct NoopEmitter; @@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter { } /// Global event emitter that can be set at runtime. -/// This allows managers to emit events without knowing whether they're -/// running in GUI or daemon mode. +/// This allows managers to emit events without holding an AppHandle directly. static GLOBAL_EMITTER: std::sync::OnceLock> = std::sync::OnceLock::new(); /// Set the global event emitter. This should be called once during app startup. @@ -136,30 +89,6 @@ mod tests { .is_ok()); } - #[test] - fn test_daemon_emitter() { - let (emitter, mut rx) = DaemonEmitter::with_capacity(16); - - // Emit an event - let _ = emitter.emit_value("test-event", serde_json::json!("hello")); - - // Check we received it - let event = rx.try_recv().unwrap(); - assert_eq!(event.event_type, "test-event"); - assert_eq!(event.payload, serde_json::json!("hello")); - } - - #[test] - fn test_daemon_emitter_no_receivers() { - let (tx, _) = broadcast::channel::(16); - let emitter = DaemonEmitter::new(tx); - - // Should not error even with no receivers - assert!(emitter - .emit_value("test-event", serde_json::json!("hello")) - .is_ok()); - } - #[test] fn test_emit_convenience_function() { // Test that emit() works with various types diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e16dedd..a319d7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,11 +52,6 @@ mod wayfern_terms; pub mod cloud_auth; mod commercial_license; mod cookie_manager; -pub mod daemon; -pub mod daemon_client; -#[allow(dead_code)] -mod daemon_spawn; -pub mod daemon_ws; pub mod events; mod mcp_integrations; mod mcp_server; @@ -98,10 +93,10 @@ use downloaded_browsers_registry::{ use downloader::{cancel_download, download_browser}; use settings_manager::{ - decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings, - get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings, - get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings, - save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt, + 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, }; use sync::{ @@ -196,7 +191,8 @@ impl WindowExt for WebviewWindow { } } -#[tauri::command] +// Called internally for deep-link / startup URL handling — not invoked from the +// frontend, so it is intentionally not a `#[tauri::command]`. async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { log::info!("handle_url_open called with URL: {url}"); @@ -1175,6 +1171,34 @@ fn show_main_window(app_handle: &tauri::AppHandle) { } } +/// Update the tray menu labels with localized strings pushed from the frontend +/// (which owns the active language). The item ids are unchanged so the existing +/// menu-event handler keeps matching. +#[tauri::command] +fn update_tray_menu( + app_handle: tauri::AppHandle, + show_label: String, + quit_label: String, +) -> Result<(), String> { + use tauri::menu::{MenuBuilder, MenuItemBuilder}; + if let Some(tray) = app_handle.tray_by_id("main") { + let show_item = MenuItemBuilder::with_id("tray_show", show_label) + .build(&app_handle) + .map_err(|e| e.to_string())?; + let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label) + .build(&app_handle) + .map_err(|e| e.to_string())?; + let menu = MenuBuilder::new(&app_handle) + .item(&show_item) + .separator() + .item(&quit_item) + .build() + .map_err(|e| e.to_string())?; + tray.set_menu(Some(menu)).map_err(|e| e.to_string())?; + } + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = env::args().collect(); @@ -1248,14 +1272,6 @@ pub fn run() { mgr.ensure_icons_extracted(); } - // Daemon (tray icon) is currently disabled — clean up any existing autostart - if daemon::autostart::is_autostart_enabled() { - log::info!("Removing daemon autostart (daemon is disabled)"); - if let Err(e) = daemon::autostart::disable_autostart() { - log::warn!("Failed to remove daemon autostart: {e}"); - } - } - // Create the main window programmatically #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) @@ -1275,6 +1291,12 @@ pub fn run() { // System tray so the user can keep the app running after the close // dialog's "Minimize" action hides the window. + // + // These initial labels are bootstrap defaults only — the frontend pushes + // localized labels via `update_tray_menu` on mount and on every language + // change (the active language lives in the webview). The tray menu is only + // ever opened after the user minimizes to tray, by which point the + // frontend has already localized it, so these strings are never shown. { use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; @@ -2066,6 +2088,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ confirm_quit, hide_to_tray, + update_tray_menu, get_supported_browsers, is_browser_supported_on_platform, download_browser, @@ -2096,9 +2119,6 @@ pub fn run() { save_app_settings, read_log_files, open_log_directory, - should_show_launch_on_login_prompt, - enable_launch_on_login, - decline_launch_on_login, get_table_sorting_settings, save_table_sorting_settings, get_system_language, @@ -2216,7 +2236,6 @@ pub fn run() { disconnect_vpn, get_vpn_status, list_active_vpn_connections, - handle_url_open, // Cloud auth commands cloud_auth::cloud_exchange_device_code, cloud_auth::cloud_get_user, diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index e3095f2..780e5ce 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String { { match base_name { "donut-proxy" => "donut-proxy.exe".to_string(), - "donut-daemon" => "donut-daemon.exe".to_string(), _ => String::new(), } } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index d2a38e8..b3ada91 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -50,8 +50,6 @@ pub struct AppSettings { #[serde(default)] pub mcp_token: Option, // Displayed token for user to copy (not persisted, loaded from encrypted file) #[serde(default)] - pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt - #[serde(default)] pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default #[serde(default)] pub window_resize_warning_dismissed: bool, @@ -93,7 +91,6 @@ impl Default for AppSettings { mcp_enabled: false, mcp_port: None, mcp_token: None, - launch_on_login_declined: false, language: None, window_resize_warning_dismissed: false, disable_auto_updates: false, @@ -183,17 +180,6 @@ impl SettingsManager { Ok(()) } - pub fn should_show_launch_on_login_prompt(&self) -> Result> { - // Daemon is currently disabled, never show this prompt - Ok(false) - } - - pub fn decline_launch_on_login(&self) -> Result<(), Box> { - let mut settings = self.load_settings()?; - settings.launch_on_login_declined = true; - self.save_settings(&settings) - } - fn get_vault_password() -> String { env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() } @@ -795,7 +781,6 @@ pub async fn save_app_settings( if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) { if let Ok(current) = serde_json::from_str::(&content) { settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; - settings.launch_on_login_declined = current.launch_on_login_declined; } } @@ -919,28 +904,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri Ok(()) } -#[tauri::command] -pub async fn should_show_launch_on_login_prompt() -> Result { - let manager = SettingsManager::instance(); - manager - .should_show_launch_on_login_prompt() - .map_err(|e| format!("Failed to check launch on login prompt setting: {e}")) -} - -#[tauri::command] -pub async fn enable_launch_on_login() -> Result<(), String> { - crate::daemon::autostart::enable_autostart() - .map_err(|e| format!("Failed to enable autostart: {e}")) -} - -#[tauri::command] -pub async fn decline_launch_on_login() -> Result<(), String> { - let manager = SettingsManager::instance(); - manager - .decline_launch_on_login() - .map_err(|e| format!("Failed to decline launch on login: {e}")) -} - #[tauri::command] pub async fn get_table_sorting_settings() -> Result { let manager = SettingsManager::instance(); @@ -1182,7 +1145,6 @@ mod tests { mcp_enabled: false, mcp_port: None, mcp_token: None, - launch_on_login_declined: false, language: None, window_resize_warning_dismissed: false, disable_auto_updates: false, @@ -1247,29 +1209,6 @@ mod tests { ); } - #[test] - fn test_should_show_launch_on_login_prompt() { - let (manager, _temp_dir, _guard) = create_test_settings_manager(); - - let result = manager.should_show_launch_on_login_prompt(); - assert!(result.is_ok(), "Should not fail"); - - let _should_show = result.unwrap(); - } - - #[test] - fn test_decline_launch_on_login() { - let (manager, _temp_dir, _guard) = create_test_settings_manager(); - - let settings = manager.load_settings().unwrap(); - assert!(!settings.launch_on_login_declined); - - manager.decline_launch_on_login().unwrap(); - - let settings = manager.load_settings().unwrap(); - assert!(settings.launch_on_login_declined); - } - #[test] fn test_load_corrupted_settings_file() { let (manager, _temp_dir, _guard) = create_test_settings_manager(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4fb82f5..1c6e150 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "active": true, "targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"], "category": "Productivity", - "externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"], + "externalBin": ["binaries/donut-proxy"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/app/page.tsx b/src/app/page.tsx index 16da79d..de16b6f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,7 +23,6 @@ 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 { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; import { @@ -215,8 +214,6 @@ export default function Home() { const [passwordDialogMode, setPasswordDialogMode] = useState("set"); const pendingLaunchAfterUnlockRef = useRef(null); - const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); - const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] = useState(undefined); @@ -546,24 +543,6 @@ export default function Home() { } }, [handleUrlOpen, hasCheckedStartupUrl]); - const checkStartupPrompt = useCallback(async () => { - // Only check once during app startup to prevent reopening after dismissing notifications - if (hasCheckedStartupPrompt) return; - - try { - const shouldShow = await invoke( - "should_show_launch_on_login_prompt", - ); - if (shouldShow) { - setLaunchOnLoginDialogOpen(true); - } - } catch (error) { - console.error("Failed to check startup prompt:", error); - } finally { - setHasCheckedStartupPrompt(true); - } - }, [hasCheckedStartupPrompt]); - // Handle profile errors from useProfileEvents hook useEffect(() => { if (profilesError) { @@ -1190,9 +1169,6 @@ export default function Home() { }, [profiles, t]); useEffect(() => { - // Check for startup default browser prompt - void checkStartupPrompt(); - // Listen for URL open events and get cleanup function const setupListeners = async () => { const cleanup = await listenForUrlEvents(); @@ -1235,7 +1211,6 @@ export default function Home() { }; }, [ checkForUpdates, - checkStartupPrompt, listenForUrlEvents, checkCurrentUrl, checkMissingBinaries, @@ -1337,11 +1312,13 @@ export default function Home() { showToast({ id: "browser-support-ending-warning", type: "error", - title: "Browser support ending soon", - description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`, + title: t("browserSupport.endingSoonTitle"), + description: t("browserSupport.endingSoonDescription", { + profiles: unsupportedNames, + }), duration: 15000, action: { - label: "Learn more", + label: t("common.buttons.learnMore"), onClick: () => { const event = new CustomEvent("url-open-request", { detail: "https://github.com/zhom/donutbrowser/discussions", @@ -1351,7 +1328,7 @@ export default function Home() { }, }); } - }, [profiles]); + }, [profiles, t]); // Re-check Wayfern terms when a browser download completes useEffect(() => { @@ -1851,14 +1828,6 @@ export default function Home() { onClose={checkTrialStatus} /> - {/* Launch on Login Dialog - shown on every startup until enabled or declined */} - { - setLaunchOnLoginDialogOpen(false); - }} - /> - { @@ -29,6 +29,24 @@ export function CloseConfirmDialog() { }; }, []); + // The native tray menu is built in Rust and cannot read the active language, + // so push localized labels to it on mount and whenever the language changes. + useEffect(() => { + const syncTrayMenu = () => { + void invoke("update_tray_menu", { + showLabel: t("tray.show"), + quitLabel: t("tray.quit"), + }).catch(() => { + // Tray is desktop-only; ignore on platforms without one. + }); + }; + syncTrayMenu(); + i18n.on("languageChanged", syncTrayMenu); + return () => { + i18n.off("languageChanged", syncTrayMenu); + }; + }, [t, i18n]); + const handleMinimize = async () => { setIsOpen(false); try { diff --git a/src/components/launch-on-login-dialog.tsx b/src/components/launch-on-login-dialog.tsx deleted file mode 100644 index 8541d91..0000000 --- a/src/components/launch-on-login-dialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { LoadingButton } from "@/components/loading-button"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; - -interface LaunchOnLoginDialogProps { - isOpen: boolean; - onClose: () => void; -} - -export function LaunchOnLoginDialog({ - isOpen, - onClose, -}: LaunchOnLoginDialogProps) { - const { t } = useTranslation(); - const [isEnabling, setIsEnabling] = useState(false); - const [isDeclining, setIsDeclining] = useState(false); - - const handleEnable = useCallback(async () => { - setIsEnabling(true); - try { - await invoke("enable_launch_on_login"); - showSuccessToast(t("launchOnLogin.enableSuccess")); - onClose(); - } catch (error) { - console.error("Failed to enable launch on login:", error); - showErrorToast(t("launchOnLogin.enableFailed"), { - description: - error instanceof Error ? error.message : t("launchOnLogin.tryAgain"), - }); - } finally { - setIsEnabling(false); - } - }, [onClose, t]); - - const handleDecline = useCallback(async () => { - setIsDeclining(true); - try { - await invoke("decline_launch_on_login"); - onClose(); - } catch (error) { - console.error("Failed to decline launch on login:", error); - showErrorToast(t("launchOnLogin.declineFailed"), { - description: - error instanceof Error ? error.message : t("launchOnLogin.tryAgain"), - }); - } finally { - setIsDeclining(false); - } - }, [onClose, t]); - - return ( - - { - e.preventDefault(); - }} - onPointerDownOutside={(e) => { - e.preventDefault(); - }} - onInteractOutside={(e) => { - e.preventDefault(); - }} - > - - {t("launchOnLogin.title")} - - -

- {t("launchOnLogin.description")} -

- - - - - {t("launchOnLogin.enableButton")} - - -
-
- ); -} diff --git a/src/components/shared-camoufox-config-form.tsx b/src/components/shared-camoufox-config-form.tsx index b3dab3c..0fa42ac 100644 --- a/src/components/shared-camoufox-config-form.tsx +++ b/src/components/shared-camoufox-config-form.tsx @@ -422,7 +422,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="Mozilla/5.0..." + placeholder={t("common.placeholders.example", { + value: "Mozilla/5.0...", + })} />
@@ -436,7 +438,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., MacIntel, Win32" + placeholder={t("common.placeholders.example", { + value: "MacIntel, Win32", + })} />
@@ -452,7 +456,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., 5.0 (Macintosh)" + placeholder={t("common.placeholders.example", { + value: "5.0 (Macintosh)", + })} />
@@ -487,7 +493,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 8" + placeholder={t("common.placeholders.example", { value: "8" })} />
@@ -504,7 +510,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -549,7 +555,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., en-US" + placeholder={t("common.placeholders.example", { + value: "en-US", + })} />
@@ -573,7 +581,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -590,7 +600,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1080" + placeholder={t("common.placeholders.example", { + value: "1080", + })} />
@@ -607,7 +619,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -624,7 +638,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1055" + placeholder={t("common.placeholders.example", { + value: "1055", + })} />
@@ -641,7 +657,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 30" + placeholder={t("common.placeholders.example", { + value: "30", + })} />
@@ -658,7 +676,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 30" + placeholder={t("common.placeholders.example", { + value: "30", + })} />
@@ -682,7 +702,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1512" + placeholder={t("common.placeholders.example", { + value: "1512", + })} />
@@ -699,7 +721,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 886" + placeholder={t("common.placeholders.example", { + value: "886", + })} />
@@ -716,7 +740,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1512" + placeholder={t("common.placeholders.example", { + value: "1512", + })} />
@@ -733,7 +759,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 886" + placeholder={t("common.placeholders.example", { + value: "886", + })} />
@@ -748,7 +776,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -763,7 +791,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -786,7 +814,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 41.0019" + placeholder={t("common.placeholders.example", { + value: "41.0019", + })} />
@@ -802,7 +832,9 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 28.9645" + placeholder={t("common.placeholders.example", { + value: "28.9645", + })} />
@@ -817,7 +849,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., America/New_York" + placeholder={t("common.placeholders.example", { + value: "America/New_York", + })} />
@@ -840,7 +874,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., tr" + placeholder={t("common.placeholders.example", { + value: "tr", + })} />
@@ -854,7 +890,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., TR" + placeholder={t("common.placeholders.example", { + value: "TR", + })} />
@@ -868,7 +906,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., Latn" + placeholder={t("common.placeholders.example", { + value: "Latn", + })} />
@@ -891,7 +931,9 @@ export function SharedCamoufoxConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., Mesa" + placeholder={t("common.placeholders.example", { + value: "Mesa", + })} />
@@ -1053,7 +1095,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -1071,7 +1113,7 @@ export function SharedCamoufoxConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -1240,7 +1282,9 @@ export function SharedCamoufoxConfigForm({ : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -1259,7 +1303,9 @@ export function SharedCamoufoxConfigForm({ : undefined, ); }} - placeholder="e.g., 1080" + placeholder={t("common.placeholders.example", { + value: "1080", + })} />
@@ -1278,7 +1324,9 @@ export function SharedCamoufoxConfigForm({ : undefined, ); }} - placeholder="e.g., 800" + placeholder={t("common.placeholders.example", { + value: "800", + })} />
@@ -1297,7 +1345,9 @@ export function SharedCamoufoxConfigForm({ : undefined, ); }} - placeholder="e.g., 600" + placeholder={t("common.placeholders.example", { + value: "600", + })} />
diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index d4addd4..aa9bf33 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -302,7 +302,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="Mozilla/5.0..." + placeholder={t("common.placeholders.example", { + value: "Mozilla/5.0...", + })} />
@@ -334,7 +336,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., 10.0.0" + placeholder={t("common.placeholders.example", { + value: "10.0.0", + })} />
@@ -348,7 +352,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., Google Chrome" + placeholder={t("common.placeholders.example", { + value: "Google Chrome", + })} />
@@ -364,7 +370,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., 143" + placeholder={t("common.placeholders.example", { + value: "143", + })} />
@@ -388,7 +396,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 8" + placeholder={t("common.placeholders.example", { value: "8" })} />
@@ -405,7 +413,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -422,7 +430,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 8" + placeholder={t("common.placeholders.example", { value: "8" })} />
@@ -446,7 +454,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -463,7 +473,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1080" + placeholder={t("common.placeholders.example", { + value: "1080", + })} />
@@ -481,7 +493,9 @@ export function WayfernConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 1.0" + placeholder={t("common.placeholders.example", { + value: "1.0", + })} />
@@ -498,7 +512,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -515,7 +531,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1040" + placeholder={t("common.placeholders.example", { + value: "1040", + })} />
@@ -532,7 +550,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 24" + placeholder={t("common.placeholders.example", { + value: "24", + })} />
@@ -556,7 +576,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -573,7 +595,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1040" + placeholder={t("common.placeholders.example", { + value: "1040", + })} />
@@ -590,7 +614,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -607,7 +633,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 940" + placeholder={t("common.placeholders.example", { + value: "940", + })} />
@@ -622,7 +650,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -637,7 +665,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 0" + placeholder={t("common.placeholders.example", { value: "0" })} />
@@ -660,7 +688,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., en-US" + placeholder={t("common.placeholders.example", { + value: "en-US", + })} />
@@ -740,7 +770,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., America/New_York" + placeholder={t("common.placeholders.example", { + value: "America/New_York", + })} />
@@ -775,7 +807,9 @@ export function WayfernConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 40.7128" + placeholder={t("common.placeholders.example", { + value: "40.7128", + })} />
@@ -791,7 +825,9 @@ export function WayfernConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., -74.0060" + placeholder={t("common.placeholders.example", { + value: "-74.0060", + })} />
@@ -806,7 +842,9 @@ export function WayfernConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 100" + placeholder={t("common.placeholders.example", { + value: "100", + })} />
@@ -829,7 +867,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., Intel" + placeholder={t("common.placeholders.example", { + value: "Intel", + })} />
@@ -926,7 +966,9 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 48000" + placeholder={t("common.placeholders.example", { + value: "48000", + })} />
@@ -943,7 +985,7 @@ export function WayfernConfigForm({ e.target.value ? parseInt(e.target.value, 10) : undefined, ); }} - placeholder="e.g., 2" + placeholder={t("common.placeholders.example", { value: "2" })} />
@@ -987,7 +1029,9 @@ export function WayfernConfigForm({ e.target.value ? parseFloat(e.target.value) : undefined, ); }} - placeholder="e.g., 0.85" + placeholder={t("common.placeholders.example", { + value: "0.85", + })} /> @@ -1008,7 +1052,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., Google Inc." + placeholder={t("common.placeholders.example", { + value: "Google Inc.", + })} />
@@ -1038,7 +1084,9 @@ export function WayfernConfigForm({ e.target.value || undefined, ); }} - placeholder="e.g., 20030107" + placeholder={t("common.placeholders.example", { + value: "20030107", + })} />
@@ -1197,7 +1245,9 @@ export function WayfernConfigForm({ : undefined, ); }} - placeholder="e.g., 1920" + placeholder={t("common.placeholders.example", { + value: "1920", + })} />
@@ -1216,7 +1266,9 @@ export function WayfernConfigForm({ : undefined, ); }} - placeholder="e.g., 1080" + placeholder={t("common.placeholders.example", { + value: "1080", + })} />
@@ -1235,7 +1287,9 @@ export function WayfernConfigForm({ : undefined, ); }} - placeholder="e.g., 800" + placeholder={t("common.placeholders.example", { + value: "800", + })} />
@@ -1254,7 +1308,9 @@ export function WayfernConfigForm({ : undefined, ); }} - placeholder="e.g., 600" + placeholder={t("common.placeholders.example", { + value: "600", + })} />
diff --git a/src/hooks/use-app-update-notifications.tsx b/src/hooks/use-app-update-notifications.tsx index 226fd0f..fcfa8f7 100644 --- a/src/hooks/use-app-update-notifications.tsx +++ b/src/hooks/use-app-update-notifications.tsx @@ -71,7 +71,7 @@ export function useAppUpdateNotifications() { percentage: 0, speed: undefined, eta: undefined, - message: "Starting update...", + message: t("appUpdate.toast.startingUpdate"), }); await invoke("download_and_prepare_app_update", { diff --git a/src/hooks/use-browser-download.ts b/src/hooks/use-browser-download.ts index 48f8cb3..a2bc08c 100644 --- a/src/hooks/use-browser-download.ts +++ b/src/hooks/use-browser-download.ts @@ -443,7 +443,7 @@ export function useBrowserDownload() { showToast({ id: "geoip-download", type: "download", - title: "Downloading GeoIP database", + title: i18n.t("browserDownload.toast.geoipDownloading"), stage: "downloading", progress: { percentage, @@ -455,7 +455,7 @@ export function useBrowserDownload() { showToast({ id: "geoip-download", type: "download", - title: "GeoIP database downloaded successfully!", + title: i18n.t("browserDownload.toast.geoipDownloaded"), stage: "completed", }); } diff --git a/src/hooks/use-update-notifications.tsx b/src/hooks/use-update-notifications.tsx index 0dc22b0..4e69aca 100644 --- a/src/hooks/use-update-notifications.tsx +++ b/src/hooks/use-update-notifications.tsx @@ -62,8 +62,12 @@ export function useUpdateNotifications( showToast({ id: `auto-update-started-${browser}-${newVersion}`, type: "loading", - title: `${browserDisplayName} update started`, - description: `Version ${newVersion} download will begin shortly. Browser launch is disabled until update completes.`, + title: i18n.t("versionUpdater.toast.updateStarted", { + browser: browserDisplayName, + }), + description: i18n.t("versionUpdater.toast.updateStartedDescription", { + version: newVersion, + }), duration: 4000, }); @@ -83,8 +87,11 @@ export function useUpdateNotifications( showToast({ id: `auto-update-skip-download-${browser}-${newVersion}`, type: "success", - title: `${browserDisplayName} ${newVersion} already available`, - description: "Updating profile configurations...", + title: i18n.t("versionUpdater.toast.alreadyAvailable", { + browser: browserDisplayName, + version: newVersion, + }), + description: i18n.t("versionUpdater.toast.updatingProfiles"), duration: 3000, }); } else { @@ -92,8 +99,11 @@ export function useUpdateNotifications( showToast({ id: `auto-update-download-starting-${browser}-${newVersion}`, type: "loading", - title: `Starting ${browserDisplayName} ${newVersion} download`, - description: "Download progress will be shown below...", + title: i18n.t("versionUpdater.toast.downloadStarting", { + browser: browserDisplayName, + version: newVersion, + }), + description: i18n.t("versionUpdater.toast.downloadProgressBelow"), duration: 4000, }); @@ -115,24 +125,36 @@ export function useUpdateNotifications( // Show success message based on whether profiles were updated if (updatedProfiles.length > 0) { - const profileText = + const description = updatedProfiles.length === 1 - ? `Profile "${updatedProfiles[0]}" has been updated` - : `${updatedProfiles.length} profiles have been updated`; + ? i18n.t("versionUpdater.toast.singleProfileUpdated", { + name: updatedProfiles[0], + version: newVersion, + }) + : i18n.t("versionUpdater.toast.multipleProfilesUpdated", { + count: updatedProfiles.length, + version: newVersion, + }); showToast({ id: `auto-update-success-${browser}-${newVersion}`, type: "success", - title: `${browserDisplayName} update completed`, - description: `${profileText} to version ${newVersion}. You can now launch your browsers with the latest version.`, + title: i18n.t("versionUpdater.toast.updateCompleted", { + browser: browserDisplayName, + }), + description, duration: 6000, }); } else { showToast({ id: `auto-update-success-${browser}-${newVersion}`, type: "success", - title: `${browserDisplayName} update completed`, - description: `Version ${newVersion} is now available. Running profiles will use the new version when restarted.`, + title: i18n.t("versionUpdater.toast.updateCompleted", { + browser: browserDisplayName, + }), + description: i18n.t("versionUpdater.toast.versionAvailable", { + version: newVersion, + }), duration: 6000, }); } diff --git a/src/hooks/use-version-updater.ts b/src/hooks/use-version-updater.ts index 78a6a23..83504ee 100644 --- a/src/hooks/use-version-updater.ts +++ b/src/hooks/use-version-updater.ts @@ -139,7 +139,13 @@ export function useVersionUpdater() { try { // Show auto-update start notification showAutoUpdateToast(browserDisplayName, new_version, { - description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`, + description: i18n.t( + "versionUpdater.toast.autoDownloadStarted", + { + browser: browserDisplayName, + version: new_version, + }, + ), }); // Dismiss the update notification in the backend diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 78b2962..2405f03 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -33,7 +33,8 @@ "minimize": "Minimize", "saving": "Saving…", "saved": "Saved", - "copied": "Copied" + "copied": "Copied", + "learnMore": "Learn more" }, "status": { "active": "Active", @@ -99,6 +100,9 @@ "srOnly": { "copy": "Copy", "copied": "Copied" + }, + "placeholders": { + "example": "e.g., {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "Creating...", "createButton": "Create" }, - "launchOnLogin": { - "title": "Enable Launch on Login?", - "description": "Running in the background helps keep your proxies and browsers alive.", - "declineButton": "Don't Ask Again", - "declining": "...", - "enableButton": "Enable", - "enableSuccess": "Launch on login enabled", - "enableFailed": "Failed to enable launch on login", - "declineFailed": "Failed to save preference", - "tryAgain": "Please try again" - }, "wayfernTerms": { "title": "Wayfern Terms and Conditions", "description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.", @@ -1680,7 +1673,8 @@ "viewRelease": "View Release", "later": "Later", "uploading": "Uploading", - "downloading": "Downloading" + "downloading": "Downloading", + "startingUpdate": "Starting update..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.", "extracting": "Extracting browser files... Please do not close the app.", "verifying": "Verifying browser files...", - "downloadingRolling": "Downloading rolling release build..." + "downloadingRolling": "Downloading rolling release build...", + "geoipDownloading": "Downloading GeoIP database", + "geoipDownloaded": "GeoIP database downloaded successfully!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.", "upToDate": "No new browser versions found", "upToDateDescription": "All browser versions are up to date", - "updateAllFailed": "Failed to update browser versions" + "updateAllFailed": "Failed to update browser versions", + "updateStarted": "{{browser}} update started", + "updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.", + "downloadStarting": "Starting {{browser}} {{version}} download", + "downloadProgressBelow": "Download progress will be shown below...", + "autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "Would you like to send the app to the system tray or quit?", "minimize": "Minimize to Tray", "quit": "Quit" + }, + "tray": { + "show": "Show Donut Browser", + "quit": "Quit" + }, + "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." } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9191c56..2d2ff0f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -33,7 +33,8 @@ "minimize": "Minimizar", "saving": "Guardando…", "saved": "Guardado", - "copied": "Copiado" + "copied": "Copiado", + "learnMore": "Más información" }, "status": { "active": "Activo", @@ -99,6 +100,9 @@ "srOnly": { "copy": "Copiar", "copied": "Copiado" + }, + "placeholders": { + "example": "p. ej., {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "Creando...", "createButton": "Crear" }, - "launchOnLogin": { - "title": "¿Activar inicio al iniciar sesión?", - "description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.", - "declineButton": "No volver a preguntar", - "declining": "...", - "enableButton": "Activar", - "enableSuccess": "Inicio al iniciar sesión activado", - "enableFailed": "Error al activar el inicio al iniciar sesión", - "declineFailed": "Error al guardar la preferencia", - "tryAgain": "Por favor, inténtalo de nuevo" - }, "wayfernTerms": { "title": "Términos y condiciones de Wayfern", "description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.", @@ -1680,7 +1673,8 @@ "viewRelease": "Ver lanzamiento", "later": "Más tarde", "uploading": "Subiendo", - "downloading": "Descargando" + "downloading": "Descargando", + "startingUpdate": "Iniciando actualización..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.", "extracting": "Extrayendo archivos del navegador... No cierre la aplicación.", "verifying": "Verificando archivos del navegador...", - "downloadingRolling": "Descargando compilación rolling release..." + "downloadingRolling": "Descargando compilación rolling release...", + "geoipDownloading": "Descargando base de datos GeoIP", + "geoipDownloaded": "¡Base de datos GeoIP descargada correctamente!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.", "upToDate": "No se encontraron nuevas versiones del navegador", "upToDateDescription": "Todas las versiones del navegador están actualizadas", - "updateAllFailed": "Error al actualizar las versiones del navegador" + "updateAllFailed": "Error al actualizar las versiones del navegador", + "updateStarted": "Actualización de {{browser}} iniciada", + "updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.", + "downloadStarting": "Iniciando la descarga de {{browser}} {{version}}", + "downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...", + "autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "¿Quieres enviar la aplicación a la bandeja del sistema o salir?", "minimize": "Minimizar a la bandeja", "quit": "Salir" + }, + "tray": { + "show": "Mostrar Donut Browser", + "quit": "Salir" + }, + "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." } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index fa77510..c7a703f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -33,7 +33,8 @@ "minimize": "Réduire", "saving": "Enregistrement…", "saved": "Enregistré", - "copied": "Copié" + "copied": "Copié", + "learnMore": "En savoir plus" }, "status": { "active": "Actif", @@ -99,6 +100,9 @@ "srOnly": { "copy": "Copier", "copied": "Copié" + }, + "placeholders": { + "example": "p. ex. {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "Création...", "createButton": "Créer" }, - "launchOnLogin": { - "title": "Activer le démarrage à la connexion ?", - "description": "Tourner en arrière-plan permet de garder vos proxys et navigateurs actifs.", - "declineButton": "Ne plus demander", - "declining": "...", - "enableButton": "Activer", - "enableSuccess": "Démarrage à la connexion activé", - "enableFailed": "Échec de l'activation du démarrage à la connexion", - "declineFailed": "Échec de l'enregistrement de la préférence", - "tryAgain": "Veuillez réessayer" - }, "wayfernTerms": { "title": "Conditions générales de Wayfern", "description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.", @@ -1680,7 +1673,8 @@ "viewRelease": "Voir la version", "later": "Plus tard", "uploading": "Envoi", - "downloading": "Téléchargement" + "downloading": "Téléchargement", + "startingUpdate": "Démarrage de la mise à jour..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.", "extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.", "verifying": "Vérification des fichiers du navigateur...", - "downloadingRolling": "Téléchargement de la version rolling release..." + "downloadingRolling": "Téléchargement de la version rolling release...", + "geoipDownloading": "Téléchargement de la base de données GeoIP", + "geoipDownloaded": "Base de données GeoIP téléchargée avec succès !" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "{{newVersions}} nouvelles versions trouvées sur {{successfulUpdates}} navigateurs. Les téléchargements automatiques commenceront sous peu.", "upToDate": "Aucune nouvelle version de navigateur trouvée", "upToDateDescription": "Toutes les versions des navigateurs sont à jour", - "updateAllFailed": "Échec de la mise à jour des versions des navigateurs" + "updateAllFailed": "Échec de la mise à jour des versions des navigateurs", + "updateStarted": "Mise à jour de {{browser}} démarrée", + "updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.", + "downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}", + "downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...", + "autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "Voulez-vous envoyer l'application dans la zone de notification ou quitter ?", "minimize": "Réduire dans la barre d'état", "quit": "Quitter" + }, + "tray": { + "show": "Afficher Donut Browser", + "quit": "Quitter" + }, + "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." } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f6e7bc1..94c47ec 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -33,7 +33,8 @@ "minimize": "最小化", "saving": "保存中…", "saved": "保存しました", - "copied": "コピーしました" + "copied": "コピーしました", + "learnMore": "詳細" }, "status": { "active": "アクティブ", @@ -99,6 +100,9 @@ "srOnly": { "copy": "コピー", "copied": "コピーしました" + }, + "placeholders": { + "example": "例: {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "作成中...", "createButton": "作成" }, - "launchOnLogin": { - "title": "ログイン時に起動しますか?", - "description": "バックグラウンドで実行することで、プロキシとブラウザを維持できます。", - "declineButton": "今後は表示しない", - "declining": "...", - "enableButton": "有効にする", - "enableSuccess": "ログイン時の起動を有効にしました", - "enableFailed": "ログイン時の起動を有効にできませんでした", - "declineFailed": "設定の保存に失敗しました", - "tryAgain": "もう一度お試しください" - }, "wayfernTerms": { "title": "Wayfern 利用規約", "description": "Donut Browser を使用する前に、Wayfern の利用規約を読み、同意する必要があります。", @@ -1680,7 +1673,8 @@ "viewRelease": "リリースを見る", "later": "後で", "uploading": "アップロード中", - "downloading": "ダウンロード中" + "downloading": "ダウンロード中", + "startingUpdate": "更新を開始しています..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。", "extracting": "ブラウザファイルを展開中... アプリを閉じないでください。", "verifying": "ブラウザファイルを検証中...", - "downloadingRolling": "ローリングリリースビルドをダウンロード中..." + "downloadingRolling": "ローリングリリースビルドをダウンロード中...", + "geoipDownloading": "GeoIP データベースをダウンロード中", + "geoipDownloaded": "GeoIP データベースのダウンロードが完了しました!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "{{successfulUpdates}} 個のブラウザに {{newVersions}} 個の新しいバージョンが見つかりました。自動ダウンロードがまもなく開始します。", "upToDate": "新しいブラウザのバージョンは見つかりませんでした", "upToDateDescription": "すべてのブラウザバージョンは最新です", - "updateAllFailed": "ブラウザバージョンの更新に失敗しました" + "updateAllFailed": "ブラウザバージョンの更新に失敗しました", + "updateStarted": "{{browser}} の更新を開始しました", + "updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。", + "downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています", + "downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...", + "autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。" } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "アプリをシステムトレイに格納しますか、それとも終了しますか?", "minimize": "トレイに格納", "quit": "終了" + }, + "tray": { + "show": "Donut Browser を表示", + "quit": "終了" + }, + "browserSupport": { + "endingSoonTitle": "ブラウザのサポートが間もなく終了します", + "endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 58479e2..2a652da 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -33,7 +33,8 @@ "minimize": "최소화", "saving": "저장 중…", "saved": "저장됨", - "copied": "복사됨" + "copied": "복사됨", + "learnMore": "자세히 알아보기" }, "status": { "active": "활성", @@ -99,6 +100,9 @@ "srOnly": { "copy": "복사", "copied": "복사됨" + }, + "placeholders": { + "example": "예: {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "생성 중...", "createButton": "생성" }, - "launchOnLogin": { - "title": "로그인 시 실행을 활성화하시겠습니까?", - "description": "백그라운드에서 실행하면 프록시와 브라우저를 계속 유지할 수 있습니다.", - "declineButton": "다시 묻지 않음", - "declining": "...", - "enableButton": "활성화", - "enableSuccess": "로그인 시 실행이 활성화되었습니다", - "enableFailed": "로그인 시 실행 활성화 실패", - "declineFailed": "환경 설정 저장 실패", - "tryAgain": "다시 시도하세요" - }, "wayfernTerms": { "title": "Wayfern 이용 약관", "description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.", @@ -1680,7 +1673,8 @@ "viewRelease": "릴리스 보기", "later": "나중에", "uploading": "업로드 중", - "downloading": "다운로드 중" + "downloading": "다운로드 중", + "startingUpdate": "업데이트를 시작하는 중..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "손상된 파일이 삭제되었습니다. 다음 시도 시 다시 다운로드됩니다.", "extracting": "브라우저 파일 압축 해제 중... 앱을 닫지 마세요.", "verifying": "브라우저 파일 확인 중...", - "downloadingRolling": "롤링 릴리스 빌드 다운로드 중..." + "downloadingRolling": "롤링 릴리스 빌드 다운로드 중...", + "geoipDownloading": "GeoIP 데이터베이스 다운로드 중", + "geoipDownloaded": "GeoIP 데이터베이스를 성공적으로 다운로드했습니다!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "{{successfulUpdates}}개 브라우저에서 {{newVersions}}개의 새 버전을 찾았습니다. 자동 다운로드가 곧 시작됩니다.", "upToDate": "새 브라우저 버전이 없습니다", "upToDateDescription": "모든 브라우저 버전이 최신입니다", - "updateAllFailed": "브라우저 버전 업데이트 실패" + "updateAllFailed": "브라우저 버전 업데이트 실패", + "updateStarted": "{{browser}} 업데이트를 시작했습니다", + "updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.", + "downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중", + "downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...", + "autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?", "minimize": "트레이로 최소화", "quit": "종료" + }, + "tray": { + "show": "Donut Browser 표시", + "quit": "종료" + }, + "browserSupport": { + "endingSoonTitle": "브라우저 지원이 곧 종료됩니다", + "endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요." } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9173e49..7f4a4ea 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -33,7 +33,8 @@ "minimize": "Minimizar", "saving": "Salvando…", "saved": "Salvo", - "copied": "Copiado" + "copied": "Copiado", + "learnMore": "Saiba mais" }, "status": { "active": "Ativo", @@ -99,6 +100,9 @@ "srOnly": { "copy": "Copiar", "copied": "Copiado" + }, + "placeholders": { + "example": "ex.: {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "Criando...", "createButton": "Criar" }, - "launchOnLogin": { - "title": "Ativar inicialização no login?", - "description": "Rodar em segundo plano ajuda a manter seus proxies e navegadores ativos.", - "declineButton": "Não perguntar novamente", - "declining": "...", - "enableButton": "Ativar", - "enableSuccess": "Inicialização no login ativada", - "enableFailed": "Falha ao ativar a inicialização no login", - "declineFailed": "Falha ao salvar a preferência", - "tryAgain": "Tente novamente" - }, "wayfernTerms": { "title": "Termos e condições da Wayfern", "description": "Antes de usar o Donut Browser, você deve ler e concordar com os Termos e Condições da Wayfern.", @@ -1680,7 +1673,8 @@ "viewRelease": "Ver lançamento", "later": "Mais tarde", "uploading": "Enviando", - "downloading": "Baixando" + "downloading": "Baixando", + "startingUpdate": "Iniciando atualização..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa.", "extracting": "Extraindo arquivos do navegador... Não feche o aplicativo.", "verifying": "Verificando arquivos do navegador...", - "downloadingRolling": "Baixando build rolling release..." + "downloadingRolling": "Baixando build rolling release...", + "geoipDownloading": "Baixando banco de dados GeoIP", + "geoipDownloaded": "Banco de dados GeoIP baixado com sucesso!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "Foram encontradas {{newVersions}} novas versões em {{successfulUpdates}} navegadores. Os downloads automáticos começarão em breve.", "upToDate": "Nenhuma nova versão de navegador encontrada", "upToDateDescription": "Todas as versões dos navegadores estão atualizadas", - "updateAllFailed": "Falha ao atualizar as versões dos navegadores" + "updateAllFailed": "Falha ao atualizar as versões dos navegadores", + "updateStarted": "Atualização do {{browser}} iniciada", + "updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.", + "downloadStarting": "Iniciando o download do {{browser}} {{version}}", + "downloadProgressBelow": "O progresso do download será mostrado abaixo...", + "autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "Você deseja enviar o aplicativo para a bandeja do sistema ou sair?", "minimize": "Minimizar para a bandeja", "quit": "Sair" + }, + "tray": { + "show": "Mostrar Donut Browser", + "quit": "Sair" + }, + "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." } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 38bb80f..00b17d3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -33,7 +33,8 @@ "minimize": "Свернуть", "saving": "Сохраняем…", "saved": "Сохранено", - "copied": "Скопировано" + "copied": "Скопировано", + "learnMore": "Подробнее" }, "status": { "active": "Активен", @@ -99,6 +100,9 @@ "srOnly": { "copy": "Скопировать", "copied": "Скопировано" + }, + "placeholders": { + "example": "напр., {{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "Создание...", "createButton": "Создать" }, - "launchOnLogin": { - "title": "Запускать при входе?", - "description": "Работа в фоновом режиме помогает поддерживать прокси и браузеры активными.", - "declineButton": "Больше не спрашивать", - "declining": "...", - "enableButton": "Включить", - "enableSuccess": "Запуск при входе включен", - "enableFailed": "Не удалось включить запуск при входе", - "declineFailed": "Не удалось сохранить настройку", - "tryAgain": "Пожалуйста, попробуйте снова" - }, "wayfernTerms": { "title": "Условия использования Wayfern", "description": "Прежде чем использовать Donut Browser, необходимо прочитать и согласиться с Условиями использования Wayfern.", @@ -1680,7 +1673,8 @@ "viewRelease": "Посмотреть релиз", "later": "Позже", "uploading": "Загрузка", - "downloading": "Скачивание" + "downloading": "Скачивание", + "startingUpdate": "Запуск обновления..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.", "extracting": "Распаковка файлов браузера... Не закрывайте приложение.", "verifying": "Проверка файлов браузера...", - "downloadingRolling": "Загрузка rolling release сборки..." + "downloadingRolling": "Загрузка rolling release сборки...", + "geoipDownloading": "Загрузка базы данных GeoIP", + "geoipDownloaded": "База данных GeoIP успешно загружена!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "Найдено {{newVersions}} новых версий для {{successfulUpdates}} браузеров. Автоматическая загрузка начнётся в ближайшее время.", "upToDate": "Новых версий браузеров не найдено", "upToDateDescription": "Все версии браузеров актуальны", - "updateAllFailed": "Не удалось обновить версии браузеров" + "updateAllFailed": "Не удалось обновить версии браузеров", + "updateStarted": "Обновление {{browser}} началось", + "updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.", + "downloadStarting": "Запуск загрузки {{browser}} {{version}}", + "downloadProgressBelow": "Прогресс загрузки будет показан ниже...", + "autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже." } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "Свернуть приложение в системный трей или выйти?", "minimize": "Свернуть в трей", "quit": "Выйти" + }, + "tray": { + "show": "Показать Donut Browser", + "quit": "Выход" + }, + "browserSupport": { + "endingSoonTitle": "Поддержка браузера скоро завершится", + "endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox." } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8ad2e16..3e094c0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -33,7 +33,8 @@ "minimize": "最小化", "saving": "正在保存…", "saved": "已保存", - "copied": "已复制" + "copied": "已复制", + "learnMore": "了解更多" }, "status": { "active": "活跃", @@ -99,6 +100,9 @@ "srOnly": { "copy": "复制", "copied": "已复制" + }, + "placeholders": { + "example": "例如:{{value}}" } }, "settings": { @@ -1524,17 +1528,6 @@ "creatingButton": "正在创建...", "createButton": "创建" }, - "launchOnLogin": { - "title": "启用登录时启动?", - "description": "在后台运行有助于保持代理和浏览器存活。", - "declineButton": "不再询问", - "declining": "...", - "enableButton": "启用", - "enableSuccess": "已启用登录时启动", - "enableFailed": "启用登录时启动失败", - "declineFailed": "保存偏好失败", - "tryAgain": "请重试" - }, "wayfernTerms": { "title": "Wayfern 条款和条件", "description": "在使用 Donut Browser 之前,你必须阅读并同意 Wayfern 的条款和条件。", @@ -1680,7 +1673,8 @@ "viewRelease": "查看版本", "later": "稍后", "uploading": "上传中", - "downloading": "下载中" + "downloading": "下载中", + "startingUpdate": "正在开始更新..." } }, "browserDownload": { @@ -1694,7 +1688,9 @@ "extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。", "extracting": "正在提取浏览器文件...请不要关闭应用。", "verifying": "正在验证浏览器文件...", - "downloadingRolling": "正在下载滚动发布版本..." + "downloadingRolling": "正在下载滚动发布版本...", + "geoipDownloading": "正在下载 GeoIP 数据库", + "geoipDownloaded": "GeoIP 数据库下载成功!" } }, "versionUpdater": { @@ -1712,7 +1708,12 @@ "updateSuccessDescription": "在 {{successfulUpdates}} 个浏览器中发现 {{newVersions}} 个新版本。自动下载即将开始。", "upToDate": "未发现新的浏览器版本", "upToDateDescription": "所有浏览器版本都是最新的", - "updateAllFailed": "更新浏览器版本失败" + "updateAllFailed": "更新浏览器版本失败", + "updateStarted": "{{browser}} 更新已开始", + "updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。", + "downloadStarting": "正在开始下载 {{browser}} {{version}}", + "downloadProgressBelow": "下载进度将显示在下方...", + "autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。" } }, "profilePassword": { @@ -1918,5 +1919,13 @@ "description": "您想将应用最小化到系统托盘还是退出?", "minimize": "最小化到托盘", "quit": "退出" + }, + "tray": { + "show": "显示 Donut Browser", + "quit": "退出" + }, + "browserSupport": { + "endingSoonTitle": "浏览器支持即将结束", + "endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。" } } From 3a3f2010659dd3b53e8ecb43d4ee551b90949630 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 29 May 2026 07:50:38 +0400 Subject: [PATCH 05/11] fix: nix missing dependency --- src-tauri/src/lib.rs | 132 +++++++++++++++++++------------------- src-tauri/tauri.conf.json | 4 +- 2 files changed, 69 insertions(+), 67 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a319d7a..bcba4be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1199,6 +1199,68 @@ fn update_tray_menu( Ok(()) } +/// Build the system tray. Best-effort: on Linux the tray depends on +/// libayatana-appindicator at runtime, so any failure here must not abort app +/// startup — the caller logs and continues without a tray. +fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box> { + use std::sync::atomic::Ordering; + use tauri::menu::{MenuBuilder, MenuItemBuilder}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + + // Bootstrap labels only — the frontend pushes localized labels via + // `update_tray_menu` on mount and on language change, and the menu is only + // opened after a minimize-to-tray (post-mount), so these are never shown. + let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser").build(app)?; + let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?; + let tray_menu = MenuBuilder::new(app) + .item(&show_item) + .separator() + .item(&quit_item) + .build()?; + + // macOS uses a black template icon (the OS tints it for light/dark menu + // bars). Windows and Linux use the full-color icon, because neither tints a + // template — a black template would be invisible on dark Linux panels. + #[cfg(target_os = "macos")] + let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png"); + #[cfg(not(target_os = "macos"))] + let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png"); + let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8(); + let (tray_w, tray_h) = tray_rgba.dimensions(); + let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h); + + TrayIconBuilder::with_id("main") + .icon(tray_image) + .icon_as_template(cfg!(target_os = "macos")) + .tooltip("Donut Browser") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app_handle, event| match event.id().as_ref() { + "tray_show" => show_main_window(app_handle), + "tray_quit" => { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + app_handle.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + // Click events are not delivered on Linux (AppIndicator/SNI only drives + // the menu), so left-click-to-restore is macOS/Windows only — Linux users + // restore via the "Show Donut Browser" menu item. + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }) + .build(app)?; + + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = env::args().collect(); @@ -1290,71 +1352,11 @@ pub fn run() { let window = win_builder.build().unwrap(); // System tray so the user can keep the app running after the close - // dialog's "Minimize" action hides the window. - // - // These initial labels are bootstrap defaults only — the frontend pushes - // localized labels via `update_tray_menu` on mount and on every language - // change (the active language lives in the webview). The tray menu is only - // ever opened after the user minimizes to tray, by which point the - // frontend has already localized it, so these strings are never shown. - { - use tauri::menu::{MenuBuilder, MenuItemBuilder}; - use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; - - let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser") - .build(app) - .map_err(|e| format!("Failed to build tray show item: {e}"))?; - let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit") - .build(app) - .map_err(|e| format!("Failed to build tray quit item: {e}"))?; - let tray_menu = MenuBuilder::new(app) - .item(&show_item) - .separator() - .item(&quit_item) - .build() - .map_err(|e| format!("Failed to build tray menu: {e}"))?; - - // Tray-specific icons. macOS/Linux get a template (black + alpha) - // version so the OS can tint it for light/dark menu bars; Windows - // gets the full-color variant. Decode through the `image` crate so - // we hand Tauri raw RGBA — `Image::from_bytes` can fail silently on - // bitmaps that don't match the size Tauri expects. - #[cfg(target_os = "windows")] - let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png"); - #[cfg(not(target_os = "windows"))] - let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png"); - let tray_rgba = image::load_from_memory(tray_icon_bytes) - .map_err(|e| format!("Failed to decode tray icon: {e}"))? - .into_rgba8(); - let (tray_w, tray_h) = tray_rgba.dimensions(); - let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h); - - let _tray = TrayIconBuilder::with_id("main") - .icon(tray_image) - .icon_as_template(cfg!(not(target_os = "windows"))) - .tooltip("Donut Browser") - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(|app_handle, event| match event.id().as_ref() { - "tray_show" => show_main_window(app_handle), - "tray_quit" => { - QUIT_CONFIRMED.store(true, Ordering::SeqCst); - app_handle.exit(0); - } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - show_main_window(tray.app_handle()); - } - }) - .build(app) - .map_err(|e| format!("Failed to build tray icon: {e}"))?; + // dialog's "Minimize" action hides the window. Best-effort: a tray + // failure (e.g. missing libayatana-appindicator on Linux) must never + // prevent the app from launching, so we log and continue without it. + if let Err(e) = setup_system_tray(app.handle()) { + log::warn!("System tray unavailable, continuing without it: {e}"); } // Intercept the window close so the frontend can ask the user whether diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c6e150..46e73dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -42,11 +42,11 @@ "linux": { "deb": { "desktopTemplate": "donutbrowser.desktop", - "depends": ["xdg-utils", "libxdo3"] + "depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"] }, "rpm": { "desktopTemplate": "donutbrowser.desktop", - "depends": ["xdg-utils", "libxdo"] + "depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"] }, "appimage": { "files": { From 2131ca3e3f41721ed6175fae949163c3eb878819 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 09:52:19 +0000 Subject: [PATCH 06/11] deps(rust)(deps): bump the rust-dependencies group Bumps the rust-dependencies group in /src-tauri with 30 updates: | Package | From | To | | --- | --- | --- | | [log](https://github.com/rust-lang/log) | `0.4.29` | `0.4.30` | | [reqwest](https://github.com/seanmonstar/reqwest) | `0.13.3` | `0.13.4` | | [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.39.2` | `0.39.3` | | [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` | | [uuid](https://github.com/uuid-rs/uuid) | `1.23.1` | `1.23.2` | | [aes](https://github.com/RustCrypto/block-ciphers) | `0.9.0` | `0.9.1` | | [hyper](https://github.com/hyperium/hyper) | `1.9.0` | `1.10.1` | | [rusqlite](https://github.com/rusqlite/rusqlite) | `0.39.0` | `0.40.0` | | [brotli](https://github.com/dropbox/rust-brotli) | `8.0.2` | `8.0.3` | | [brotli-decompressor](https://github.com/dropbox/rust-brotli-decompressor) | `5.0.0` | `5.0.1` | | [cc](https://github.com/rust-lang/cc-rs) | `1.2.62` | `1.2.63` | | [displaydoc](https://github.com/yaahc/displaydoc) | `0.2.5` | `0.2.6` | | [http](https://github.com/hyperium/http) | `1.4.0` | `1.4.1` | | [jiff](https://github.com/BurntSushi/jiff) | `0.2.24` | `0.2.28` | | [jiff-static](https://github.com/BurntSushi/jiff) | `0.2.24` | `0.2.28` | | libredox | `0.1.16` | `0.1.17` | | [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | `0.37.0` | `0.38.0` | | [memchr](https://github.com/BurntSushi/memchr) | `2.8.0` | `2.8.1` | | [mio](https://github.com/tokio-rs/mio) | `1.2.0` | `1.2.1` | | [shlex](https://github.com/comex/rust-shlex) | `1.3.0` | `2.0.1` | | [socket2](https://github.com/rust-lang/socket2) | `0.6.3` | `0.6.4` | | [sqlite-wasm-rs](https://github.com/Spxg/sqlite-wasm-rs) | `0.5.4` | `0.5.5` | | [typenum](https://github.com/paholg/typenum) | `1.20.0` | `1.20.1` | | [zbus](https://github.com/z-galaxy/zbus) | `5.15.0` | `5.16.0` | | [zbus_macros](https://github.com/z-galaxy/zbus) | `5.15.0` | `5.16.0` | | [zerocopy](https://github.com/google/zerocopy) | `0.8.48` | `0.8.50` | | [zerocopy-derive](https://github.com/google/zerocopy) | `0.8.48` | `0.8.50` | | [zvariant](https://github.com/z-galaxy/zbus) | `5.11.0` | `5.12.0` | | [zvariant_derive](https://github.com/z-galaxy/zbus) | `5.11.0` | `5.12.0` | | [zvariant_utils](https://github.com/z-galaxy/zbus) | `3.3.1` | `3.4.0` | Updates `log` from 0.4.29 to 0.4.30 - [Release notes](https://github.com/rust-lang/log/releases) - [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.30) Updates `reqwest` from 0.13.3 to 0.13.4 - [Release notes](https://github.com/seanmonstar/reqwest/releases) - [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md) - [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.3...v0.13.4) Updates `sysinfo` from 0.39.2 to 0.39.3 - [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md) - [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.2...v0.39.3) Updates `bzip2` from 0.5.2 to 0.6.1 - [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases) - [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1) Updates `uuid` from 1.23.1 to 1.23.2 - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.1...v1.23.2) Updates `aes` from 0.9.0 to 0.9.1 - [Commits](https://github.com/RustCrypto/block-ciphers/compare/aes-v0.9.0...aes-v0.9.1) Updates `hyper` from 1.9.0 to 1.10.1 - [Release notes](https://github.com/hyperium/hyper/releases) - [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/hyper/compare/v1.9.0...v1.10.1) Updates `rusqlite` from 0.39.0 to 0.40.0 - [Release notes](https://github.com/rusqlite/rusqlite/releases) - [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md) - [Commits](https://github.com/rusqlite/rusqlite/compare/v0.39.0...v0.40.0) Updates `brotli` from 8.0.2 to 8.0.3 - [Release notes](https://github.com/dropbox/rust-brotli/releases) - [Commits](https://github.com/dropbox/rust-brotli/commits) Updates `brotli-decompressor` from 5.0.0 to 5.0.1 - [Commits](https://github.com/dropbox/rust-brotli-decompressor/commits) Updates `cc` from 1.2.62 to 1.2.63 - [Release notes](https://github.com/rust-lang/cc-rs/releases) - [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.62...cc-v1.2.63) Updates `displaydoc` from 0.2.5 to 0.2.6 - [Changelog](https://github.com/yaahc/displaydoc/blob/master/CHANGELOG.md) - [Commits](https://github.com/yaahc/displaydoc/compare/v0.2.5...v0.2.6) Updates `http` from 1.4.0 to 1.4.1 - [Release notes](https://github.com/hyperium/http/releases) - [Changelog](https://github.com/hyperium/http/blob/master/CHANGELOG.md) - [Commits](https://github.com/hyperium/http/compare/v1.4.0...v1.4.1) Updates `jiff` from 0.2.24 to 0.2.28 - [Release notes](https://github.com/BurntSushi/jiff/releases) - [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.24...jiff-static-0.2.28) Updates `jiff-static` from 0.2.24 to 0.2.28 - [Release notes](https://github.com/BurntSushi/jiff/releases) - [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.24...jiff-static-0.2.28) Updates `libredox` from 0.1.16 to 0.1.17 Updates `libsqlite3-sys` from 0.37.0 to 0.38.0 - [Release notes](https://github.com/rusqlite/rusqlite/releases) - [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md) - [Commits](https://github.com/rusqlite/rusqlite/compare/v0.37.0...v0.38.0) Updates `memchr` from 2.8.0 to 2.8.1 - [Commits](https://github.com/BurntSushi/memchr/compare/2.8.0...2.8.1) Updates `mio` from 1.2.0 to 1.2.1 - [Release notes](https://github.com/tokio-rs/mio/releases) - [Changelog](https://github.com/tokio-rs/mio/blob/master/CHANGELOG.md) - [Commits](https://github.com/tokio-rs/mio/commits) Updates `shlex` from 1.3.0 to 2.0.1 - [Changelog](https://github.com/comex/rust-shlex/blob/master/CHANGELOG.md) - [Commits](https://github.com/comex/rust-shlex/commits) Updates `socket2` from 0.6.3 to 0.6.4 - [Release notes](https://github.com/rust-lang/socket2/releases) - [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/socket2/commits) Updates `sqlite-wasm-rs` from 0.5.4 to 0.5.5 - [Release notes](https://github.com/Spxg/sqlite-wasm-rs/releases) - [Changelog](https://github.com/Spxg/sqlite-wasm-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/Spxg/sqlite-wasm-rs/compare/0.5.4...0.5.5) Updates `typenum` from 1.20.0 to 1.20.1 - [Release notes](https://github.com/paholg/typenum/releases) - [Changelog](https://github.com/paholg/typenum/blob/main/CHANGELOG.md) - [Commits](https://github.com/paholg/typenum/compare/v1.20.0...v1.20.1) Updates `zbus` from 5.15.0 to 5.16.0 - [Release notes](https://github.com/z-galaxy/zbus/releases) - [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml) - [Commits](https://github.com/z-galaxy/zbus/compare/zbus-5.15.0...zbus-5.16.0) Updates `zbus_macros` from 5.15.0 to 5.16.0 - [Release notes](https://github.com/z-galaxy/zbus/releases) - [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml) - [Commits](https://github.com/z-galaxy/zbus/compare/zbus_macros-5.15.0...zbus_macros-5.16.0) Updates `zerocopy` from 0.8.48 to 0.8.50 - [Release notes](https://github.com/google/zerocopy/releases) - [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md) - [Commits](https://github.com/google/zerocopy/commits) Updates `zerocopy-derive` from 0.8.48 to 0.8.50 - [Release notes](https://github.com/google/zerocopy/releases) - [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md) - [Commits](https://github.com/google/zerocopy/commits) Updates `zvariant` from 5.11.0 to 5.12.0 - [Release notes](https://github.com/z-galaxy/zbus/releases) - [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml) - [Commits](https://github.com/z-galaxy/zbus/compare/zvariant-5.11.0...zvariant-5.12.0) Updates `zvariant_derive` from 5.11.0 to 5.12.0 - [Release notes](https://github.com/z-galaxy/zbus/releases) - [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml) - [Commits](https://github.com/z-galaxy/zbus/compare/zvariant_derive-5.11.0...zvariant_derive-5.12.0) Updates `zvariant_utils` from 3.3.1 to 3.4.0 - [Release notes](https://github.com/z-galaxy/zbus/releases) - [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml) - [Commits](https://github.com/z-galaxy/zbus/compare/zvariant_utils-3.3.1...zvariant_utils-3.4.0) --- updated-dependencies: - dependency-name: log dependency-version: 0.4.30 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: reqwest dependency-version: 0.13.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: sysinfo dependency-version: 0.39.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: bzip2 dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: uuid dependency-version: 1.23.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: aes dependency-version: 0.9.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: hyper dependency-version: 1.10.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: rusqlite dependency-version: 0.40.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: brotli dependency-version: 8.0.3 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: brotli-decompressor dependency-version: 5.0.1 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: cc dependency-version: 1.2.63 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: displaydoc dependency-version: 0.2.6 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: http dependency-version: 1.4.1 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: jiff dependency-version: 0.2.28 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: jiff-static dependency-version: 0.2.28 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: libredox dependency-version: 0.1.17 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: libsqlite3-sys dependency-version: 0.38.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: memchr dependency-version: 2.8.1 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: mio dependency-version: 1.2.1 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: shlex dependency-version: 2.0.1 dependency-type: indirect update-type: version-update:semver-major dependency-group: rust-dependencies - dependency-name: socket2 dependency-version: 0.6.4 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: sqlite-wasm-rs dependency-version: 0.5.5 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: typenum dependency-version: 1.20.1 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: zbus dependency-version: 5.16.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: zbus_macros dependency-version: 5.16.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: zerocopy dependency-version: 0.8.50 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: zerocopy-derive dependency-version: 0.8.50 dependency-type: indirect update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: zvariant dependency-version: 5.12.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: zvariant_derive dependency-version: 5.12.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies - dependency-name: zvariant_utils dependency-version: 3.4.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: rust-dependencies ... Signed-off-by: dependabot[bot] --- src-tauri/Cargo.lock | 151 ++++++++++++++++++++++++------------------- src-tauri/Cargo.toml | 6 +- 2 files changed, 86 insertions(+), 71 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ae1565..a18a503 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -31,9 +31,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher 0.5.2", "cpubits", @@ -214,7 +214,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -745,9 +745,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -756,9 +756,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -871,6 +871,15 @@ dependencies = [ "bzip2-sys", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -971,9 +980,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1726,9 +1735,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1786,7 +1795,7 @@ dependencies = [ name = "donutbrowser" version = "0.24.4" dependencies = [ - "aes 0.9.0", + "aes 0.9.1", "aes-gcm", "argon2", "async-socks5", @@ -1795,7 +1804,7 @@ dependencies = [ "base64 0.22.1", "blake3", "boringtun", - "bzip2", + "bzip2 0.6.1", "cbc", "chrono", "chrono-tz", @@ -1827,7 +1836,7 @@ dependencies = [ "quick-xml 0.40.1", "rand 0.10.1", "regex-lite", - "reqwest 0.13.3", + "reqwest 0.13.4", "resvg", "ring", "rusqlite", @@ -2938,9 +2947,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2998,9 +3007,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -3087,7 +3096,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3431,9 +3440,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -3444,9 +3453,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -3606,6 +3615,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" version = "0.2.186" @@ -3649,18 +3664,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c" dependencies = [ "cc", "pkg-config", @@ -3709,9 +3724,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" dependencies = [ "value-bag", ] @@ -3820,9 +3835,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -3870,9 +3885,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -4109,7 +4124,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -5345,9 +5360,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -5518,9 +5533,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.11.1", "fallible-iterator", @@ -6133,9 +6148,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigchld" @@ -6247,9 +6262,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.60.2", @@ -6324,9 +6339,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -6468,9 +6483,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.2" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" dependencies = [ "libc", "memchr", @@ -6620,7 +6635,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -6974,7 +6989,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6999,7 +7014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.59.0", @@ -7585,9 +7600,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -7845,9 +7860,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9084,9 +9099,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -9119,9 +9134,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9145,18 +9160,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -9245,7 +9260,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ "aes 0.8.4", "arbitrary", - "bzip2", + "bzip2 0.5.2", "constant_time_eq 0.3.1", "crc32fast", "crossbeam-utils", @@ -9352,9 +9367,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", @@ -9366,9 +9381,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9379,9 +9394,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 14cc39f..fe171f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -87,7 +87,7 @@ cbc = "0.2" ring = "0.17" sha2 = "0.11" shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] } -hyper = { version = "1.8", features = ["full"] } +hyper = { version = "1.10", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" clap = { version = "4", features = ["derive"] } @@ -98,7 +98,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master # Wayfern CDP integration tokio-tungstenite = { version = "0.29", features = ["native-tls"] } -rusqlite = { version = "0.39", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } serde_yaml = "0.9" toml = "1.1" thiserror = "2.0" @@ -145,7 +145,7 @@ windows = { version = "0.62", features = [ [dev-dependencies] tempfile = "3.24.0" wiremock = "0.6" -hyper = { version = "1.8", features = ["full"] } +hyper = { version = "1.10", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" tower = "0.5" From 98f1c7452a75d6040a4cd78879d5a72ff396efcb Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:05:35 +0400 Subject: [PATCH 07/11] feat: add onboarding --- .github/workflows/flake-test.yml | 8 + AGENTS.md | 51 ++ donut-sync/src/sync/dto/sync.dto.ts | 8 + donut-sync/src/sync/sync.service.ts | 7 + flake.nix | 2 + package.json | 5 + pnpm-lock.yaml | 65 +++ src-tauri/src/api_server.rs | 54 +- src-tauri/src/app_dirs.rs | 28 + src-tauri/src/auto_updater.rs | 1 + src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 18 + src-tauri/src/cloud_auth.rs | 23 +- src-tauri/src/downloaded_browsers_registry.rs | 76 ++- src-tauri/src/downloader.rs | 140 ++++- src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/group_manager.rs | 8 + src-tauri/src/lib.rs | 84 ++- src-tauri/src/mcp_server.rs | 12 +- src-tauri/src/profile/manager.rs | 22 +- src-tauri/src/profile/types.rs | 6 + src-tauri/src/profile_importer.rs | 3 + src-tauri/src/proxy_manager.rs | 20 + src-tauri/src/settings_manager.rs | 25 + src-tauri/src/sync/client.rs | 37 ++ src-tauri/src/sync/engine.rs | 197 ++++--- src-tauri/src/sync/types.rs | 14 + src-tauri/src/vpn/config.rs | 4 + src-tauri/src/vpn/storage.rs | 12 + src-tauri/src/wayfern_manager.rs | 38 +- src-tauri/tests/vpn_integration.rs | 4 + src/app/page.tsx | 120 ++++- src/components/account-page.tsx | 31 ++ src/components/app-update-toast.tsx | 2 +- src/components/client-providers.tsx | 5 +- src/components/create-profile-dialog.tsx | 178 +++++-- src/components/custom-toast.tsx | 26 +- .../extension-management-dialog.tsx | 8 +- src/components/group-badges.tsx | 6 +- src/components/home-header.tsx | 1 + src/components/import-profile-dialog.tsx | 4 +- src/components/onboarding-card.tsx | 100 ++++ src/components/onboarding-provider.tsx | 66 +++ src/components/permission-dialog.tsx | 10 +- src/components/profile-data-table.tsx | 1 + src/components/profile-info-dialog.tsx | 30 +- .../shared-camoufox-config-form.tsx | 16 +- src/components/thank-you-dialog.tsx | 83 +++ src/components/ui/color-picker.tsx | 2 +- src/components/ui/dialog.tsx | 70 ++- src/components/ui/sonner.tsx | 4 +- src/components/wayfern-config-form.tsx | 16 +- src/components/welcome-dialog.tsx | 484 ++++++++++++++++++ src/hooks/use-browser-download.ts | 83 +-- src/hooks/use-browser-setup.ts | 342 +++++++++++++ src/i18n/locales/en.json | 102 +++- src/i18n/locales/es.json | 102 +++- src/i18n/locales/fr.json | 102 +++- src/i18n/locales/ja.json | 102 +++- src/i18n/locales/ko.json | 102 +++- src/i18n/locales/pt.json | 102 +++- src/i18n/locales/ru.json | 102 +++- src/i18n/locales/zh.json | 102 +++- src/lib/backend-errors.ts | 12 + src/lib/onboarding-signal.ts | 18 + src/lib/toast-utils.ts | 12 +- src/types.ts | 6 + 67 files changed, 3157 insertions(+), 369 deletions(-) create mode 100644 src/components/onboarding-card.tsx create mode 100644 src/components/onboarding-provider.tsx create mode 100644 src/components/thank-you-dialog.tsx create mode 100644 src/components/welcome-dialog.tsx create mode 100644 src/hooks/use-browser-setup.ts create mode 100644 src/lib/onboarding-signal.ts diff --git a/.github/workflows/flake-test.yml b/.github/workflows/flake-test.yml index cb62128..9902c11 100644 --- a/.github/workflows/flake-test.yml +++ b/.github/workflows/flake-test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index adb4354..1a41645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` (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. diff --git a/donut-sync/src/sync/dto/sync.dto.ts b/donut-sync/src/sync/dto/sync.dto.ts index 54c5394..5e377b4 100644 --- a/donut-sync/src/sync/dto/sync.dto.ts +++ b/donut-sync/src/sync/dto/sync.dto.ts @@ -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; } export class PresignUploadRequestDto { key: string; contentType?: string; expiresIn?: number; + // Object metadata to sign into the presigned PUT as `x-amz-meta-*`. + metadata?: Record; } 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; } export class PresignDownloadRequestDto { diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index 7fd8292..97e95e2 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -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 }); diff --git a/flake.nix b/flake.nix index 9d82dcd..d801938 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/package.json b/package.json index 20272a0..bb39ebe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6975e67..4c8110e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 69393d1..523cc02 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -586,6 +586,24 @@ pub async fn get_api_server_status() -> Result, 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( + config: Option<&T>, + is_paid: bool, +) -> Option { + 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, String> { )] async fn get_profiles() -> Result, 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 = profiles @@ -616,10 +637,7 @@ async fn get_profiles() -> Result, 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, ) -> Result, 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, ) -> Result, 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, diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index f9b26cf..a48873d 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -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 `/{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 { + std::env::var_os("DONUTBROWSER_DATA_ROOT") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) +} + +/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`/logs`); `None` +/// otherwise, in which case the platform default app log dir is used. +pub fn log_dir_override() -> Option { + 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(handle: &tauri::AppHandle) -> PathBuf { + if let Some(dir) = log_dir_override() { + return dir; + } use tauri::Manager; handle .path() diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index dbce456..8285613 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -703,6 +703,7 @@ mod tests { dns_blocklist: None, password_protected: false, created_at: None, + updated_at: None, } } diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index f287245..f8f3e73 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -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); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index f256a62..b9879d3 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -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()); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index a8f9472..ae16e5a 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -46,6 +46,16 @@ pub struct CloudUser { pub team_name: Option, #[serde(rename = "teamRole", default)] pub team_role: Option, + // 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, + #[serde(rename = "deviceCount", default)] + pub device_count: Option, + #[serde(rename = "isPrimaryDevice", default)] + pub is_primary_device: Option, } #[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::(&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 diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index d3b96b1..95d632c 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -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) diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index a36af34..6bdde53 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -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>> = @@ -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 diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 5808d9c..68c47cf 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -281,6 +281,7 @@ mod tests { dns_blocklist: None, password_protected: false, created_at: None, + updated_at: None, } } diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index cf8e6fb..3b8100a 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -13,6 +13,10 @@ pub struct ProfileGroup { pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, + /// 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, } #[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()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcba4be..c713b5d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 Result { + check_vpn_validity_core(&vpn_id).await +} + +pub async fn check_vpn_validity_core( + vpn_id: &str, ) -> Result { 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 /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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index d04187f..07b1ba9 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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) diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index fbc3278..a42da1c 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -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 diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 4e3dfe3..1ef596e 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -78,6 +78,12 @@ pub struct BrowserProfile { /// any staleness check. #[serde(default)] pub created_at: Option, + /// 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, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 51562f8..280fcd6 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -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)?; diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index b3e3b54..fe96554 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -103,6 +103,11 @@ pub struct StoredProxy { pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, + /// 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, #[serde(default)] pub is_cloud_managed: bool, #[serde(default)] @@ -124,6 +129,14 @@ pub struct StoredProxy { pub dynamic_proxy_format: Option, } +/// 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()), diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index b3ada91..4f3374c 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -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 { Ok(settings.window_resize_warning_dismissed) } +#[tauri::command] +pub async fn get_onboarding_completed() -> Result { + 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, }; diff --git a/src-tauri/src/sync/client.rs b/src-tauri/src/sync/client.rs index 9808fcc..388b2e3 100644 --- a/src-tauri/src/sync/client.rs +++ b/src-tauri/src/sync/client.rs @@ -49,6 +49,21 @@ impl SyncClient { &self, key: &str, content_type: Option<&str>, + ) -> SyncResult { + 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>, ) -> SyncResult { 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>, ) -> 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 diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 754f64c..fa8b8a8 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -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>> = 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::().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::(&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 = 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 = 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 = 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 = 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 = 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 diff --git a/src-tauri/src/sync/types.rs b/src-tauri/src/sync/types.rs index 5cf671e..36c36ea 100644 --- a/src-tauri/src/sync/types.rs +++ b/src-tauri/src/sync/types.rs @@ -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, pub size: Option, + /// 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>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -20,6 +26,9 @@ pub struct PresignUploadRequest { pub content_type: Option, #[serde(rename = "expiresIn")] pub expires_in: Option, + /// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`). + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, } #[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>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/vpn/config.rs b/src-tauri/src/vpn/config.rs index 10cd68f..9910f38 100644 --- a/src-tauri/src/vpn/config.rs +++ b/src-tauri/src/vpn/config.rs @@ -52,6 +52,10 @@ pub struct VpnConfig { pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, + /// 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, } /// Parsed WireGuard configuration diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index 67d8fd7..c2e6c50 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -36,6 +36,8 @@ struct StoredVpnConfig { sync_enabled: bool, #[serde(default)] last_sync: Option, + #[serde(default)] + updated_at: Option, } /// 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 { 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(); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index bfbf1e1..37a5785 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -51,6 +51,12 @@ pub struct WayfernLaunchResult { pub profilePath: Option, pub url: Option, pub cdp_port: Option, + /// 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, } 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 = 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, }); } diff --git a/src-tauri/tests/vpn_integration.rs b/src-tauri/tests/vpn_integration.rs index edcd23c..093d932 100644 --- a/src-tauri/tests/vpn_integration.rs +++ b/src-tauri/tests/vpn_integration.rs @@ -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, } } diff --git a/src/app/page.tsx b/src/app/page.tsx index de16b6f..d337a46 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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( + 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("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} /> + + setThankYouOpen(false)} + /> + { diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 82ae581..6007f5b 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -280,9 +280,40 @@ export function AccountPage({

{user.planPeriod}

)} + {typeof user.deviceOrdinal === "number" && ( +
+

+ {t("account.fields.device")} +

+

+ {t("account.deviceOrdinal", { + ordinal: user.deviceOrdinal, + count: user.deviceCount ?? user.deviceOrdinal, + })} +

+
+ )} )} + {isLoggedIn && + user && + user.plan !== "free" && + user.isPrimaryDevice === false && ( +

+ {t("account.automationPrimaryOnly")} +

+ )} + {isLoggedIn && + user && + user.plan !== "free" && + user.isPrimaryDevice === true && + (user.deviceCount ?? 1) > 1 && ( +

+ {t("account.automationActiveHere")} +

+ )} +
{isLoggedIn ? ( <> diff --git a/src/components/app-update-toast.tsx b/src/components/app-update-toast.tsx index 381fb64..2627133 100644 --- a/src/components/app-update-toast.tsx +++ b/src/components/app-update-toast.tsx @@ -37,7 +37,7 @@ export function AppUpdateToast({ return (
- +
diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx index 760e252..ff6c60e 100644 --- a/src/components/client-providers.tsx +++ b/src/components/client-providers.tsx @@ -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 }) { - {children} + + {children} + diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index d335b0d..09d1ce4 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -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 ( - + {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" >
- {(() => { - const IconComponent = getBrowserIcon("wayfern"); - return IconComponent ? ( - - ) : null; - })()} + {isBrowserCurrentlyDownloading("wayfern") ? ( + + ) : ( + (() => { + const IconComponent = getBrowserIcon("wayfern"); + return IconComponent ? ( + + ) : null; + })() + )}
{t("createProfile.chromiumLabel")}
- {t("createProfile.chromiumSubtitle")} + {isBrowserCurrentlyDownloading("wayfern") + ? t("createProfile.downloadingSubtitle") + : t("createProfile.chromiumSubtitle")}
@@ -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" >
- {(() => { - const IconComponent = getBrowserIcon("camoufox"); - return IconComponent ? ( - - ) : null; - })()} + {isBrowserCurrentlyDownloading("camoufox") ? ( + + ) : ( + (() => { + const IconComponent = + getBrowserIcon("camoufox"); + return IconComponent ? ( + + ) : null; + })() + )}
{t("createProfile.firefoxLabel")}
- {t("createProfile.firefoxSubtitle")} + {isBrowserCurrentlyDownloading("camoufox") + ? t("createProfile.downloadingSubtitle") + : t("createProfile.firefoxSubtitle")}
+ + {!getCreatableVersion("wayfern") && + !getCreatableVersion("camoufox") && ( +

+ {t("createProfile.browsersDownloading")} +

+ )}
@@ -867,7 +895,7 @@ export function CreateProfileDialog({ {!isLoadingReleaseTypes && !releaseTypesError && !isBrowserCurrentlyDownloading("wayfern") && - !isBrowserVersionAvailable("wayfern") && + !getCreatableVersion("wayfern") && getBestAvailableVersion("wayfern") && (

@@ -899,17 +927,53 @@ export function CreateProfileDialog({ {!isLoadingReleaseTypes && !releaseTypesError && !isBrowserCurrentlyDownloading("wayfern") && - isBrowserVersionAvailable("wayfern") && ( + getCreatableVersion("wayfern") && (

✓{" "} {t("createProfile.version.available", { browser: "Wayfern", version: - getBestAvailableVersion("wayfern") - ?.version, + getCreatableVersion("wayfern")?.version, })}
)} + {!isLoadingReleaseTypes && + !releaseTypesError && + !isBrowserCurrentlyDownloading("wayfern") && + getCreatableVersion("wayfern") && + !isBrowserVersionAvailable("wayfern") && + getBestAvailableVersion("wayfern") && ( +
+

+ {t( + "createProfile.version.upgradeAvailable", + { + browser: "Wayfern", + version: + getBestAvailableVersion("wayfern") + ?.version, + }, + )} +

+ { + void handleDownload("wayfern"); + }} + isLoading={isBrowserCurrentlyDownloading( + "wayfern", + )} + size="sm" + variant="outline" + disabled={isBrowserCurrentlyDownloading( + "wayfern", + )} + > + {isBrowserCurrentlyDownloading("wayfern") + ? t("common.buttons.downloading") + : t("common.buttons.download")} + +
+ )} {isBrowserCurrentlyDownloading("wayfern") && (
{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") && (

@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({ {!isLoadingReleaseTypes && !releaseTypesError && !isBrowserCurrentlyDownloading("camoufox") && - isBrowserVersionAvailable("camoufox") && ( + getCreatableVersion("camoufox") && (

✓{" "} {t("createProfile.version.available", { browser: "Camoufox", version: - getBestAvailableVersion("camoufox") - ?.version, + getCreatableVersion("camoufox")?.version, })}
)} + {!isLoadingReleaseTypes && + !releaseTypesError && + !isBrowserCurrentlyDownloading("camoufox") && + getCreatableVersion("camoufox") && + !isBrowserVersionAvailable("camoufox") && + getBestAvailableVersion("camoufox") && ( +
+

+ {t( + "createProfile.version.upgradeAvailable", + { + browser: "Camoufox", + version: + getBestAvailableVersion("camoufox") + ?.version, + }, + )} +

+ { + void handleDownload("camoufox"); + }} + isLoading={isBrowserCurrentlyDownloading( + "camoufox", + )} + size="sm" + variant="outline" + disabled={isBrowserCurrentlyDownloading( + "camoufox", + )} + > + {isBrowserCurrentlyDownloading("camoufox") + ? t("common.buttons.downloading") + : t("common.buttons.download")} + +
+ )} {isBrowserCurrentlyDownloading("camoufox") && (
{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")}
)} @@ -1086,7 +1186,7 @@ export function CreateProfileDialog({ !isBrowserCurrentlyDownloading( selectedBrowser, ) && - !isBrowserVersionAvailable(selectedBrowser) && + !getCreatableVersion(selectedBrowser) && getBestAvailableVersion(selectedBrowser) && (

@@ -1122,18 +1222,15 @@ export function CreateProfileDialog({ !isBrowserCurrentlyDownloading( selectedBrowser, ) && - isBrowserVersionAvailable( - selectedBrowser, - ) && ( + getCreatableVersion(selectedBrowser) && (

✓{" "} {t( "createProfile.version.latestAvailable", { version: - getBestAvailableVersion( - selectedBrowser, - )?.version, + getCreatableVersion(selectedBrowser) + ?.version, }, )}
@@ -1432,7 +1529,7 @@ export function CreateProfileDialog({

- Fetching available versions... + {t("createProfile.version.fetching")}

)} @@ -1458,7 +1555,7 @@ export function CreateProfileDialog({ !isBrowserCurrentlyDownloading( selectedBrowser, ) && - !isBrowserVersionAvailable(selectedBrowser) && + !getCreatableVersion(selectedBrowser) && getBestAvailableVersion(selectedBrowser) && (

@@ -1494,16 +1591,15 @@ export function CreateProfileDialog({ !isBrowserCurrentlyDownloading( selectedBrowser, ) && - isBrowserVersionAvailable(selectedBrowser) && ( + getCreatableVersion(selectedBrowser) && (

✓{" "} {t( "createProfile.version.latestAvailable", { version: - getBestAvailableVersion( - selectedBrowser, - )?.version, + getCreatableVersion(selectedBrowser) + ?.version, }, )}
@@ -1701,7 +1797,7 @@ export function CreateProfileDialog({ - + {currentStep === "browser-config" ? ( <> diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index 835a218..cb06452 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -174,42 +174,38 @@ function formatEtaCompact(seconds: number): string { function getToastIcon(type: ToastProps["type"], stage?: string) { switch (type) { case "success": - return ; + return ; case "error": - return ( - - ); + return ; case "download": if (stage === "completed") { - return ( - - ); + return ; } - return ; + return ; case "version-update": return ( - + ); case "fetching": return ( - + ); case "twilight-update": return ( - + ); case "sync-progress": return ( - + ); case "loading": return ( -
+
); default: return ( -
+
); } } @@ -232,7 +228,7 @@ export function UnifiedToast(props: ToastProps) { + )} + +
+ {!isFirst && !isLast && ( + + )} + {isLast ? ( + + ) : requiresAction ? null : ( + + )} +
+
+ + {arrow} +
+ ); +} diff --git a/src/components/onboarding-provider.tsx b/src/components/onboarding-provider.tsx new file mode 100644 index 0000000..31033bb --- /dev/null +++ b/src/components/onboarding-provider.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 73e6732..22a74fe 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -131,9 +131,9 @@ export function PermissionDialog({ const getPermissionIcon = (type: PermissionType) => { switch (type) { case "microphone": - return ; + return ; case "camera": - return ; + return ; } }; @@ -195,13 +195,11 @@ export function PermissionDialog({ -
+ {getPermissionIcon(permissionType)} -
- {getPermissionTitle(permissionType)} - + {getPermissionDescription(permissionType)}
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index fb8bc1a..0427519 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -441,6 +441,7 @@ function DnsCell({ +
+ + + ); +} diff --git a/src/components/ui/color-picker.tsx b/src/components/ui/color-picker.tsx index 1cbd4f3..80831c0 100644 --- a/src/components/ui/color-picker.tsx +++ b/src/components/ui/color-picker.tsx @@ -312,7 +312,7 @@ export const ColorPickerAlpha = ({ 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center', }} > -
+
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 89b55ac..bfe7759 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -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. */} +